Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e058bb44 | |||
| c405f47916 | |||
| 0849268232 | |||
| ab5ea7876b | |||
| a589f51698 | |||
| 40c1023a86 | |||
| 2cc6f1f543 | |||
| a3435c463f | |||
| fcc0aba810 | |||
| cdcd4c4b94 | |||
| 041620e2a5 | |||
| dd367e40ac | |||
| 3d48f5042d | |||
| 71da117335 | |||
| d2eaabe0da | |||
| 95673d5c54 | |||
| a0c20972ac | |||
| 0c8cf7c4cd | |||
| 429e78c722 | |||
| f4f0d67306 | |||
| 4faef8b766 | |||
| fb6f29db76 | |||
| 2bcd5992eb | |||
| d8c06d6cc6 | |||
| 15542e977a | |||
| 5ab0ca78b2 | |||
| 8ab67def5f | |||
| 8944e59ead | |||
| cad84f8a67 | |||
| 33405b7d8b | |||
| 67dea99cd1 | |||
| de851085dd | |||
| af4e9b13ed | |||
| b1e6083996 | |||
| cfc6686904 | |||
| 7f7fc28e57 | |||
| bd8ccbb4a1 | |||
| 40a6076961 | |||
| d072aeeaa0 | |||
| 53a4103308 | |||
| ea7bbebb3e | |||
| 94193d485b | |||
| 265cd7444b | |||
| f1c794654e | |||
| fd9b8640b7 | |||
| 4791729b65 | |||
| d362e1a975 | |||
| 924724c5e4 | |||
| eed5d43e21 | |||
| 5a0554b3b1 | |||
| 82bef38536 | |||
| d050e06870 | |||
| 045c3d8188 | |||
| f3e9f5a3fb | |||
| 922c7bd0d0 | |||
| 3f8bd11a17 | |||
| e2bc4ae6bd | |||
| ef66f687bd | |||
| f5422b1541 |
@@ -0,0 +1,27 @@
|
||||
name: Endoleap brand checks
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'AGENTS.md'
|
||||
- '.gitea/workflows/brand.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.gitea/workflows/brand.yml'
|
||||
concurrency:
|
||||
group: brand-${{ gitea.event.pull_request.number || gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify brand assets, Tailwind compilation and typed exports in Docker
|
||||
run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" .
|
||||
@@ -0,0 +1,47 @@
|
||||
name: creative workspace review checks
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/studio/**'
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- '.gitea/workflows/**'
|
||||
- 'scripts/*studio*'
|
||||
- 'compose.creative-review.yml'
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'scripts/test-integration-cleanup.py'
|
||||
concurrency:
|
||||
group: creative-workspace-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check patch formatting
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh
|
||||
python3 scripts/test-integration-cleanup.py
|
||||
- name: Verify Studio types, tests, build and smoke
|
||||
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
|
||||
- name: Build Wiki integration and runtime images
|
||||
run: |
|
||||
docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" .
|
||||
docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
|
||||
- name: Verify isolated PostgreSQL, S3 and browser integration
|
||||
env:
|
||||
STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/integration.sh
|
||||
- name: Clean this run disposable integration resources even after cancellation
|
||||
if: always()
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
@@ -1,51 +0,0 @@
|
||||
name: studio CI and deployment
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/studio/**'
|
||||
- 'scripts/deploy-studio.sh'
|
||||
- 'scripts/verify-studio-production.sh'
|
||||
- 'scripts/verify-studio-library.mjs'
|
||||
- 'scripts/verify-studio-visual.cjs'
|
||||
- '.gitea/workflows/studio.yml'
|
||||
- 'scripts/*studio*'
|
||||
- 'content/studio-examples/**'
|
||||
- 'content/studio-tools/layouts-api.md'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check shared content and documentation contracts
|
||||
run: |
|
||||
node scripts/check-studio-contracts.mjs
|
||||
node --check scripts/export-studio-series.cjs
|
||||
node --check scripts/save-studio-series.mjs
|
||||
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
|
||||
- name: Build and verify studio
|
||||
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
|
||||
- name: Release studio
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
|
||||
- name: Verify the deployed Studio API and native pages
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
run: bash scripts/verify-studio-production.sh
|
||||
- name: Save server acceptance evidence
|
||||
id: acceptance_evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: studio-production-acceptance
|
||||
path: artifacts/studio-acceptance/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Remove temporary acceptance output after upload
|
||||
if: always() && steps.acceptance_evidence.outcome == 'success'
|
||||
run: rm -rf artifacts/studio-acceptance
|
||||
@@ -1,9 +1,13 @@
|
||||
name: waterfall-wiki CI and deployment
|
||||
name: creative workspace CI and deployment
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- 'apps/studio/**'
|
||||
- 'scripts/*studio*'
|
||||
- 'content/studio-examples/**'
|
||||
- 'content/studio-tools/layouts-api.md'
|
||||
- '.gitea/workflows/waterfall-wiki.yml'
|
||||
- 'Dockerfile'
|
||||
- 'compose.yml'
|
||||
@@ -12,17 +16,29 @@ on:
|
||||
- 'scripts/infra.sh'
|
||||
- 'scripts/auth-admin.sh'
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'scripts/test-integration-cleanup.py'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check shared content and documentation contracts
|
||||
run: |
|
||||
python3 scripts/test-integration-cleanup.py
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh
|
||||
node scripts/check-studio-contracts.mjs
|
||||
node --check scripts/export-studio-series.cjs
|
||||
node --check scripts/save-studio-series.mjs
|
||||
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
|
||||
- name: Build and verify Studio
|
||||
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
|
||||
- name: Build, verify, deploy and retain wiki images
|
||||
env:
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
@@ -32,3 +48,24 @@ jobs:
|
||||
else
|
||||
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
|
||||
fi
|
||||
- name: Release Studio after Wiki routes are deployed
|
||||
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
|
||||
- name: Verify the deployed creative workspace
|
||||
run: bash scripts/verify-studio-production.sh
|
||||
- name: Save server acceptance evidence
|
||||
id: acceptance_evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: studio-production-acceptance
|
||||
path: artifacts/studio-acceptance/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Remove temporary acceptance output after upload
|
||||
if: always() && steps.acceptance_evidence.outcome == 'success'
|
||||
run: rm -rf artifacts/studio-acceptance
|
||||
- name: Clean this run disposable integration resources even after cancellation
|
||||
if: always()
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
|
||||
@@ -18,3 +18,7 @@ __pycache__/
|
||||
*.webp
|
||||
*.gif
|
||||
*.avif
|
||||
|
||||
# Approved, versioned brand resources are package source assets.
|
||||
!packages/brand/assets/**/*.png
|
||||
!packages/brand/docs/logo-preview.png
|
||||
|
||||
@@ -14,3 +14,10 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
|
||||
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
|
||||
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
|
||||
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
|
||||
|
||||
## 啡跃 Endoleap 品牌与主题
|
||||
|
||||
- 共享品牌包位于 [`packages/brand`](packages/brand/README.md)。修改品牌色、主题或标志前,阅读包内的 [品牌边界](packages/brand/docs/overview.md)、[配色与交互规范](packages/brand/docs/colors.md)、[Tailwind / shadcn 接入](packages/brand/docs/integration.md) 和 [Logo 规则](packages/brand/docs/logo.md)。不在仓库根 docs 重复维护一套品牌规范。
|
||||
- 四套正式主题为 home-light、home-dark、canvas-light、canvas-dark;不创建 drafts,不恢复 proposed 命名。画布 Light / Dark 背景保持中性近白 / 近黑,用户素材与作品不整体染色。
|
||||
- 基础色只在 tokens/palette.json 定义,主题引用色板键;Logo 轮廓只在 tokens/geometry.json 定义。保持 CSS、类型声明、资产与清单一致,不通过图片模型重画已固定的图形。
|
||||
- 本地仅运行不生成产物的轻量读取检查。品牌导出、Tailwind/TypeScript 编译和 SVG 栅格化检查必须通过已确认的 ces-control Docker CI 执行,不在本机启动预览或构建。
|
||||
|
||||
@@ -81,14 +81,14 @@ bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
|
||||
|
||||
## 部署与持久化
|
||||
|
||||
Gitea 工作流按应用路径触发:`waterfall-wiki.yml`、`studio.yml` 和 `python.yml`。waterfall-wiki 完成类型检查、单元测试、生产构建、冒烟和真实 PostgreSQL/S3 集成测试后部署;Studio 独立构建和部署。文档与作者数据本身不触发应用部署。
|
||||
Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。
|
||||
|
||||
| 服务 | 入口/端口 |
|
||||
| --- | --- |
|
||||
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
|
||||
| PostgreSQL | `127.0.0.1:18132` |
|
||||
| 对象存储 | `127.0.0.1:18133` |
|
||||
| Studio | `127.0.0.1:18134`;公网通过 `/studio` 代理 |
|
||||
| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 |
|
||||
| Python | 无网络端口,按需运行 |
|
||||
|
||||
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
|
||||
|
||||
@@ -11,6 +11,7 @@ COPY apps/studio/ .
|
||||
# Compare the two public parameter validators inside the isolated CI build.
|
||||
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts
|
||||
COPY apps/waterfall-wiki/src/lib/series.ts /waterfall-wiki/src/lib/series.ts
|
||||
COPY apps/waterfall-wiki/src/lib/canvas-document.ts /waterfall-wiki/src/lib/canvas-document.ts
|
||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||
FROM node:24-bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
@@ -2,7 +2,13 @@ const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
|
||||
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://127.0.0.1:3011')+'/studio/graphic');
|
||||
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
|
||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
||||
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
|
||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
||||
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
|
||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
||||
await page.getByRole('button',{name:'添加文字',exact:true}).click();
|
||||
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
|
||||
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
|
||||
|
||||
@@ -1,4 +1,110 @@
|
||||
// Run against an already started Studio. BASE_URL is the origin, including port.
|
||||
// Isolated browser context against the designated deployed server only.
|
||||
const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto((process.env.BASE_URL||'http://localhost:3018')+'/studio/infinite_canvas');await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'添加笔记',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByLabel('节点名称').fill('Smoke note');await page.getByLabel('笔记内容').fill('A saved idea');await page.reload();await page.getByText('Smoke note',{exact:true}).first().waitFor();assert.equal(await page.locator('article').count(),4);await page.getByText('Smoke note',{exact:true}).first().click();await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByRole('button',{name:'适应画布'}).click();const card=page.locator('article').last();const before=await card.getAttribute('style');const box=await card.locator('div').first().boundingBox();await page.mouse.move(box.x+70,box.y+20);await page.mouse.down();await page.mouse.move(box.x+120,box.y+45,{steps:5});await page.mouse.up();assert.notEqual(await card.getAttribute('style'),before);const download=page.waitForEvent('download');await page.getByRole('button',{name:'导出项目'}).click();assert.equal((await download).suggestedFilename(),'studio-canvas.json');if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-desktop.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png',fullPage:true});assert.deepEqual(errors,[]);console.log('Canvas browser smoke passed: create, edit, restore, delete, undo, fit, export, mobile.')}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
const fs=require('node:fs/promises');
|
||||
(async()=>{
|
||||
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
|
||||
assert.equal(base,'http://8.211.154.134:18131');
|
||||
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
|
||||
let page;
|
||||
try{
|
||||
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
|
||||
page=await context.newPage();const errors=[];let choosers=0;
|
||||
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
|
||||
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
|
||||
const node=id=>page.locator(`[data-node-id="${id}"]`);
|
||||
const content=id=>node(id).locator('[data-node-content]');
|
||||
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
|
||||
const composer=page.getByRole('region',{name:'节点编辑器'});
|
||||
const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
|
||||
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
|
||||
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
|
||||
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
|
||||
await page.goto(base+'/infinite_canvas');
|
||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false');
|
||||
await page.waitForTimeout(700);
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑');
|
||||
await waitField('brief','text','选择后再次点击进入编辑');
|
||||
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
|
||||
await node('brief').getByRole('button',{name:'斜体',exact:true}).click();
|
||||
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
|
||||
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
|
||||
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
|
||||
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
|
||||
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
|
||||
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
|
||||
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
|
||||
await capture('canvas-text-format-desktop');
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
|
||||
await page.keyboard.press('Escape');
|
||||
const before=(await snapshot()).cards.find(n=>n.id==='brief');
|
||||
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
|
||||
await drag(content('brief'),45,25);
|
||||
const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z);
|
||||
assert.equal(await node('brief').getAttribute('data-editing'),'false');
|
||||
|
||||
await content('reference').click();assert.equal(await composer.count(),0);
|
||||
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
|
||||
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
|
||||
await capture('canvas-composer-desktop');
|
||||
await page.getByRole('button',{name:'缩小',exact:true}).click();
|
||||
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
|
||||
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
|
||||
const chooser=page.waitForEvent('filechooser');await composer.getByRole('button',{name:'上传图片',exact:true}).click();
|
||||
await (await chooser).setFiles({name:'reference.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')});
|
||||
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
|
||||
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
|
||||
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
|
||||
await unobstructed(upscale.id);
|
||||
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
|
||||
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
|
||||
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
|
||||
|
||||
await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
|
||||
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
|
||||
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
|
||||
await unobstructed(script.id);
|
||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
|
||||
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
|
||||
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
|
||||
await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click();
|
||||
assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
|
||||
await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click();
|
||||
assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id));
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click();
|
||||
await page.getByRole('button',{name:'删除节点',exact:true}).click();
|
||||
assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference')));
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
|
||||
await page.keyboard.press('Escape');
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width;
|
||||
await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30);
|
||||
assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320));
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth);
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();
|
||||
await page.getByRole('button',{name:'移动画布',exact:true}).click();
|
||||
await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up();
|
||||
assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true);
|
||||
const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}});
|
||||
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
|
||||
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
|
||||
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
|
||||
const saved=await snapshot();await page.reload();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
|
||||
await page.getByRole('button',{name:'切换明暗主题',exact:true}).click();await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light');
|
||||
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
|
||||
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
|
||||
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
|
||||
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload and mobile bounds');
|
||||
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
|
||||
@@ -6,13 +6,13 @@ const standardId='00000000-0000-4000-8000-000000000003';
|
||||
const seriesId='00000000-0000-4000-8000-000000000004';
|
||||
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
|
||||
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
|
||||
let reads=0;
|
||||
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
|
||||
let reads=0,mediaReads=0;
|
||||
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
|
||||
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
|
||||
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
assert.equal(reads,0,'Default pages never load a saved design');
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
|
||||
const headers={cookie:'session=smoke-test'};
|
||||
for(const id of [designId,standardId]){
|
||||
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {forwardRef,useEffect,useImperativeHandle,useRef,useState} from 'react';
|
||||
import {parseCanvasDocument,type CanvasDocument} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-agent.module.css';
|
||||
type Task={id:string;targetId:string;status:'queued'|'running'|'succeeded'|'failed';error?:string};
|
||||
type State={id:string;name:string;revision:number;document:CanvasDocument;tasks:Task[]};
|
||||
type LinkState={expiresAt:string;lastSeenAt:string|null};
|
||||
export type CanvasAgentHandle={queue:(target:string)=>Promise<void>};
|
||||
type Props={document:CanvasDocument;email:string;onLoad:(document:CanvasDocument)=>void;onFeedback:(text:string)=>void};
|
||||
async function api(path:string,method='GET',body?:unknown){const r=await fetch('/api/canvases'+path,{method,headers:body?{'Content-Type':'application/json'}:undefined,...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.error||'在线画布暂时不可用');return data;}
|
||||
const names={queued:'等待 Agent',running:'Agent 正在处理',succeeded:'已完成',failed:'已结束'};
|
||||
export default forwardRef<CanvasAgentHandle,Props>(function CanvasAgentPanel(p,ref){
|
||||
const [open,setOpen]=useState(false),[busy,setBusy]=useState(false),[message,setMessage]=useState(''),[remote,setRemote]=useState<State|null>(null),[pending,setPending]=useState<State|null>(null),[connection,setConnection]=useState<LinkState|null>(null),[credential,setCredential]=useState(''),[items,setItems]=useState<{id:string;name:string}[]>([]);
|
||||
const live=useRef(p.document),synced=useRef(''),current=useRef<State|null>(null),locked=useRef(false),account=useRef(p.email),generation=useRef(0),credentialField=useRef<HTMLTextAreaElement>(null);
|
||||
live.current=p.document;account.current=p.email;
|
||||
function update(state:State){current.current=state;setRemote(state);}
|
||||
function apply(state:State){state={...state,document:parseCanvasDocument(state.document)};synced.current=JSON.stringify(state.document);update(state);setPending(null);p.onLoad(state.document);}
|
||||
async function run(action:()=>Promise<void>){if(locked.current)return;locked.current=true;setBusy(true);try{await action()}catch(e){const text=e instanceof Error?e.message:'操作失败';setMessage(text);p.onFeedback(text)}finally{locked.current=false;setBusy(false)}}
|
||||
useEffect(()=>{generation.current++;current.current=null;synced.current='';setRemote(null);setPending(null);setConnection(null);setCredential('');setItems([]);if(!p.email)return;const epoch=generation.current;void api('').then(data=>{if(epoch===generation.current)setItems(data.canvases)}).catch(()=>{});},[p.email]);
|
||||
useEffect(()=>{if(!remote||!p.email)return;const id=remote.id,epoch=generation.current;let cancelled=false;const poll=async()=>{try{const state:State=await api('/'+id);if(cancelled||epoch!==generation.current||current.current?.id!==id||locked.current)return;if(state.revision!==current.current.revision){if(JSON.stringify(live.current)===synced.current){apply(state);setMessage('已同步 Agent 的画布更新')}else{setPending(state);setMessage('在线画布有更新,请选择要保留的版本')}}else update(state);const link=await api('/'+id+'/link');if(!cancelled&&epoch===generation.current)setConnection(link.link)}catch(e){if(!cancelled)setMessage(e instanceof Error?e.message:'同步失败')}};void poll();const timer=setInterval(()=>void poll(),5000);return()=>{cancelled=true;clearInterval(timer)};},[remote?.id,p.email]);
|
||||
async function save():Promise<State>{if(!account.current)throw Error('请先登录邮箱账户');if(pending)throw Error('请先处理在线版本冲突');const doc=live.current,serialized=JSON.stringify(doc),old=current.current;const state:State=old?serialized===synced.current?old:await api('/'+old.id,'PUT',{revision:old.revision,document:doc}):await api('','POST',{document:doc,name:'灵感画布 '+new Date().toLocaleDateString()});if(account.current!==p.email)throw Error('账户已切换,请重新连接');synced.current=serialized;update(state);setMessage('已保存在线画布');return state;}
|
||||
useImperativeHandle(ref,()=>({queue:async target=>{setOpen(true);await run(async()=>{const state=await save();if(JSON.stringify(live.current)!==synced.current)throw Error('画布刚有修改,请重新派发任务');const task:Task=await api('/'+state.id+'/tasks','POST',{revision:state.revision,targetId:target});if(account.current!==p.email)return;update({...state,tasks:[task,...state.tasks]});setMessage('任务已创建,等待你的 coding agent 领取');p.onFeedback('任务已创建,等待 Agent')})}}));
|
||||
async function connect(){const state=await save(),link=await api('/'+state.id+'/link','POST',{});if(account.current!==p.email)return;setConnection({expiresAt:link.expiresAt,lastSeenAt:null});setCredential(JSON.stringify({canvasUrl:location.origin+'/api/canvases/'+state.id,authorization:'Bearer '+link.token,documentation:location.origin+link.documentation,expiresAt:link.expiresAt},null,2));setMessage('连接凭据已生成,有效期 1 小时');}
|
||||
async function copyCredential(){
|
||||
if(navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(credential);setMessage('凭据已复制');return}catch{}}
|
||||
const field=credentialField.current;if(!field)return;field.focus();field.select();
|
||||
let copied=false;try{copied=document.execCommand('copy')}catch{}
|
||||
setMessage(copied?'凭据已复制':'已选中凭据,请按 Ctrl+C 或 ⌘C 复制');
|
||||
}
|
||||
const dirty=!!remote&&JSON.stringify(p.document)!==synced.current;
|
||||
return <div className={s.root}><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}>Agent{remote?' · 在线画布':''}</button>{open&&<section className={s.panel} aria-label="Agent 连接"><header><strong>你的 coding agent</strong><button aria-label="关闭 Agent 连接" onClick={()=>setOpen(false)}>×</button></header>{!p.email?<a href="/account">登录后连接在线画布 ↗</a>:<>
|
||||
<div className={s.actions}><button disabled={busy||!!pending} onClick={()=>void run(async()=>{await save()})}>{remote?(dirty?'保存修改':'已同步'):'保存为在线画布'}</button><button disabled={busy||!!pending} onClick={()=>void run(connect)}>生成连接凭据</button>{connection&&<button disabled={busy} onClick={()=>void run(async()=>{await api('/'+current.current!.id+'/link','DELETE',{});setConnection(null);setCredential('');setMessage('Agent 访问已撤销')})}>撤销连接</button>}</div>
|
||||
{!remote&&<label>打开在线画布<select aria-label="打开在线画布" defaultValue="" disabled={busy} onChange={e=>{const id=e.target.value;if(id)void run(async()=>{const state:State=await api('/'+id);parseCanvasDocument(state.document);setPending(state);setMessage('加载在线画布将替换当前草稿,请先导出需要保留的内容')})}}><option value="">选择画布</option>{items.map(item=><option value={item.id} key={item.id}>{item.name} · {item.id.slice(0,8)}</option>)}</select></label>}
|
||||
{connection&&<small>{connection.lastSeenAt?'Agent 最近访问:'+new Date(connection.lastSeenAt).toLocaleTimeString():'等待 Agent 首次访问'} · 到期 {new Date(connection.expiresAt).toLocaleTimeString()}</small>}
|
||||
{credential&&<><textarea ref={credentialField} aria-label="Agent 连接凭据" readOnly value={credential}/><button onClick={()=>void copyCredential()}>复制给 coding agent</button></>}
|
||||
{pending&&<div className={s.conflict}><button disabled={busy} onClick={()=>{apply(pending);setMessage('已载入在线版本')}}>载入在线版本</button><button disabled={busy} onClick={()=>{setPending(null);current.current=null;setRemote(null);synced.current='';setConnection(null);setCredential('');setMessage('已保留本机草稿,可另存为新画布')}}>保留草稿并断开</button></div>}
|
||||
{!!remote?.tasks.length&&<ul aria-label="画布任务">{remote.tasks.slice(0,8).map(task=><li key={task.id}><span>{p.document.cards.find(n=>n.id===task.targetId)?.title||'已移除节点'} · {names[task.status]}</span>{task.error&&<small>{task.error}</small>}{['queued','running'].includes(task.status)&&<button disabled={busy} onClick={()=>void run(async()=>{await api('/'+remote.id+'/tasks/'+task.id,'PATCH',{status:'failed',error:'用户结束任务'});setMessage('任务已结束,可修改后重新派发')})}>结束任务</button>}</li>)}</ul>}
|
||||
</>}<p role="status">{message}</p><a href="/api/canvases?agent_mode=True" target="_blank" rel="noreferrer">接入文档 ↗</a></section>}</div>
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ImagePlus,FileText,Plus,X,Maximize,Download,ArrowUp} from 'lucide-react';
|
||||
import type {CanvasNode} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-composer.module.css';
|
||||
type Props={pickerRequest:number;feedback:string;node:CanvasNode;nodes:CanvasNode[];references:CanvasNode[];onChange:(text:string)=>void;onRename:(title:string)=>void;onBeginEdit:()=>void;onUpload:()=>void;onAddReference:(id:string)=>void;onRemoveReference:(id:string)=>void;onQueue:()=>void;onExport:()=>void;onClose:()=>void};
|
||||
export default function CanvasComposer(p:Props){const [expanded,expand]=useState(false),[choosing,choose]=useState(false);useEffect(()=>{if(p.pickerRequest)choose(true)},[p.pickerRequest]);return <section className={s.composer} data-expanded={expanded} aria-label="节点编辑器"><header><span>{p.node.kind==='image'?<ImagePlus size={16}/>:<FileText size={16}/>} <input aria-label="节点名称" maxLength={200} value={p.node.title} onFocus={p.onBeginEdit} onChange={e=>p.onRename(e.target.value)}/></span><div>{p.node.kind==='image'&&<button onClick={p.onUpload}><Plus size={15}/>上传图片</button>}<button aria-expanded={choosing} onClick={()=>choose(v=>!v)}><Plus size={15}/>画布参考</button><button aria-label="展开编辑器" aria-pressed={expanded} onClick={()=>expand(v=>!v)}><Maximize size={16}/></button><button aria-label="关闭节点编辑器" onClick={p.onClose}><X size={17}/></button></div></header>
|
||||
{choosing&&<div className={s.picker} role="group" aria-label="选择引用节点">{p.nodes.filter(n=>n.id!==p.node.id&&!p.references.some(r=>r.id===n.id)).map(n=><button key={n.id} onClick={()=>{p.onAddReference(n.id);choose(false)}}>{n.kind==='image'?<ImagePlus size={15}/>:<FileText size={15}/>} {n.title}</button>)}{p.nodes.length<2&&<span>先在画布上添加一个参考节点</span>}</div>}
|
||||
{!!p.references.length&&<div className={s.references}>{p.references.map((n,i)=><div className={s.reference} key={n.id}>{n.image?<img src={n.image} alt={n.title}/>:<FileText size={24}/>}<span>{i+1}</span><small>{n.title}</small><button aria-label={`移除引用 ${n.title}`} onClick={()=>p.onRemoveReference(n.id)}><X size={12}/></button></div>)}</div>}
|
||||
<textarea aria-label="创作要求" maxLength={10000} value={p.node.instruction??p.node.text} onFocus={p.onBeginEdit} onChange={e=>p.onChange(e.target.value)} placeholder={p.node.kind==='script'?'描述剧情片段、故事或分镜要求…':'描述画面,或写下对参考图的修改要求…'}/><footer><span role="status">{p.feedback||<>{p.node.operation==='upscale'?'图片高清':p.node.kind==='script'?'分镜脚本':'创作任务'} · {p.references.length} 个引用</>}</span><div><button onClick={p.onExport}><Download size={15}/>导出任务</button><button onClick={p.onQueue}><ArrowUp size={15}/>交给 Agent</button></div></footer></section>}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useRef,type PointerEvent} from 'react';
|
||||
import {Type,ImagePlus,FileText,Plus,Bold,Italic,Maximize,MoreHorizontal} from 'lucide-react';
|
||||
import type {CanvasNode,TextStyle} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-node.module.css';
|
||||
type Props={node:CanvasNode;selected:boolean;editing:boolean;hand:boolean;onSelect:()=>void;onChooseReference:()=>void;onEdit:()=>void;onDrag:(e:PointerEvent<HTMLElement>)=>void;onResize:(e:PointerEvent<HTMLElement>)=>void;onChange:(text:string)=>void;onRename:(title:string)=>void;onStyle:(style:TextStyle)=>void;onReference:(e:PointerEvent<HTMLButtonElement>|React.MouseEvent<HTMLButtonElement>)=>void;onMenu:(e:React.MouseEvent<HTMLButtonElement>)=>void;onUpload:()=>void;onUpscale:()=>void;onExpand:()=>void;onBeginEdit:()=>void};
|
||||
export default function CanvasNodeView(p:Props){
|
||||
const {node:n}=p;const gesture=useRef<{selected:boolean;x:number;y:number;moved:boolean}|null>(null);
|
||||
const icon=n.kind==='image'?<ImagePlus size={15}/>:n.kind==='script'?<FileText size={15}/>:<Type size={15}/>;
|
||||
const style={fontWeight:n.style?.bold?700:400,fontStyle:n.style?.italic?'italic':'normal',fontSize:n.style?.size||15,color:n.style?.color};
|
||||
return <article data-node-id={n.id} data-editing={p.editing} data-selected={p.selected} className={s.node} style={{left:n.x,top:n.y,width:n.width||320,height:n.height||350}} onPointerDown={e=>{gesture.current=null;if(p.hand||(p.editing&&n.kind==='text')||e.button!==0||(e.target as HTMLElement).closest('button,input,select'))return;gesture.current={selected:p.selected,x:e.clientX,y:e.clientY,moved:false};e.stopPropagation();p.onSelect();p.onDrag(e)}} onPointerMove={e=>{const g=gesture.current;if(g&&Math.hypot(e.clientX-g.x,e.clientY-g.y)>=4)g.moved=true}} onClick={e=>{if((e.target as HTMLElement).closest('button,input,select'))return;const g=gesture.current;if(!p.hand&&!p.editing&&g?.selected&&!g.moved)p.onEdit();gesture.current=null}} onKeyDown={e=>{if(e.target!==e.currentTarget)return;if(e.key==='Enter'){e.preventDefault();p.onEdit()}}} tabIndex={0} aria-label={n.title}>
|
||||
<header className={s.title}>{icon}<input aria-label="节点名称" value={n.title} maxLength={200} onFocus={p.onBeginEdit} onChange={e=>p.onRename(e.target.value)} onPointerDown={e=>e.stopPropagation()}/>{p.selected&&<button aria-label={`${n.title} 操作`} onClick={e=>{e.stopPropagation();p.onMenu(e)}}><MoreHorizontal size={17}/></button>}</header>
|
||||
{p.editing&&n.kind==='text'&&<div className={s.format} onPointerDown={e=>e.stopPropagation()}><input aria-label="文字颜色" type="color" value={n.style?.color||'#dddddd'} onChange={e=>p.onStyle({...n.style,color:e.target.value})}/><select aria-label="文字样式" value={n.style?.size||15} onChange={e=>p.onStyle({...n.style,size:Number(e.target.value)})}><option value={15}>正文</option><option value={24}>小标题</option><option value={32}>标题</option></select><button aria-label="加粗" aria-pressed={!!n.style?.bold} onClick={()=>p.onStyle({...n.style,bold:!n.style?.bold})}><Bold size={17}/></button><button aria-label="斜体" aria-pressed={!!n.style?.italic} onClick={()=>p.onStyle({...n.style,italic:!n.style?.italic})}><Italic size={17}/></button><button aria-label="展开文本节点" onClick={p.onExpand}><Maximize size={17}/></button></div>}
|
||||
{n.kind==='image'?<div data-node-content className={s.image}>{n.image?<img src={n.image} alt={n.title} draggable={false}/>:<div className={s.empty}><ImagePlus size={48} strokeWidth={1}/><button onClick={e=>{e.stopPropagation();p.onUpload()}}>上传图片</button></div>}{p.selected&&n.image&&<button className={s.upscale} onClick={e=>{e.stopPropagation();p.onUpscale()}}>图片高清 ↗</button>}</div>:p.editing&&n.kind==='text'?<textarea data-node-content autoFocus aria-label={`${n.title} 内容`} style={style} value={n.text} maxLength={10000} placeholder="输入内容…" onFocus={p.onBeginEdit} onChange={e=>p.onChange(e.target.value)}/>:<div data-node-content className={s.content} style={style}>{n.text?<p>{n.text}</p>:<div className={s.empty}>{icon}<button onClick={e=>{e.stopPropagation();p.onEdit()}}>{n.kind==='text'?'自己编写内容':n.kind==='script'?'编写脚本':'填写创作要求'}</button></div>}</div>}
|
||||
{p.selected&&<><button className={`${s.reference} ${s.incoming}`} aria-label={`为 ${n.title} 添加参考`} onClick={e=>{e.stopPropagation();p.onChooseReference()}}><Plus size={18}/></button><button className={s.reference} aria-label={`引用 ${n.title} 生成`} onClick={e=>{e.stopPropagation();p.onReference(e)}}><Plus size={18}/></button><button className={s.resize} aria-label={`调整 ${n.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();gesture.current=null;p.onResize(e)}} onClick={e=>e.stopPropagation()}>↘</button></>}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export type NodeKind = 'text' | 'image' | 'prompt' | 'script';
|
||||
export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number};
|
||||
export type CanvasNode = {
|
||||
id:string;kind:NodeKind;title:string;text:string;x:number;y:number;
|
||||
image?:string;width?:number;height?:number;style?:TextStyle;
|
||||
instruction?:string;
|
||||
operation?:'generate'|'upscale'|'script';
|
||||
};
|
||||
export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]};
|
||||
const kinds:NodeKind[]=['text','image','prompt','script'];
|
||||
const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
||||
const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max;
|
||||
const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max;
|
||||
const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v);
|
||||
|
||||
export function parseCanvasDocument(value:unknown):CanvasDocument {
|
||||
const d=typeof value==='string'?JSON.parse(value):value;
|
||||
if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确');
|
||||
const ids=new Set<string>();
|
||||
const cards:CanvasNode[]=d.cards.map((n:unknown)=>{
|
||||
if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确');
|
||||
if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确');
|
||||
if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确');
|
||||
if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确');
|
||||
let style:TextStyle|undefined;
|
||||
if(n.style!==undefined){
|
||||
if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确');
|
||||
style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})};
|
||||
}
|
||||
ids.add(n.id as string);
|
||||
return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})};
|
||||
});
|
||||
const pairs=new Set<string>();
|
||||
const links:[string,string][]=d.links.map((l:unknown)=>{
|
||||
if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在');
|
||||
const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]];
|
||||
});
|
||||
const outgoing=new Map<string,string[]>(),indegree=new Map(cards.map(n=>[n.id,0]));
|
||||
for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)}
|
||||
const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0;
|
||||
while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}}
|
||||
if(visited!==cards.length)throw Error('引用不能形成循环');
|
||||
return {version:1,cards,links};
|
||||
}
|
||||
|
||||
export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{
|
||||
return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean);
|
||||
}
|
||||
export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument {
|
||||
if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点');
|
||||
if(doc.links.some(([a,b])=>a===from&&b===to))return doc;
|
||||
if(doc.links.length>=200)throw Error('最多 200 条引用');
|
||||
const seen=new Set<string>();const pending=[to];
|
||||
while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));}
|
||||
return {...doc,links:[...doc.links,[from,to]]};
|
||||
}
|
||||
export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
|
||||
return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))};
|
||||
}
|
||||
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
|
||||
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
|
||||
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
|
||||
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
|
||||
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
|
||||
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
|
||||
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
|
||||
target.y=nextY>100000?-100000:nextY;
|
||||
}
|
||||
throw Error('请移动现有节点后再创建引用节点');
|
||||
}
|
||||
export function agentTask(doc:CanvasDocument,id:string){
|
||||
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
|
||||
const instruction=node.instruction??node.text;
|
||||
if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求');
|
||||
const references=referencedNodes(doc,id);
|
||||
if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片');
|
||||
return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type {Parameters} from './layout-parameters';
|
||||
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
|
||||
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
for(const id of ids){const url='http://waterfall-wiki:3000/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||
return sources;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function embedSvgMedia(req:NextApiRequest,svg:string){
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
let total=0;
|
||||
for(const path of paths){
|
||||
const response=await fetch('http://waterfall-wiki:3000'+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
|
||||
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
|
||||
const mime=response.headers.get('content-type')?.split(';')[0];
|
||||
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
|
||||
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {ideas} from '../../content/ideas';
|
||||
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
|
||||
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [在线画布、引用与 Agent 任务](/api/canvases?agent_mode=True)\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useRef,useState,type PointerEvent} from 'react';
|
||||
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X} from 'lucide-react';
|
||||
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X,Hand,Link2,BookOpen,PanelLeft,PanelRight} from 'lucide-react';
|
||||
import {newElementId} from '../../lib/native-layout';
|
||||
import styles from '../../styles/graphic.module.css';
|
||||
import {parseGraphicDocument,type GraphicItem as Item,type GraphicDocument as Doc} from '../../lib/graphic-document';
|
||||
@@ -8,6 +8,7 @@ const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]};
|
||||
const storage='studio.graphic.v1';
|
||||
export default function Graphic(){
|
||||
const [doc,setDoc]=useState<Doc>(blank),[selected,setSelected]=useState(''),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[zoom,setZoom]=useState(.65),[status,setStatus]=useState('本机草稿'),[tab,setTab]=useState('图层'),[user,setUser]=useState<{email:string}|null>(null),[auth,setAuth]=useState(false),[mode,setMode]=useState('login'),[message,setMessage]=useState(''),[busy,setBusy]=useState(false);
|
||||
const [tool,setTool]=useState('select'),[offset,setOffset]=useState({x:0,y:0}),[showLayers,setShowLayers]=useState(true),[showProps,setShowProps]=useState(true);
|
||||
const dialog=useRef<HTMLDivElement>(null);
|
||||
useEffect(()=>{if(!auth)return;const old=document.activeElement as HTMLElement;const box=dialog.current;box?.querySelector<HTMLElement>('button,input')?.focus();function key(e:KeyboardEvent){if(e.key==='Escape'){setAuth(false);return}if(e.key!=='Tab'||!box)return;const nodes=Array.from(box.querySelectorAll<HTMLElement>('button:not(:disabled),input,a[href]'));const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus()}}document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);old?.focus()}},[auth]);
|
||||
const restore=useRef<HTMLInputElement>(null);
|
||||
@@ -24,17 +25,19 @@ export default function Graphic(){
|
||||
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1));}
|
||||
function remove(){if(item&&!item.locked){change({...doc,items:doc.items.filter(i=>i.id!==selected)});setSelected('')}}
|
||||
function duplicate(){if(item&&doc.items.length<500){const id=newElementId();change({...doc,items:[...doc.items,{...item,id,x:item.x+24,y:item.y+24,name:item.name+' 副本',locked:false}]});setSelected(id)}}
|
||||
function fit(){if(viewport.current)setZoom(Math.max(.15,Math.min(1,(viewport.current.clientWidth-100)/800,(viewport.current.clientHeight-100)/1000)))}
|
||||
function fit(){setOffset({x:0,y:0});if(viewport.current)setZoom(Math.max(.15,Math.min(1,(viewport.current.clientWidth-100)/800,(viewport.current.clientHeight-100)/1000)))}
|
||||
useEffect(()=>{fit();},[]);
|
||||
useEffect(()=>{function key(e:KeyboardEvent){if((e.target as HTMLElement).closest('input,textarea,select,button'))return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape')setSelected('');else if(item&&!item.locked&&e.key.startsWith('Arrow')){e.preventDefault();const step=e.shiftKey?10:1;patch({x:item.x+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:item.y+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)});
|
||||
function drag(e:PointerEvent<HTMLDivElement>,i:Item){e.stopPropagation();setSelected(i.id);if(i.locked||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const x=e.clientX,y=e.clientY,start=doc;let next=start;const target=e.currentTarget;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(n=>n.id===i.id?{...n,x:Math.max(-4000,Math.min(4000,i.x+(p.clientX-x)/zoom)),y:Math.max(-4000,Math.min(4000,i.y+(p.clientY-y)/zoom))}:n)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);}
|
||||
useEffect(()=>{function key(e:KeyboardEvent){if((e.target as HTMLElement).closest('input,textarea,select,button'))return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape')setSelected('');else if(!e.ctrlKey&&!e.metaKey&&e.key.toLowerCase()==='v')setTool('select');else if(!e.ctrlKey&&!e.metaKey&&e.key.toLowerCase()==='h')setTool('hand');else if(item&&!item.locked&&e.key.startsWith('Arrow')){e.preventDefault();const step=e.shiftKey?10:1;patch({x:item.x+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:item.y+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)});
|
||||
function drag(e:PointerEvent<HTMLDivElement>,i:Item){if(tool==='hand'){panBoard(e);return}e.stopPropagation();setSelected(i.id);if(i.locked||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const x=e.clientX,y=e.clientY,start=doc;let next=start;const target=e.currentTarget;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(n=>n.id===i.id?{...n,x:Math.max(-4000,Math.min(4000,i.x+(p.clientX-x)/zoom)),y:Math.max(-4000,Math.min(4000,i.y+(p.clientY-y)/zoom))}:n)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);}
|
||||
function resize(e:PointerEvent<HTMLElement>){e.stopPropagation();if(!item||item.locked)return;e.preventDefault();const start=doc,x=e.clientX,y=e.clientY,target=e.currentTarget;target.setPointerCapture(e.pointerId);let next=start;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(i=>i.id===item.id?{...i,w:Math.max(1,Math.min(4000,item.w+(p.clientX-x)/zoom)),h:Math.max(1,Math.min(4000,item.h+(p.clientY-y)/zoom))}:i)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
|
||||
function panBoard(e:PointerEvent<HTMLElement>){if(tool!=='hand')return;e.preventDefault();const x=e.clientX,y=e.clientY,start=offset,target=e.currentTarget;target.setPointerCapture(e.pointerId);const move=(p:globalThis.PointerEvent)=>setOffset({x:start.x+p.clientX-x,y:start.y+p.clientY-y});const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end)};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
|
||||
function download(content:string,type:string,extension:string){const url=URL.createObjectURL(new Blob([content],{type}));const a=document.createElement('a');a.href=url;a.download=doc.name+'.'+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
function exportSVG(){const esc=(s:string)=>s.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`<text x="${i.x}" y="${i.y+i.size}" font-family="sans-serif" font-size="${i.size}" fill="${esc(i.fill)}">${i.text.split('\n').map((line,n)=>`<tspan x="${i.x}" dy="${n?i.size*1.3:0}">${esc(line)}</tspan>`).join('')}</text>`:i.kind==='image'?`<image x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" href="${esc(i.src||'')}" preserveAspectRatio="xMidYMid slice"/>`:i.kind==='ellipse'?`<ellipse cx="${i.x+i.w/2}" cy="${i.y+i.h/2}" rx="${i.w/2}" ry="${i.h/2}" fill="${esc(i.fill)}"/>`:`<rect x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" fill="${esc(i.fill)}"/>`).join('');download(`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000"><rect width="800" height="1000" fill="${esc(doc.background)}"/>${svg}</svg>`,'image/svg+xml','svg');}
|
||||
async function account(e:React.FormEvent<HTMLFormElement>){e.preventDefault();setBusy(true);setMessage('');const data=new FormData(e.currentTarget);try{const r=await fetch('/api/account/'+mode,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:data.get('email'),password:data.get('password')})});const d=await r.json();setMessage(d.error||d.message||'登录成功');if(r.ok){await refresh();if(mode==='login')setAuth(false);}}catch{setMessage('账户服务暂时无法连接')}finally{setBusy(false)}}
|
||||
return <div className={styles.app}><Head><title>{doc.name + " · Studio Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
|
||||
<header className={styles.top}><a href="/studio/" className={styles.brand}><span>✳</span> studio<span className={styles.badge}>GRAPHIC</span></a><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
|
||||
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/studio/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
|
||||
<main ref={viewport} className={styles.viewport} onPointerDown={()=>setSelected('')}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" onClick={()=>setSelected('')}><MousePointer2 size={20}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
|
||||
return <div className={styles.app} data-layers={showLayers} data-properties={showProps}><Head><title>{doc.name + " · Studio Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
|
||||
<header className={styles.top}><a href="/workspace" className={styles.brand}><span>✳</span> studio<span className={styles.badge}>GRAPHIC</span></a><button aria-label="切换图层面板" aria-pressed={showLayers} onClick={()=>setShowLayers(v=>!v)}><PanelLeft size={17}/></button><button aria-label="切换属性面板" aria-pressed={showProps} onClick={()=>setShowProps(v=>!v)}><PanelRight size={17}/></button><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
|
||||
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材','Agent'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='Agent'?<div className={styles.agentPanel}><Link2 size={24}/><h3>与你的 Agent 一起设计</h3><p>将接入文档交给 coding agent,使用已有的模板与创作 API。</p><a href="/studio/api" target="_blank" rel="noreferrer">查看接入文档 ↗</a><a href="/documents" target="_blank" rel="noreferrer"><BookOpen size={15}/>知识与教程 ↗</a><small>在 Agent 中打开文档,查看可用的创作接口与使用方式。</small></div>:tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
|
||||
<main ref={viewport} className={styles.viewport} onPointerDown={e=>{setSelected('');panBoard(e)}}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom,transform:`translate(${offset.x}px,${offset.y}px)`}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner} onPointerDown={resize}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>{setTool('select');setSelected('')}}><MousePointer2 size={20}/></button><button title="抓手工具" aria-label="抓手工具" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand size={19}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
|
||||
<aside className={styles.right}><div className={styles.inspectorTitle}>设计属性<span>Design</span></div>{item?<div className={styles.props}><label>图层名称<input maxLength={200} value={item.name} disabled={item.locked} onChange={e=>patch({name:e.target.value})}/></label><h3>布局</h3><div className={styles.fields}>{(['x','y','w','h'] as const).map(k=><label key={k}>{({x:'X',y:'Y',w:'宽',h:'高'})[k]}<input type="number" value={Math.round(item[k])} disabled={item.locked} onChange={e=>{const value=Number(e.target.value);if(Number.isFinite(value))patch({[k]:k==='w'||k==='h'?Math.max(1,Math.min(4000,value)):Math.max(-4000,Math.min(4000,value))})}}/></label>)}</div>{item.kind==='text'&&<><h3>文字</h3><textarea aria-label="文字内容" value={item.text} maxLength={4000} disabled={item.locked} onChange={e=>patch({text:e.target.value})}/><label>字号<input type="number" min={8} max={240} value={item.size} disabled={item.locked} onChange={e=>patch({size:Math.max(8,Math.min(240,Number(e.target.value)||8))})}/></label></>}<h3>填色</h3><label className={styles.color}><input type="color" value={item.fill} disabled={item.locked} onChange={e=>patch({fill:e.target.value})}/>{item.fill.toUpperCase()}</label><div className={styles.actions}><button onClick={duplicate}><Copy size={15}/>复制</button><button disabled={item.locked} onClick={remove}><Trash2 size={15}/>删除</button></div><button disabled={item.locked} onClick={()=>{const n=doc.items.findIndex(i=>i.id===item.id);if(n>0){const items=[...doc.items];[items[n-1],items[n]]=[items[n],items[n-1]];change({...doc,items})}}}>下移一层</button></div>:<div className={styles.props}><h3>画板</h3><p>自由画布 · 竖版</p><div className={styles.dimensions}>W <b>800</b> H <b>1000</b></div><h3>背景颜色</h3><label className={styles.color}><input type="color" value={doc.background} onChange={e=>change({...doc,background:e.target.value})}/>{doc.background.toUpperCase()}</label><div className={styles.note}><MousePointer2 size={20}/><p>选中一个图层,<br/>调整属于它的每个细节。</p></div></div>}<div className={styles.rightFooter}><button onClick={()=>download(JSON.stringify(doc,null,2),'application/json','json')}><Download size={15}/>备份设计 JSON</button><button onClick={()=>restore.current?.click()}>导入设计 JSON</button><p>草稿仅保存在此浏览器。<br/>邮箱登录共用全站账户。</p></div></aside></div>
|
||||
<input ref={restore} hidden type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>5*1024*1024)throw Error('文件不得超过 5MB');const restored=parseGraphicDocument(await f.text());change(restored);setSelected('');}catch(error){setStatus(error instanceof Error?error.message:'导入失败')}}}/>
|
||||
<input ref={upload} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={e=>{const f=e.target.files?.[0];if(!f)return;if(f.size>3*1024*1024||!['image/png','image/jpeg','image/webp'].includes(f.type)){setStatus('请使用 3MB 以内的 PNG / JPEG / WebP');return}const reader=new FileReader();reader.onload=()=>add('image',String(reader.result));reader.readAsDataURL(f);e.target.value=''}}/>
|
||||
|
||||
@@ -1,35 +1,58 @@
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight} from 'lucide-react';
|
||||
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight,Sun,Moon} from 'lucide-react';
|
||||
import s from '../../styles/infinite-canvas.module.css';
|
||||
type Card={id:string;kind:'text'|'image'|'prompt';title:string;text:string;x:number;y:number;image?:string};
|
||||
type Doc={version:1;cards:Card[];links:[string,string][]};
|
||||
const initial:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
|
||||
import CanvasNodeView from '../../components/canvas/CanvasNodeView';
|
||||
import CanvasComposer from '../../components/canvas/CanvasComposer';
|
||||
import CanvasAgentPanel,{type CanvasAgentHandle} from '../../components/canvas/CanvasAgentPanel';
|
||||
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask,type CanvasNode as Card,type CanvasDocument as Doc} from '../../lib/canvas-document';
|
||||
const example:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
|
||||
const initial:Doc={version:1,cards:[],links:[]};
|
||||
const key='studio.infinite-canvas.v1';
|
||||
function valid(v:unknown):v is Doc{const d=v as Doc;return !!d&&d.version===1&&Array.isArray(d.cards)&&d.cards.length<=80&&d.cards.every(c=>c&&typeof c.id==='string'&&c.id.length<100&&['text','image','prompt'].includes(c.kind)&&typeof c.title==='string'&&c.title.length<=200&&typeof c.text==='string'&&c.text.length<=10000&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&Math.abs(c.x)<100000&&Math.abs(c.y)<100000&&(!c.image||(/^data:image\/(png|jpeg|webp);base64,/.test(c.image)&&c.image.length<3000000)))&&new Set(d.cards.map(c=>c.id)).size===d.cards.length&&Array.isArray(d.links)&&d.links.length<=200&&d.links.every(l=>Array.isArray(l)&&l.length===2&&l[0]!==l[1]&&l.every(id=>d.cards.some(c=>c.id===id)));}
|
||||
export default function InfiniteCanvas(){
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState('');
|
||||
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc}|null>(null);
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[theme,setTheme]=useState<'light'|'dark'>('dark'),[editing,setEditing]=useState(''),[refMenu,setRefMenu]=useState(''),[taskFeedback,setTaskFeedback]=useState(''),[composerTarget,setComposerTarget]=useState(''),[pickerRequest,setPickerRequest]=useState(0);
|
||||
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|null>(null);
|
||||
const pickerSequence=useRef(0),agent=useRef<CanvasAgentHandle>(null);
|
||||
const active=doc.cards.find(c=>c.id===selected);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){setDoc(d);setStatus('已恢复本机草稿')}}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}try{const t=localStorage.getItem(key+'.theme');setTheme(t==='light'?'light':'dark')}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
|
||||
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
|
||||
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
|
||||
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);setRefMenu('');setEditing('');return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
|
||||
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.theme',theme)}catch{}},[theme,ready]);
|
||||
function scaleAt(next:number,cx?:number,cy?:number){const el=viewport.current;if(!el)return;const r=el.getBoundingClientRect(),x=cx===undefined?r.width/2:cx-r.left,y=cy===undefined?r.height/2:cy-r.top;const z=Math.max(.2,Math.min(2,next));setPan({x:x-(x-pan.x)*z/zoom,y:y-(y-pan.y)*z/zoom});setZoom(z)}
|
||||
function duplicate(){if(!active||doc.cards.length>=80)return;const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');commit({...doc,cards:[...doc.cards,{...active,id,title:active.title.slice(0,197)+' 副本',x:active.x+40,y:active.y+40}]});select(id)}
|
||||
function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)}
|
||||
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'参考素材':'生成提示词',text:'',x:((rect?.width||700)/2-pan.x)/zoom-160,y:(160-pan.y)/zoom,image}]});select(id)}
|
||||
function change(field:'title'|'text',value:string){commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,[field]:value}:c)})}
|
||||
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'图片节点':kind==='script'?'脚本生成器':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
|
||||
function change(field:'title'|'text'|'instruction',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))}
|
||||
function beginEdit(){setPast(p=>[...p.slice(-29),doc]);setFuture([])}
|
||||
function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))}
|
||||
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))}
|
||||
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+320))-minX,h=Math.max(...doc.cards.map(c=>c.y+350))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
|
||||
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
|
||||
function selectNode(id:string){if(id!==selected)setEditing('');select(id)}
|
||||
function editNode(id:string){setPickerRequest(0);setComposerTarget('');select(id);setEditing(id);setMenu(null);setRefMenu('');setTaskFeedback('');setTool('select')}
|
||||
function patchNode(id:string,patch:Partial<Card>){commit({...doc,cards:doc.cards.map(n=>n.id===id?{...n,...patch}:n)})}
|
||||
function derive(source:string,kind:Card['kind'],operation:Card['operation']='generate'){
|
||||
const parent=doc.cards.find(n=>n.id===source);if(!parent)return;
|
||||
const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const title=operation==='upscale'?'图片高清':kind==='script'?'脚本生成器':kind==='image'?'图片节点':'文本节点';
|
||||
try{commit(deriveNode(doc,source,{id,kind,operation,title,text:'',x:parent.x+(parent.width||320)+150,y:parent.y,width:360,height:300}));editNode(id);setComposerTarget(id)}catch(e){setStatus(e instanceof Error?e.message:'无法创建节点')}
|
||||
}
|
||||
function connect(from:string,to:string){try{const next=connectNodes(doc,from,to);if(next!==doc)commit(next);setTaskFeedback('')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法引用节点')}}
|
||||
function exportTask(id:string){try{const task=agentTask(doc,id),url=URL.createObjectURL(new Blob([JSON.stringify(task,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`canvas-task-${id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setTaskFeedback('Agent 任务已导出')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法导出任务')}}
|
||||
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
return <main className={s.app}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/studio" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/studio" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
|
||||
return <main className={s.app} data-theme={theme}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><button aria-label="切换明暗主题" title={theme==='dark'?'切换浅色模式':'切换深色模式'} onClick={()=>setTheme(t=>t==='dark'?'light':'dark')}>{theme==='dark'?<Sun size={17}/>:<Moon size={17}/>}</button><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
|
||||
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
|
||||
<section className={s.canvasArea}><div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
|
||||
<div className={s.viewport} ref={viewport} onWheel={e=>{if(e.ctrlKey||e.metaKey){setZoom(z=>Math.max(.2,Math.min(2,z-e.deltaY*.002)))}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{if(e.target===e.currentTarget||tool==='hand'){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}:c)})}} onPointerUp={()=>{const d=drag.current;if(d&&d.id!=='pan'){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{drag.current=null}}>
|
||||
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+320} ${from.y+150} C ${from.x+365} ${from.y+150},${to.x-45} ${to.y+150},${to.x} ${to.y+150}`}/>})}</svg>
|
||||
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} style={{left:c.x,top:c.y}} onClick={()=>select(c.id)}><div className={s.cardHead} onPointerDown={e=>{if(tool==='hand')return;e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}}><span>{c.kind==='text'?<Type size={15}/>:c.kind==='image'?<ImagePlus size={15}/>:<Sparkles size={15}/>} {c.title}</span><span>⠿</span></div>{c.kind==='image'?<div className={s.image} onClick={()=>{select(c.id);file.current?.click()}}>{c.image?<img src={c.image} alt={c.title}/>:<><div className={s.landscape}><span>REF.</span><b>光的形状</b><small>LIGHT / SPACE / EMOTION</small></div><span className={s.sample}>示例占位 · 点击上传参考图</span></>}</div>:<div className={s.cardBody}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><p>{c.text||'选择节点,在右侧写下你的想法。'}</p></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<span className={s.port}/></footer></article>)}
|
||||
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.2,z-.1))}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
<aside className={s.inspector}><div className={s.panelHeading}><Workflow size={17}/><strong>节点属性</strong><span>{doc.cards.length} 个节点</span></div>{active?<><span className={s.eyebrow}>INSPECTOR</span><h2>{active.kind==='prompt'?'把想法描述清楚':active.kind==='image'?'让参考彼此连接':'记录灵感的起点'}</h2><label>节点名称<input maxLength={200} value={active.title} onChange={e=>change('title',e.target.value)}/></label><label>{active.kind==='prompt'?'画面描述':'笔记内容'}<textarea maxLength={10000} value={active.text} onChange={e=>change('text',e.target.value)} placeholder="写下你的创作想法…"/></label>{active.kind==='image'&&<button className={s.wide} onClick={()=>file.current?.click()}><ImagePlus size={16}/>选择参考图片</button>}{active.kind==='prompt'&&<div className={s.notice}><Sparkles size={18}/><strong>为下一次创作做好准备</strong><p>提示词可保存在项目中。当前没有连接模型服务,暂不支持 AI 图片或视频生成。</p></div>}<label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button className={s.wide} disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}><Link2 size={16}/>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button className={s.connection} key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>↗ {doc.cards.find(c=>c.id===(a===selected?b:a))?.title} <span>断开 ×</span></button>)}<button className={s.delete} onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('')}}><Trash2 size={15}/>删除节点</button></>:<><h2>给灵感留一点空间</h2><p>选择画布中的节点来编辑,或从左侧添加新内容。</p><button className={s.wide} onClick={()=>add('text')}><Plus size={16}/>添加笔记</button></>}<div className={s.help}>拖动节点标题移动节点<br/>滚轮平移 · Ctrl / ⌘ + 滚轮缩放<br/>草稿仅存于当前浏览器,请及时导出。</div></aside></div>
|
||||
<section className={s.canvasArea}>{!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
|
||||
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{if(e.target===e.currentTarget){setMenu(null);add('text')}}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);setRefMenu('');if(e.target===e.currentTarget){setEditing('');select('')};if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else {if(!d.resize&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4)return;e.currentTarget.setPointerCapture(e.pointerId);setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
|
||||
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
|
||||
{doc.cards.map(c=><CanvasNodeView key={c.id} node={c} selected={selected===c.id} editing={editing===c.id&&composerTarget!==c.id} hand={tool==='hand'} onSelect={()=>selectNode(c.id)} onChooseReference={()=>{editNode(c.id);setComposerTarget(c.id);setPickerRequest(++pickerSequence.current)}} onEdit={()=>editNode(c.id)} onDrag={e=>{setMenu(null);if(c.id!==editing)setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onResize={e=>{viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}} onChange={value=>change('text',value,c.id)} onRename={value=>change('title',value,c.id)} onStyle={style=>patchNode(c.id,{style})} onReference={e=>{select(c.id);setRefMenu(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}} onMenu={e=>{select(c.id);setRefMenu('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onUpload={()=>{select(c.id);file.current?.click()}} onUpscale={()=>derive(c.id,'image','upscale')} onExpand={()=>patchNode(c.id,{width:Math.min(1200,(c.width||320)*1.5),height:Math.min(1200,(c.height||350)*1.3)})} onBeginEdit={beginEdit}/>)}
|
||||
|
||||
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{refMenu?'引用该节点生成':active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{refMenu?<>{(['text','image','script'] as const).map(kind=><button key={kind} onClick={()=>derive(refMenu,kind,kind==='script'?'script':'generate')}>{kind==='text'?'文本':kind==='image'?'图片':'脚本'}</button>)}</>:active?<><button onClick={()=>{editNode(active.id);setComposerTarget(active.id)}}>创作要求</button><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit(removeCanvasNode(doc,selected));select('');setEditing('');setMenu(null)}}>删除节点</button><label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button disabled={!link} onClick={()=>{connect(selected,link);setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>断开:{doc.cards.find(c=>c.id===(a===selected?b:a))?.title}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('script');setMenu(null)}}>添加脚本</button></>}</div>}</div>
|
||||
{active&&editing===active.id&&(active.kind!=='text'||composerTarget===active.id)&&<CanvasComposer pickerRequest={pickerRequest} key={active.id} node={active} nodes={doc.cards} references={referencedNodes(doc,active.id)} onChange={value=>change('instruction',value,active.id)} onRename={value=>change('title',value,active.id)} onBeginEdit={beginEdit} onUpload={()=>file.current?.click()} onAddReference={id=>connect(id,active.id)} onRemoveReference={id=>commit({...doc,links:doc.links.filter(([from,to])=>from!==id||to!==active.id)})} onQueue={()=>void agent.current?.queue(active.id)} onExport={()=>exportTask(active.id)} onClose={()=>setEditing('')} feedback={taskFeedback}/>}
|
||||
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/>
|
||||
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=JSON.parse(await f.text());if(!valid(d))throw Error();commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
|
||||
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=parseCanvasDocument(await f.text());commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
|
||||
</main>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.root{position:static}.panel{position:absolute;left:0;top:calc(100% + 12px);width:min(390px,calc(100vw - 32px));max-height:75dvh;overflow:auto;padding:20px;border:1px solid #414141;border-radius:16px;background:#242424;color:#ddd;box-shadow:0 15px 70px #0005;z-index:12;display:grid;gap:14px;font-size:12px}.panel header,.actions,.conflict{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.panel button{font-size:11px!important;padding:7px 10px!important;background:#333!important;color:inherit!important;border:1px solid #555!important}.panel button:disabled{opacity:.45}.panel textarea{width:100%;height:150px;resize:vertical;background:#181818;color:#ddd;border:1px solid #444;border-radius:8px;padding:10px;font:10px/1.7 monospace}.panel p{margin:0;color:#aaa;line-height:1.7}.panel small{color:#aaa;font-size:10px}.panel label{display:grid;gap:8px}.panel select{width:100%;background:#333;color:inherit;padding:8px;border-radius:6px}.panel ul{padding:0;margin:0;list-style:none;display:grid;gap:12px}.panel li{display:grid;gap:5px;border-top:1px solid #444;padding-top:10px}.panel li button{justify-self:start}.panel a{color:inherit!important;text-decoration:underline!important}:global([data-theme=light]) .panel{background:white;color:#333;border-color:#ccc}:global([data-theme=light]) .panel button{background:#f3f3f3!important;border-color:#ddd!important}
|
||||
|
||||
:global(header):has(> .root .panel){z-index:12}
|
||||
@@ -0,0 +1,4 @@
|
||||
.composer{position:fixed;z-index:6;left:50%;bottom:92px;transform:translateX(-50%);width:min(900px,calc(100vw - 40px));height:280px;border:1px solid #414141;border-radius:22px;padding:20px 24px;background:#242424;color:#ddd;display:flex;flex-direction:column;box-shadow:0 15px 70px #0003}.composer[data-expanded=true]{height:min(65dvh,650px)}.composer header{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}.composer header>span,.composer header>div{display:flex;align-items:center;gap:8px}.composer header button,.composer footer button{background:#303030;border-color:#414141;font-size:11px;padding:7px 10px}.composer textarea{display:block;flex:1;min-height:50px;width:100%;resize:none;padding:17px 0;border:0;background:transparent;color:inherit;font:inherit;line-height:1.8;outline:none}.composer footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10px;color:#888}.references{display:flex;gap:10px;padding-top:15px;flex-shrink:0;overflow:auto}.reference{width:70px;height:65px;border:1px solid #555;border-radius:8px;position:relative;display:grid;place-items:center;flex-shrink:0;overflow:hidden;background:#333}.reference img{width:100%;height:100%;object-fit:cover}.reference>span{position:absolute;top:3px;left:4px;background:#151515;color:white;border-radius:50%;padding:1px 4px;font-size:9px}.reference small{position:absolute;bottom:0;width:100%;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:#222b;font-size:8px;padding:3px}.reference button{position:absolute;top:1px;right:1px;padding:2px!important;background:#222c!important;border:0!important}.picker{position:absolute;bottom:calc(100% + 10px);left:0;background:#242424;border:1px solid #444;border-radius:14px;padding:10px;max-height:200px;overflow:auto;display:grid;gap:5px;min-width:220px}.picker button{justify-content:flex-start;background:transparent;border:0}.picker>span{font-size:11px;padding:12px}:global([data-theme=light]) .composer,:global([data-theme=light]) .picker{background:#fff;color:#333;border-color:#ccc}:global([data-theme=light]) .composer button{background:#f1f1f1;color:#333;border-color:#ddd}@media(max-width:600px){.composer{width:calc(100vw - 20px);padding:15px;bottom:80px;height:260px;border-radius:16px}.composer header>span{max-width:95px;overflow:hidden;white-space:nowrap;font-size:10px}.composer header button{padding:5px;font-size:10px}.composer header{gap:4px}.composer header>div{gap:4px}}
|
||||
.composer header input{width:200px;max-width:25vw;min-width:0;border:0;background:transparent;color:inherit;font:inherit;outline:none}
|
||||
|
||||
.composer footer>div{display:flex;gap:8px;flex-shrink:0}@media(max-width:600px){.composer footer>div{gap:4px}.composer footer button{font-size:10px;padding:6px}.composer footer>span{min-width:0}}
|
||||
@@ -0,0 +1,3 @@
|
||||
.node{position:absolute;background:#242424;border:1px solid #414141;border-radius:13px;color:#ddd;pointer-events:auto;touch-action:none;cursor:grab;outline:none}.node[data-selected=true]{border-color:#999}.node[data-editing=true]{border:2px solid #aaa;cursor:default}.title{position:absolute;top:-31px;left:0;right:0;display:flex;gap:7px;align-items:center;height:26px;color:#969696;font-size:12px;white-space:nowrap}.title>span{overflow:hidden;text-overflow:ellipsis}.title>button{margin-left:auto;padding:3px!important;background:transparent!important;border:0!important}.content,.image{height:100%;overflow:hidden;border-radius:12px}.content{padding:25px;white-space:pre-wrap;overflow:auto;scrollbar-width:thin}.content p{margin:0;line-height:1.7;pointer-events:none}.image{position:relative;display:grid;place-items:center}.image img{width:100%;height:100%;object-fit:contain;pointer-events:none;user-select:none;-webkit-user-drag:none}.empty{height:100%;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;color:#777}.empty button{background:transparent!important;border:0!important;color:#bbb!important;font-size:12px!important}.node>textarea{width:100%;height:100%;border:0;border-radius:12px;background:transparent;color:inherit;outline:none;resize:none;padding:25px;line-height:1.7;touch-action:auto}.format{position:absolute;bottom:calc(100% + 48px);left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:4px;background:#242424;border:1px solid #444;border-radius:12px;padding:6px;white-space:nowrap;z-index:3}.format input{width:24px;height:24px;padding:0;border:0;background:transparent}.format select{max-width:100px;background:transparent;color:#ddd;border:0;padding:6px}.format button{padding:5px!important;border:0!important;background:transparent!important}.format button[aria-pressed=true]{background:#4b4b4b!important}.reference{position:absolute!important;right:-38px;top:calc(50% - 14px);width:28px;height:28px;border-radius:50%!important;border:1px solid #777!important;padding:2px!important;background:#202020!important;color:#ddd!important}.resize{position:absolute!important;right:3px;bottom:3px;padding:3px!important;border:0!important;background:transparent!important;color:#888!important;cursor:nwse-resize!important}.upscale{position:absolute;bottom:10px;left:10px;font-size:10px!important;background:#242424df!important}.node:focus-visible{outline:2px solid #a6c579;outline-offset:3px}:global([data-theme=light]) .node{background:white;color:#333;border-color:#d0d0d0}:global([data-theme=light]) .node[data-selected=true]{border-color:#555}:global([data-theme=light]) .empty button{color:#555!important}:global([data-theme=light]) .format{background:white;border-color:#ccc}:global([data-theme=light]) .format select{color:#333}
|
||||
.title>input{min-width:0;width:100%;border:0!important;background:transparent!important;padding:0!important;color:inherit;font:inherit;outline:none}.title>button{flex-shrink:0}
|
||||
.incoming{right:auto!important;left:-38px}
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts';
|
||||
const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50};
|
||||
const doc={version:1,cards:[note],links:[]};
|
||||
test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify({...doc,token:'ignored',cards:[{...note,html:'ignored'}]})),doc);
|
||||
for(const bad of [{...doc,cards:[note,note]},{...doc,links:[['brief','missing']]},{...doc,cards:[{...note,image:'https://example.com/x.png'}]},{...doc,cards:[{...note,style:{color:'url(javascript:bad)'}}]}])assert.throws(()=>parseCanvasDocument(bad));
|
||||
});
|
||||
test('derived image and script nodes preserve directed references across save and restore',()=>{
|
||||
const picture={...note,id:'picture',kind:'image',title:'参考图',image:'data:image/png;base64,AAAA',operation:'generate'};
|
||||
const next=deriveNode(doc,'brief',picture);
|
||||
const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'})));
|
||||
assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture');
|
||||
assert.throws(()=>connectNodes(restored,'script','brief'),/循环/);
|
||||
assert.throws(()=>parseCanvasDocument({...restored,links:[...restored.links,['script','brief']]}),/循环/);
|
||||
const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2);
|
||||
});
|
||||
test('agent task captures current referenced content and validates upscale inputs',()=>{
|
||||
const next=deriveNode(doc,'brief',{...note,id:'out',kind:'script',operation:'script'});
|
||||
assert.equal(agentTask(next,'out').references[0].text,'清晨的城市');
|
||||
assert.equal(agentTask(next,'out').result.targetId,'out');
|
||||
assert.throws(()=>agentTask({...next,cards:next.cards.map(n=>n.id==='out'?{...n,operation:'upscale'}:n)},'out'),/引用一张图片/);
|
||||
});
|
||||
|
||||
test('instructions stay distinct from generated content in agent requests',()=>{
|
||||
const result={...note,id:'script',kind:'script',text:'已有脚本正文',instruction:'改写为六个分镜',operation:'script'};
|
||||
const restored=parseCanvasDocument(JSON.stringify(deriveNode(doc,'brief',result)));
|
||||
assert.equal(agentTask(restored,'script').instruction,'改写为六个分镜');
|
||||
assert.equal(restored.cards.find(n=>n.id==='script').text,'已有脚本正文');
|
||||
});
|
||||
|
||||
test('editing an image passes the current image to the external agent',()=>{
|
||||
const image={...note,id:'image',kind:'image',instruction:'把背景改为雪夜',image:'data:image/png;base64,AAAA'};
|
||||
assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image);
|
||||
});
|
||||
|
||||
const overlaps=(a,b)=>a.x<b.x+(b.width||320)&&a.x+(a.width||320)>b.x&&a.y<b.y+(b.height||350)&&a.y+(a.height||350)>b.y;
|
||||
test('branching the example keeps the source image accessible after image and script derivation',()=>{
|
||||
const scene={version:1,cards:[
|
||||
{...note,x:70,y:140},
|
||||
{...note,id:'reference',kind:'image',x:460,y:80},
|
||||
{...note,id:'shot',kind:'prompt',x:850,y:160},
|
||||
],links:[['brief','reference'],['reference','shot']]};
|
||||
const image=deriveNode(scene,'reference',{...note,id:'hd',kind:'image',operation:'upscale',x:930,y:80,width:360,height:300});
|
||||
const result=deriveNode(image,'brief',{...note,id:'script',kind:'script',operation:'script',x:540,y:140,width:360,height:300});
|
||||
assert.deepEqual(result.cards.slice(0,3),scene.cards,'Existing content and positions stay unchanged');
|
||||
for(const added of result.cards.slice(3))for(const other of result.cards.filter(n=>n.id!==added.id))assert.equal(overlaps(added,other),false,`${added.id} blocks ${other.id}`);
|
||||
assert.equal(referencedNodes(result,'hd')[0].id,'reference');
|
||||
assert.equal(referencedNodes(result,'script')[0].id,'brief');
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
|
||||
});
|
||||
test('a free requested position is retained and large nodes near the coordinate limit can still branch',()=>{
|
||||
const free=deriveNode(doc,'brief',{...note,id:'free',x:2000,y:50});
|
||||
assert.deepEqual({x:free.cards[1].x,y:free.cards[1].y},{x:2000,y:50});
|
||||
const edge={version:1,cards:[{...note,x:90000,y:99600,width:1200,height:1200}],links:[]};
|
||||
const result=deriveNode(edge,'brief',{...note,id:'edge',x:90500,y:99700,width:360,height:300});
|
||||
assert.equal(overlaps(result.cards[0],result.cards[1]),false);
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
|
||||
});
|
||||
|
||||
test('agent and browser parse the same canvas document contract',()=>{
|
||||
assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8'));
|
||||
});
|
||||
@@ -19,3 +19,9 @@ test('SVG export rejects unavailable or non-image objects',async()=>{
|
||||
test('SVG exports without managed images do not access storage',async()=>{
|
||||
assert.equal(await embedSvgMedia({headers:{}},'<svg><text>Title</text></svg>'),'<svg><text>Title</text></svg>');
|
||||
});
|
||||
|
||||
test('SVG export uses the configured isolated Wiki origin',async()=>{
|
||||
const original=globalThis.fetch,previous=process.env.STUDIO_WIKI_ORIGIN;
|
||||
try{process.env.STUDIO_WIKI_ORIGIN='http://127.0.0.1:19000';globalThis.fetch=async url=>{assert.equal(url,'http://127.0.0.1:19000/api/media/'+id);return new Response('fixture',{headers:{'content-type':'image/png'}})};assert.ok((await embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`)).includes('data:image/png;base64,'));}
|
||||
finally{globalThis.fetch=original;if(previous===undefined)delete process.env.STUDIO_WIKI_ORIGIN;else process.env.STUDIO_WIKI_ORIGIN=previous;}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
CREATE TABLE canvas_documents (
|
||||
id uuid PRIMARY KEY, owner_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT '未命名画布' CHECK(length(name) BETWEEN 1 AND 200),
|
||||
revision integer NOT NULL DEFAULT 1 CHECK(revision>0), document_key text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX canvas_owner_updated ON canvas_documents(owner_id,updated_at DESC);
|
||||
CREATE TABLE canvas_agent_links (
|
||||
digest text PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL, last_seen_at timestamptz
|
||||
);
|
||||
CREATE INDEX canvas_link_expiry ON canvas_agent_links(canvas_id,expires_at);
|
||||
CREATE TABLE canvas_tasks (
|
||||
id uuid PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE,
|
||||
target_id text NOT NULL, source_revision integer NOT NULL,
|
||||
status text NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','succeeded','failed')),
|
||||
request_key text NOT NULL, result_digest text, error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX canvas_target_active_task ON canvas_tasks(canvas_id,target_id) WHERE status IN ('queued','running');
|
||||
INSERT INTO schema_migrations(version) VALUES ('016_canvas_workspace');
|
||||
COMMIT;
|
||||
@@ -3,8 +3,15 @@ import managedMedia from "./src/content/managed-media.json";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["playwright-core"],
|
||||
async redirects(){return [
|
||||
{source:'/studio/graphic',destination:'/graphic',permanent:true},
|
||||
{source:'/studio/infinite_canvas',destination:'/infinite_canvas',permanent:true},
|
||||
{source:'/studio',destination:'/workspace',permanent:false},
|
||||
]},
|
||||
async rewrites(){return [
|
||||
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
|
||||
{source:'/graphic',destination:'http://prompt-studio:3000/studio/graphic'},
|
||||
{source:'/infinite_canvas',destination:'http://prompt-studio:3000/studio/infinite_canvas'},
|
||||
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
|
||||
{source:"/index.md",destination:"/api/agent-index"},
|
||||
{source:"/skills/index.md",destination:"/api/guidance/skills"},
|
||||
|
||||
@@ -47,13 +47,15 @@ async function main() {
|
||||
if (command === 'verify' || command === 'exercise') {
|
||||
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
|
||||
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
|
||||
assert.equal((await request('/')).status,200,'Public knowledge homepage');
|
||||
for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path);
|
||||
const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace');
|
||||
for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)}
|
||||
assert.equal((await request('/account')).status,200,'Public account page');
|
||||
const homeData = await request(`/_next/data/${buildId}/index.json`);
|
||||
assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages');
|
||||
assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base));
|
||||
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights');
|
||||
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/library/import.json`]) {
|
||||
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio/layouts/ref-01',`/_next/data/${buildId}/library/import.json`]) {
|
||||
const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination);
|
||||
}
|
||||
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
|
||||
|
||||
@@ -48,7 +48,13 @@ try {
|
||||
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
|
||||
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/__smoke-preview.png')}&width=480`);
|
||||
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
|
||||
console.log("Production smoke test passed");
|
||||
const home=await fetch(`http://127.0.0.1:${port}/`).then(r=>r.text());
|
||||
for(const href of ['/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes(`href="${href}"`),href);
|
||||
for(const [legacy,canonical] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
|
||||
const redirect=await fetch(`http://127.0.0.1:${port}${legacy}`,{redirect:'manual'});
|
||||
assert.equal(redirect.status,308);assert.equal(redirect.headers.get('location'),canonical);
|
||||
}
|
||||
console.log("Production smoke test passed, including landing entry points and canonical redirects");
|
||||
} finally {
|
||||
await unlink(previewFixture);
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
|
||||
@@ -2,6 +2,14 @@ import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
|
||||
// Read documentation before business validation or storage access. Mutations never enter this branch.
|
||||
export const apiDocuments:Record<string,readonly string[]>={
|
||||
"/api/canvases/{...path}": [
|
||||
"在线画布与 coding agent", "GET, POST, PUT, PATCH, DELETE",
|
||||
"参数:POST /api/canvases {name?,document} 创建本人画布;GET 列出本人画布。GET /api/canvases/{id} 返回 document、revision 和 tasks;PUT {revision,document} 更新,冲突返回 409。所有者使用已验证邮箱会话,写入要求同源 Origin。",
|
||||
"连接:所有者 POST /api/canvases/{id}/link 创建仅限此画布、有效一小时的 token(只返回一次,旧 token 同时撤销)。Agent 使用 Authorization: Bearer <token>。DELETE 同一路径并发送 {} 撤销;GET 读取 expiresAt/lastSeenAt。不要将 token 放入 URL、日志或作品中。",
|
||||
"任务:POST /api/canvases/{id}/tasks {revision,targetId} 保存当前引用和指令快照;GET /api/canvases/{id}/tasks/{taskId} 读取 request(instruction、references、可选 sourceImage)。PATCH 先发送 {status:running} 领取任务,执行实际生成后发送 {status:succeeded,result:{text?,image?}},或 {status:failed,error:原因}。image 仅允许嵌入式 PNG/JPEG/WebP data URL(小于 3MB);正文最多 10000 字符。结果只写回该任务的目标节点,不能改写节点标识和引用。",
|
||||
"返回:任务状态 queued/running/succeeded/failed,画布版本为正整数。任务期间画布变动则结果回写返回 409,先读取新画布并重新派发任务;相同成功结果重复提交幂等。请求快照和画布内容保存在 S3;访问凭据仅在数据库保存摘要。",
|
||||
"401 未登录;403 来源或权限不足;404 画布/任务不存在或不可访问;409 版本/任务冲突;413 文件过大;503 服务不可用。"
|
||||
],
|
||||
"/api/posts/{id}/archive": [
|
||||
"归档或恢复笔记", "POST",
|
||||
"路径参数 id 为存储 UUID 或24位 publicId。JSON 参数:{archived:true} 归档,{archived:false} 恢复。重复调用幂等,不删除 post、正文、媒体、标签或页面记录。",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export type NodeKind = 'text' | 'image' | 'prompt' | 'script';
|
||||
export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number};
|
||||
export type CanvasNode = {
|
||||
id:string;kind:NodeKind;title:string;text:string;x:number;y:number;
|
||||
image?:string;width?:number;height?:number;style?:TextStyle;
|
||||
instruction?:string;
|
||||
operation?:'generate'|'upscale'|'script';
|
||||
};
|
||||
export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]};
|
||||
const kinds:NodeKind[]=['text','image','prompt','script'];
|
||||
const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
||||
const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max;
|
||||
const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max;
|
||||
const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v);
|
||||
|
||||
export function parseCanvasDocument(value:unknown):CanvasDocument {
|
||||
const d=typeof value==='string'?JSON.parse(value):value;
|
||||
if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确');
|
||||
const ids=new Set<string>();
|
||||
const cards:CanvasNode[]=d.cards.map((n:unknown)=>{
|
||||
if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确');
|
||||
if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确');
|
||||
if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确');
|
||||
if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确');
|
||||
let style:TextStyle|undefined;
|
||||
if(n.style!==undefined){
|
||||
if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确');
|
||||
style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})};
|
||||
}
|
||||
ids.add(n.id as string);
|
||||
return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})};
|
||||
});
|
||||
const pairs=new Set<string>();
|
||||
const links:[string,string][]=d.links.map((l:unknown)=>{
|
||||
if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在');
|
||||
const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]];
|
||||
});
|
||||
const outgoing=new Map<string,string[]>(),indegree=new Map(cards.map(n=>[n.id,0]));
|
||||
for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)}
|
||||
const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0;
|
||||
while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}}
|
||||
if(visited!==cards.length)throw Error('引用不能形成循环');
|
||||
return {version:1,cards,links};
|
||||
}
|
||||
|
||||
export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{
|
||||
return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean);
|
||||
}
|
||||
export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument {
|
||||
if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点');
|
||||
if(doc.links.some(([a,b])=>a===from&&b===to))return doc;
|
||||
if(doc.links.length>=200)throw Error('最多 200 条引用');
|
||||
const seen=new Set<string>();const pending=[to];
|
||||
while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));}
|
||||
return {...doc,links:[...doc.links,[from,to]]};
|
||||
}
|
||||
export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
|
||||
return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))};
|
||||
}
|
||||
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
|
||||
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
|
||||
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
|
||||
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
|
||||
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
|
||||
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
|
||||
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
|
||||
target.y=nextY>100000?-100000:nextY;
|
||||
}
|
||||
throw Error('请移动现有节点后再创建引用节点');
|
||||
}
|
||||
export function agentTask(doc:CanvasDocument,id:string){
|
||||
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
|
||||
const instruction=node.instruction??node.text;
|
||||
if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求');
|
||||
const references=referencedNodes(doc,id);
|
||||
if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片');
|
||||
return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
import {PutObjectCommand} from '@aws-sdk/client-s3';
|
||||
import {db,transaction} from './db';
|
||||
import {storage,bucket,getObject,removeObject} from './storage';
|
||||
import {newToken,tokenDigest} from './account-crypto';
|
||||
import {parseCanvasDocument,agentTask,type CanvasDocument} from './canvas-document';
|
||||
export class CanvasError extends Error { constructor(public status:number,message:string){super(message)} }
|
||||
const inputDocument=(input:unknown)=>{try{return parseCanvasDocument(input)}catch{throw new CanvasError(400,'画布或结果参数无效')}};
|
||||
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(v);
|
||||
export const revision=(v:unknown):v is number=>typeof v==='number'&&Number.isSafeInteger(v)&&v>0&&v<2147483647;
|
||||
export async function readCanvasBody(key:string):Promise<CanvasDocument>{const o=await getObject(key);return parseCanvasDocument(await o.Body!.transformToString())}
|
||||
async function putJson(canvas:string,value:unknown){const key=`canvases/${canvas}/${randomUUID()}.json`,body=JSON.stringify(value);if(Buffer.byteLength(body)>15000000)throw new CanvasError(413,'画布不能超过 15MB');await storage().send(new PutObjectCommand({Bucket:bucket(),Key:key,Body:body,ContentType:'application/json'}));return key}
|
||||
export async function authorizeCanvas(id:string,owner:string|null,bearer:string|null){
|
||||
const record=await db().query('SELECT * FROM canvas_documents WHERE id=$1',[id]);const doc=record.rows[0];
|
||||
if(!doc)throw new CanvasError(404,'画布不存在');
|
||||
if(owner===doc.owner_id)return doc;
|
||||
if(bearer&&/^[a-f\d]{64}$/.test(bearer)){
|
||||
const auth=await db().query('UPDATE canvas_agent_links SET last_seen_at=now() WHERE canvas_id=$1 AND digest=$2 AND expires_at>now() RETURNING canvas_id',[id,tokenDigest(bearer)]);
|
||||
if(auth.rowCount)return doc;
|
||||
}
|
||||
throw new CanvasError(404,'画布不存在或无访问权限');
|
||||
}
|
||||
export async function canvasState(record:{id:string;revision:number;name:string;document_key:string}){
|
||||
const tasks=await db().query('SELECT id,target_id AS "targetId",status,error,source_revision AS "sourceRevision",created_at AS "createdAt" FROM canvas_tasks WHERE canvas_id=$1 ORDER BY created_at DESC LIMIT 80',[record.id]);
|
||||
return {id:record.id,name:record.name,revision:record.revision,document:await readCanvasBody(record.document_key),tasks:tasks.rows};
|
||||
}
|
||||
export async function createCanvas(owner:string,input:unknown,name='未命名画布'){
|
||||
if(typeof name!=='string'||!name.trim()||name.length>200)throw new CanvasError(400,'画布名称无效');
|
||||
const document=inputDocument(input),id=randomUUID(),key=await putJson(id,document);
|
||||
try{const r=await db().query('INSERT INTO canvas_documents(id,owner_id,name,document_key) VALUES($1,$2,$3,$4) RETURNING *',[id,owner,name.trim(),key]);return canvasState(r.rows[0])}catch(e){await removeObject(key).catch(()=>{});throw e}
|
||||
}
|
||||
export async function saveCanvas(id:string,expected:number,input:unknown){
|
||||
if(!revision(expected))throw new CanvasError(400,'版本无效');const document=inputDocument(input),key=await putJson(id,document);
|
||||
try{return await transaction(async client=>{const r=await client.query('UPDATE canvas_documents SET document_key=$3,revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$2 RETURNING *',[id,expected,key]);if(!r.rowCount)throw new CanvasError(409,'在线画布已有更新,请先读取新版本');return r.rows[0]})}catch(e){await removeObject(key).catch(()=>{});throw e}
|
||||
}
|
||||
export async function createCanvasLink(id:string){
|
||||
const token=newToken(),expiresAt=new Date(Date.now()+3600000).toISOString();
|
||||
await transaction(async client=>{await client.query('SELECT id FROM canvas_documents WHERE id=$1 FOR UPDATE',[id]);await client.query('DELETE FROM canvas_agent_links WHERE canvas_id=$1',[id]);await client.query('INSERT INTO canvas_agent_links(digest,canvas_id,expires_at) VALUES($1,$2,$3)',[tokenDigest(token),id,expiresAt])});
|
||||
return {token,expiresAt};
|
||||
}
|
||||
export async function queueCanvasTask(id:string,expected:number,target:string){
|
||||
if(!revision(expected)||typeof target!=='string'||target.length>100)throw new CanvasError(400,'任务参数无效');
|
||||
let key:string|undefined;
|
||||
try{return await transaction(async client=>{
|
||||
const row=(await client.query('SELECT * FROM canvas_documents WHERE id=$1 FOR UPDATE',[id])).rows[0];if(!row)throw new CanvasError(404,'画布不存在');if(row.revision!==expected)throw new CanvasError(409,'画布已有更新,请先同步');
|
||||
const body=await readCanvasBody(row.document_key);let request;try{request=agentTask(body,target)}catch(e){throw new CanvasError(400,e instanceof Error?e.message:'创作要求无效')}key=await putJson(id,request);
|
||||
const result=await client.query('INSERT INTO canvas_tasks(id,canvas_id,target_id,source_revision,request_key) VALUES($1,$2,$3,$4,$5) RETURNING id,status,target_id AS "targetId"',[randomUUID(),id,target,expected,key]);return result.rows[0];
|
||||
})}catch(e){if(key)await removeObject(key).catch(()=>{});if((e as {code?:string}).code==='23505')throw new CanvasError(409,'该节点已有待处理任务');throw e}
|
||||
}
|
||||
export async function readCanvasTask(id:string,task:string){
|
||||
const r=await db().query('SELECT * FROM canvas_tasks WHERE canvas_id=$1 AND id=$2',[id,task]);if(!r.rowCount)throw new CanvasError(404,'任务不存在');const row=r.rows[0],o=await getObject(row.request_key);return {id:row.id,targetId:row.target_id,status:row.status,sourceRevision:row.source_revision,error:row.error,request:JSON.parse(await o.Body!.transformToString())};
|
||||
}
|
||||
export async function updateCanvasTask(id:string,task:string,input:{status?:unknown;result?:unknown;error?:unknown}){
|
||||
let key:string|undefined;
|
||||
try{return await transaction(async client=>{
|
||||
const record=(await client.query('SELECT * FROM canvas_documents WHERE id=$1 FOR UPDATE',[id])).rows[0];
|
||||
const row=(await client.query('SELECT * FROM canvas_tasks WHERE canvas_id=$1 AND id=$2 FOR UPDATE',[id,task])).rows[0];
|
||||
if(!record||!row)throw new CanvasError(404,'任务不存在');
|
||||
if(input.status==='running'){
|
||||
if(!['queued','running'].includes(row.status))throw new CanvasError(409,'任务已经结束');
|
||||
await client.query("UPDATE canvas_tasks SET status='running',updated_at=now() WHERE id=$1",[task]);return {id:task,status:'running',revision:record.revision};
|
||||
}
|
||||
if(input.status==='failed'){
|
||||
if(!['queued','running'].includes(row.status))throw new CanvasError(409,'任务已经结束');
|
||||
if(typeof input.error!=='string'||!input.error.trim()||input.error.length>1000)throw new CanvasError(400,'请提供任务失败原因');
|
||||
await client.query("UPDATE canvas_tasks SET status='failed',error=$2,updated_at=now() WHERE id=$1",[task,input.error]);return {id:task,status:'failed',revision:record.revision};
|
||||
}
|
||||
if(input.status!=='succeeded'||!input.result||typeof input.result!=='object'||Array.isArray(input.result))throw new CanvasError(400,'任务更新格式不正确');
|
||||
const result=input.result as {text?:unknown;image?:unknown};
|
||||
if(result.text===undefined&&result.image===undefined)throw new CanvasError(400,'没有返回结果');
|
||||
const digest=createHash('sha256').update(JSON.stringify({text:result.text,image:result.image})).digest('hex');
|
||||
if(row.status==='succeeded'&&row.result_digest===digest)return {id:task,status:'succeeded',revision:record.revision};
|
||||
if(row.status!=='running')throw new CanvasError(409,'请先领取任务,已结束的任务不能改写');
|
||||
if(record.revision!==row.source_revision)throw new CanvasError(409,'画布在任务创建后已修改,请重新派发任务');
|
||||
const doc=await readCanvasBody(record.document_key),node=doc.cards.find(n=>n.id===row.target_id);if(!node)throw new CanvasError(409,'目标节点已移除');
|
||||
if(result.image!==undefined&&node.kind!=='image')throw new CanvasError(400,'该节点不接收图片结果');
|
||||
const next=inputDocument({...doc,cards:doc.cards.map(n=>n.id===node.id?{...n,...(result.text===undefined?{}:{text:result.text}),...(result.image===undefined?{}:{image:result.image})}:n)});
|
||||
key=await putJson(id,next);await client.query('UPDATE canvas_documents SET document_key=$2,revision=revision+1,updated_at=now() WHERE id=$1',[id,key]);
|
||||
await client.query("UPDATE canvas_tasks SET status='succeeded',result_digest=$2,error=NULL,updated_at=now() WHERE id=$1",[task,digest]);return {id:task,status:'succeeded',revision:record.revision+1};
|
||||
})}catch(e){if(key)await removeObject(key).catch(()=>{});throw e}
|
||||
}
|
||||
@@ -24,14 +24,14 @@ function localNext(value: string | string[] | undefined) {
|
||||
value.startsWith("//") ||
|
||||
/[\\\u0000-\u001f]/.test(value)
|
||||
)
|
||||
return "/write";
|
||||
return "/workspace";
|
||||
try {
|
||||
const url = new URL(value, "https://local.invalid");
|
||||
return url.origin === "https://local.invalid"
|
||||
? url.pathname + url.search + url.hash
|
||||
: "/write";
|
||||
: "/workspace";
|
||||
} catch {
|
||||
return "/write";
|
||||
return "/workspace";
|
||||
}
|
||||
}
|
||||
export default function AccountPage() {
|
||||
@@ -93,6 +93,7 @@ function AccountContent() {
|
||||
"如果信息符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。",
|
||||
});
|
||||
await account.refresh();
|
||||
if (name === "login") await router.push(localNext(router.query.next));
|
||||
} catch (e) {
|
||||
setFeedback({
|
||||
tone: "error",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {serveApiDocumentation} from '@/lib/api-documentation';
|
||||
import {currentAccount} from '@/lib/account';
|
||||
import {trustedOrigin} from '@/lib/account-crypto';
|
||||
import {db} from '@/lib/db';
|
||||
import {CanvasError,uuid,authorizeCanvas,canvasState,createCanvas,saveCanvas,createCanvasLink,queueCanvasTask,readCanvasTask,updateCanvasTask} from '@/lib/canvas-store';
|
||||
export const config={api:{bodyParser:{sizeLimit:'15mb'}}};
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(serveApiDocumentation(req,res,'/api/canvases/{...path}'))return;
|
||||
res.setHeader('Cache-Control','private, no-store');res.setHeader('Referrer-Policy','no-referrer');
|
||||
const path=req.query.path||[];if(!Array.isArray(path)||path.length>3)return res.status(404).end();
|
||||
const [id,section,taskId]=path;
|
||||
if(id&&!uuid(id)||taskId&&!uuid(taskId))return res.status(404).end();
|
||||
if(!['GET','POST','PUT','PATCH','DELETE'].includes(req.method||'')){res.setHeader('Allow','GET, POST, PUT, PATCH, DELETE');return res.status(405).end()}
|
||||
const input=req.body;
|
||||
if(req.method!=='GET'&&(!input||typeof input!=='object'||Array.isArray(input)))return res.status(400).json({error:'请发送 JSON 对象'});
|
||||
try{
|
||||
const headers=new Headers(Object.entries(req.headers).filter(([,v])=>typeof v==='string') as [string,string][]);
|
||||
const request=new Request(new URL(req.url||'/',process.env.ACCOUNT_PUBLIC_ORIGIN||'http://'+req.headers.host),{headers});
|
||||
const bearer=req.headers.authorization?.startsWith('Bearer ')?req.headers.authorization.slice(7):null;
|
||||
if(bearer&&req.headers['sec-fetch-site']==='cross-site')throw new CanvasError(403,'请求来源不受信任');
|
||||
if(!bearer&&req.method!=='GET'&&!trustedOrigin(request))throw new CanvasError(403,'请求来源不受信任');
|
||||
const user=bearer?null:await currentAccount(request);
|
||||
if(!id){
|
||||
if(!user)throw new CanvasError(401,'请先登录邮箱账户');
|
||||
if(req.method==='GET'){const rows=await db().query('SELECT id,name,revision,updated_at AS "updatedAt" FROM canvas_documents WHERE owner_id=$1 ORDER BY updated_at DESC LIMIT 100',[user.id]);return res.json({canvases:rows.rows})}
|
||||
if(req.method==='POST')return res.status(201).json(await createCanvas(user.id,input.document,input.name));
|
||||
return res.status(405).end();
|
||||
}
|
||||
const record=await authorizeCanvas(id,user?.id||null,bearer);
|
||||
if(!section){
|
||||
if(req.method==='GET')return res.json(await canvasState(record));
|
||||
if(req.method==='PUT')return res.json(await canvasState(await saveCanvas(id,input.revision,input.document)));
|
||||
return res.status(405).end();
|
||||
}
|
||||
if(section==='link'&&!taskId){
|
||||
if(!user||user.id!==record.owner_id)throw new CanvasError(403,'只有画布所有者可以管理 Agent 连接');
|
||||
if(req.method==='POST')return res.status(201).json({...await createCanvasLink(id),canvasId:id,documentation:`/api/canvases/${id}?agent_mode=True`});
|
||||
if(req.method==='DELETE'){await db().query('DELETE FROM canvas_agent_links WHERE canvas_id=$1',[id]);return res.json({revoked:true})}
|
||||
if(req.method==='GET'){const r=await db().query('SELECT expires_at AS "expiresAt",last_seen_at AS "lastSeenAt" FROM canvas_agent_links WHERE canvas_id=$1 AND expires_at>now() ORDER BY expires_at DESC LIMIT 1',[id]);return res.json({link:r.rows[0]||null})}
|
||||
return res.status(405).end();
|
||||
}
|
||||
if(section==='tasks'){
|
||||
if(!taskId&&req.method==='POST')return res.status(201).json(await queueCanvasTask(id,input.revision,input.targetId));
|
||||
if(taskId&&req.method==='GET')return res.json(await readCanvasTask(id,taskId));
|
||||
if(taskId&&req.method==='PATCH')return res.json(await updateCanvasTask(id,taskId,input));
|
||||
return res.status(405).end();
|
||||
}
|
||||
return res.status(404).end();
|
||||
}catch(e){
|
||||
if(e instanceof CanvasError)return res.status(e.status).json({error:e.message});
|
||||
if(e instanceof SyntaxError||e instanceof Error&&/格式|节点|引用|创作要求|图片|画布不能/.test(e.message))return res.status(400).json({error:'画布或任务参数无效'});
|
||||
return res.status(503).json({error:'在线画布暂时不可用'});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import DocumentDirectory from "@/components/DocumentDirectory";
|
||||
export default function Home() {
|
||||
return <DocumentDirectory />;
|
||||
}
|
||||
import Head from 'next/head';
|
||||
import {ArrowUpRight, MousePointer2, Plus, Type, Image, Sparkles, Workflow, Layers, ArrowRight} from 'lucide-react';
|
||||
import s from '@/styles/landing.module.css';
|
||||
export default function Home(){return <main className={s.page}>
|
||||
<Head><title>织页 Studio · 让灵感,有形。</title><meta name="description" content="连接你的 coding agent,在图形编辑器与无限画布中,把想法组织成作品。"/></Head>
|
||||
<nav className={s.nav}><a className={s.logo} href="/">✳ <b>织页</b><span>STUDIO</span></a><div><a href="#tools">创作工具</a><a href="/documents">知识与教程</a><a href="#workflow">工作方式</a></div><a href="/account?next=/workspace">登录</a><a className={s.dark} href="/account?next=/workspace">进入工作台 <ArrowUpRight size={15}/></a></nav>
|
||||
<section className={s.hero}><p className={s.eyebrow}>A SPACE FOR HUMAN IDEAS & AGENTS</p><h1>想法不该停在对话里。<br/><span>让灵感,有形。</span></h1><p className={s.intro}>从一闪而过的念头,到值得分享的作品。<br/>连接你的 coding agent,在一张画布上,把创作继续下去。</p><div className={s.actions}><a className={s.dark} href="/account?next=/workspace">开始创作 <ArrowRight size={17}/></a><a href="#tools">探索你的创作空间 ↓</a></div><div className={s.product}><div className={s.productBar}><b>✳ STUDIO</b><span>日常的另一种可能 / 创意探索</span><span>本机草稿</span></div><div className={s.demo}><aside>工作空间<hr/>图层与素材<p>◈ 创意简报</p><p>▧ 视觉探索</p><p>✳ 镜头构思</p><small>把线索连接起来</small></aside><div className={s.demoWorld}><svg viewBox="0 0 800 330" preserveAspectRatio="none"><path d="M190 170 C300 170 260 120 360 120 S520 240 620 240"/></svg><article className={s.note}><small>01 / THE IDEA</small><h3>把日常,<br/>拍成一场电影。</h3><p>自然光、细腻颗粒,<br/>让每一帧都有呼吸感。</p></article><article className={s.art}><small>02 / VISUAL STUDY</small><div className={s.orbit}/><h3>光的形状</h3><span>LIGHT / SPACE / EMOTION</span></article><article className={s.prompt}><Sparkles size={17}/><small>03 / PROMPT</small><h3>留住清晨的光。</h3><p>窗边的植物,安静的房间。<br/>35mm 胶片,缓慢推进。</p></article><div className={s.dock}><MousePointer2/><Plus/><Type/><Image/><Sparkles/></div></div></div></div><p className={s.caption}>一个创作场景示意。你的想法、素材与工具,在这里相遇。</p></section>
|
||||
<section className={s.tools} id="tools"><p className={s.eyebrow}>TWO TOOLS. ONE CREATIVE FLOW.</p><h2>给不同的想法,<br/>恰好的空间。</h2><div className={s.toolGrid}><a href="/graphic"><Layers/><span>01 / GRAPHIC</span><h3>把灵感排成作品。</h3><p>文字、图形与图片自由编排。精确调整图层和位置,把设计导出为 SVG。</p><div className={s.poster}><span>MAKE<br/>ROOM<br/><i>for ideas.</i></span><b>✳</b></div><strong>打开图形编辑器 <ArrowUpRight size={17}/></strong></a><a href="/infinite_canvas"><Workflow/><span>02 / INFINITE CANVAS</span><h3>让思路,自由生长。</h3><p>把笔记、参考图与提示词连成线索。在无限画布上平移、缩放,整理下一次创作。</p><div className={s.miniCanvas}><b>一个念头</b><i>⟶</i><b>一张参考</b><i>⟶</i><b>新的可能</b></div><strong>打开无限画布 <ArrowUpRight size={17}/></strong></a></div></section>
|
||||
<section className={s.workflow} id="workflow"><div><p className={s.eyebrow}>YOUR AGENT. YOUR WORKFLOW.</p><h2>用熟悉的 Agent,<br/>继续你的创作。</h2><p>通过项目已有的 API 与文档,让 coding agent 理解模板和创作内容。画布负责呈现,你来决定下一步。</p><a href="/studio/api">查看 Agent 接入文档 <ArrowUpRight size={16}/></a></div><ol><li><b>01</b><div><h3>找到起点</h3><p>从空白设计、素材或一段灵感出发。</p></div></li><li><b>02</b><div><h3>连接工作方式</h3><p>将接入文档交给你的 coding agent。</p></div></li><li><b>03</b><div><h3>亲手打磨,保存作品</h3><p>在编辑器中调整细节,导出并保留备份。</p></div></li></ol></section>
|
||||
<section className={s.end}><span>✳</span><h2>下一件作品,<br/>从这里开始。</h2><a className={s.dark} href="/account?next=/workspace">进入你的工作台 <ArrowRight size={17}/></a></section><footer className={s.footer}><a className={s.logo} href="/">✳ 织页 STUDIO</a><span>给想法留白,给创作空间。</span><a href="/documents">知识与教程 ↗</a></footer></main>}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ArrowUpRight, Layers, Workflow, BookOpen, Link2} from 'lucide-react';
|
||||
import s from '@/styles/workspace.module.css';
|
||||
export default function Workspace(){const [email,setEmail]=useState(''),[state,setState]=useState('loading');useEffect(()=>{fetch('/api/account/session').then(async r=>{if(!r.ok)throw Error();const d=await r.json();if(!d.user){window.location.replace('/account?next=/workspace');return}setEmail(d.user.email);setState('ready')}).catch(()=>setState('error'))},[]);return <main className={s.app}><Head><title>工作台 · 织页 Studio</title></Head><aside><a className={s.brand} href="/">✳ 织页 <small>STUDIO</small></a><span>你的工作空间</span><a aria-current="page" href="/workspace">◈ 创作概览</a><a href="/graphic"><Layers size={16}/>图形设计</a><a href="/infinite_canvas"><Workflow size={16}/>无限画布</a><a href="/documents"><BookOpen size={16}/>知识与教程</a><a href="/studio/api"><Link2 size={16}/>Agent 接入文档</a><a className={s.account} href="/account">{email||'账户设置'} ↗</a></aside><section>{state==='loading'?<p role="status">正在打开你的工作台…</p>:state==='error'?<p role="alert">暂时无法读取账户。<a href="/account?next=/workspace">重新登录</a></p>:<><header><span>WORKSPACE / OVERVIEW</span><a href="/account">账户设置 ↗</a></header><p className={s.kicker}>MAKE SOMETHING THAT MATTERS.</p><h1>今天,让哪个想法成形?</h1><p className={s.sub}>选一个空间,开始你的下一次创作。</p><div className={s.grid}><a href="/graphic"><Layers/><small>GRAPHIC</small><h2>图形设计</h2><p>在文字、图形与图像之间,找到你的表达。</p><div className={s.poster}>Ideas,<br/><i>in shape.</i><b>✳</b></div><strong>打开编辑器 <ArrowUpRight size={18}/></strong></a><a href="/infinite_canvas"><Workflow/><small>INFINITE CANVAS</small><h2>无限画布</h2><p>收集灵感、连接参考,让思路自由生长。</p><div className={s.canvas}><span>灵感</span>⟶<span>参考</span>⟶<span>作品</span></div><strong>打开画布 <ArrowUpRight size={18}/></strong></a></div><div className={s.agent}><Link2/><div><h3>带上你的 coding agent</h3><p>从接入文档开始,使用已有的创作 API。连接由你的 Agent 发起。</p></div><a href="/studio/api">查看文档 ↗</a></div><p className={s.hint}>编辑器草稿保存在当前浏览器。更换设备前,请先导出项目备份。</p></>}</section></main>}
|
||||
@@ -4,8 +4,8 @@ export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'};
|
||||
// Public knowledge pages are read-only. Account handlers enforce their own origin and session checks.
|
||||
const publicRead = ['GET','HEAD'].includes(request.method) && (path === '/' || path === '/protocol' || ['/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides'].includes(path) || path.startsWith('/guide/') || path.startsWith('/api/guides/'));
|
||||
if (path === '/write' || path === '/documents' || path.startsWith('/documents/') || path === '/api/documents' || path.startsWith('/api/documents/') || publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}});
|
||||
const publicRead = ['GET','HEAD'].includes(request.method) && (path === '/' || ['/workspace','/graphic','/infinite_canvas','/studio','/studio/graphic','/studio/infinite_canvas','/studio/api'].includes(path) || path.startsWith('/studio/_next/') || path === '/protocol' || ['/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides'].includes(path) || path.startsWith('/guide/') || path.startsWith('/api/guides/'));
|
||||
if (path === '/api/canvases' || path.startsWith('/api/canvases/') || path === '/write' || path === '/documents' || path.startsWith('/documents/') || path === '/api/documents' || path.startsWith('/api/documents/') || publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}});
|
||||
if (path === '/brand-mark.svg' || path === '/login' || path === '/api/auth/login' || path === '/api/auth/logout' || path === '/api/health' || path === '/api/ready' || path.startsWith('/_next/static/')) return NextResponse.next({headers:responseHeaders});
|
||||
let config;
|
||||
try { config = authConfig(); } catch { return NextResponse.json({error:'登录服务配置不可用'}, {status:503,headers:responseHeaders}); }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.app{display:flex;min-height:100dvh;background:#fafaf8;color:#30352c;font-family:Arial,'PingFang SC',sans-serif}.app *{box-sizing:border-box}.app a{color:inherit;text-decoration:none}.app>aside{width:230px;flex-shrink:0;border-right:1px solid #e0e3da;background:#f3f4ef;padding:30px 20px;display:flex;flex-direction:column;gap:9px}.brand{font-size:22px;font-weight:600;margin-bottom:45px}.brand small{font-size:9px;letter-spacing:1px;color:#87917c}.app aside>span{font-size:10px;color:#99a08f;margin-bottom:10px;padding-left:10px}.app aside>a:not(.brand){display:flex;align-items:center;gap:12px;font-size:12px;padding:12px;border-radius:7px}.app aside>a[aria-current]{background:#e5eadb}.app aside>.account{margin-top:auto;overflow-wrap:anywhere}.app>section{padding:35px 55px;max-width:1250px;width:100%;margin:auto;min-height:100dvh}.app header{display:flex;justify-content:space-between;color:#8d9582;font-size:10px;letter-spacing:1px}.kicker{font-size:10px;letter-spacing:2px;color:#8a9876;margin-top:65px}.app h1{font-size:34px;font-weight:500;letter-spacing:-1px;margin:18px 0}.sub{font-size:13px;color:#929887}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:35px}.grid>a{background:white;border:1px solid #dfe3d6;border-radius:12px;padding:26px;transition:transform .2s}.grid>a:hover{transform:translateY(-3px)}.grid small{font-size:9px;letter-spacing:1.5px;margin-left:12px;color:#909b82}.grid>a>svg{vertical-align:middle;width:20px;color:#7b8c65}.grid h2{font-size:23px;font-weight:500;margin:24px 0 12px}.grid p{font-size:12px;color:#929a87;line-height:1.8}.poster,.canvas{height:190px;margin:25px 0;background:#ecf0e3;position:relative;overflow:hidden;border-radius:5px}.poster{padding:25px;font-size:40px;line-height:1;font-weight:600;letter-spacing:-2px}.poster i{font-family:Georgia,serif;font-weight:400}.poster b{position:absolute;right:0;top:12px;font-size:155px;color:#a7b88b}.canvas{display:flex;align-items:center;justify-content:center;gap:12px;background:radial-gradient(#c5cfb8 1px,transparent 1px) 0 0/18px 18px,#f3f5ef;color:#91a276}.canvas span{background:white;border:1px solid #d5ddc9;padding:20px 13px;font-size:12px;border-radius:6px;color:#59684a}.grid strong{display:flex;justify-content:space-between;font-size:12px;font-weight:400}.agent{display:flex;align-items:center;gap:18px;border:1px solid #dde3d1;border-radius:10px;padding:23px;margin-top:28px}.agent>svg{color:#83956c}.agent h3{font-size:14px;margin:0 0 8px}.agent p{font-size:11px;color:#8a947d;margin:0;line-height:1.8}.agent a{margin-left:auto;font-size:12px;white-space:nowrap}.hint{font-size:10px;color:#a1a793;margin-top:25px}.app a:focus-visible{outline:2px solid #718358;outline-offset:4px}@media(max-width:850px){.app>aside{width:180px}.app>section{padding:30px 25px}.grid{gap:15px}.grid>a{padding:20px}.app h1{font-size:28px}.canvas{gap:5px}.canvas span{padding:15px 8px}}@media(max-width:600px){.app{display:block}.app>aside{width:100%;flex-direction:row;flex-wrap:wrap;padding:18px;gap:5px;border-right:0;border-bottom:1px solid #ddd}.brand{margin:0;width:100%}.app aside>span,.app aside>.account{display:none}.app aside>a:not(.brand){padding:8px;font-size:10px}.grid{grid-template-columns:1fr}.kicker{margin-top:35px}.agent{flex-wrap:wrap}.agent a{margin-left:0}}
|
||||
@@ -13,7 +13,7 @@ const request=(method:string,agent_mode:unknown)=>({method,query:{agent_mode}} a
|
||||
|
||||
test('every business handler serves documentation before validating parameters or accessing dependencies',async()=>{
|
||||
const routes:Record<string,string>={
|
||||
'/api/explore-feed':'explore-feed','/api/feed':'feed','/api/notes/{id}':'notes/[id]',
|
||||
'/api/canvases/{...path}':'canvases/[[...path]]','/api/explore-feed':'explore-feed','/api/feed':'feed','/api/notes/{id}':'notes/[id]',
|
||||
'/api/posts/{id}/archive':'posts/[id]/archive','/api/posts':'posts/index','/api/tags':'tags','/api/upload':'upload',
|
||||
'/api/media/{id}':'media/[id]','/api/preview':'preview',
|
||||
'/api/skills/street-people':'skills/street-people','/api/health':'health','/api/ready':'ready',
|
||||
|
||||
@@ -30,13 +30,18 @@ test('cookies are HTTP-only and secure by default; redirects stay local',()=>{
|
||||
for(const path of ['https://evil.test','//evil.test','/\\evil.test','/login?next=/','/\nLocation: bad']) assert.equal(safeReturn(path),'/');
|
||||
assert.equal(safeReturn('/explore/123?q=test'),'/explore/123?q=test');
|
||||
});
|
||||
test('proxy protects APIs, data routes, static content and Studio; fails closed',()=>{
|
||||
test('proxy exposes creative entry pages while protecting APIs, data and Studio documents; fails closed',()=>{
|
||||
const temp=mkdtempSync(join(tmpdir(),'prompt-auth-')); const old=process.env.AUTH_CONFIG_FILE;
|
||||
try{
|
||||
const config=newAuthConfig(); process.env.AUTH_CONFIG_FILE=join(temp,'config.json'); writeFileSync(process.env.AUTH_CONFIG_FILE,JSON.stringify(config));
|
||||
for(const path of ['/library/import','/skills/example.png','/_next/data/build/library/import.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
|
||||
for(const path of ['/api/upload','/studio/api/export','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
|
||||
for(const path of ['/library/import','/skills/example.png','/_next/data/build/library/import.json','/studio/layouts/ref-01']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
|
||||
for(const path of ['/api/upload','/studio/api/export','/studio/api/layouts','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
|
||||
for(const path of ['/','/_next/data/build/index.json','/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides','/write','/documents','/api/documents','/api/documents/preview','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
|
||||
const creativeEntries=['/workspace','/graphic','/infinite_canvas','/studio','/studio/graphic','/studio/infinite_canvas','/studio/api','/studio/_next/static/chunk.js'];
|
||||
for(const path of creativeEntries){
|
||||
for(const method of ['GET','HEAD'])assert.equal(proxy(new NextRequest('http://wiki.test'+path,{method})).status,200,`${method} ${path}`);
|
||||
assert.equal(proxy(new NextRequest('http://wiki.test'+path,{method:'POST'})).status,401,`POST ${path} must not bypass authentication`);
|
||||
}
|
||||
const headers={cookie:'prompt_session='+signSession(config),host:'wiki.test'};
|
||||
assert.equal(proxy(new NextRequest('http://wiki.test/api/feed',{headers})).status,200);
|
||||
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://evil.test'}})).status,403);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {db} from '../src/lib/db';
|
||||
import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
|
||||
import type {CanvasDocument} from '../src/lib/canvas-document';
|
||||
export async function verifyCanvasWorkspace(base:string){
|
||||
const identities=await Promise.all([0,1].map(async()=>{const id=randomUUID(),token=newToken();await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,'test',now())",[id,id+'@example.com']);await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES($1,$2,'session',now()+interval '1 hour')",[tokenDigest(token),id]);return {[`cookie`]:ACCOUNT_COOKIE+'='+token}}));
|
||||
const origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base;
|
||||
const call=(path:string,method='GET',body?:unknown,headers:Record<string,string>={})=>fetch(base+'/api/canvases'+path,{method,headers:{'Content-Type':'application/json',origin,...headers},...(body===undefined?{}:{body:JSON.stringify(body)})});
|
||||
const doc={version:1,cards:[{id:'note',kind:'text',title:'简报',text:'自然光与城市清晨',x:0,y:0},{id:'script',kind:'script',title:'脚本',text:'',instruction:'写六个分镜',operation:'script',x:500,y:0}],links:[['note','script']]};
|
||||
assert.equal((await call('','POST',{document:doc})).status,401);
|
||||
const create=await call('','POST',{document:doc},identities[0]);assert.equal(create.status,201,await create.clone().text());const canvas=await create.json();assert.equal(canvas.revision,1);
|
||||
const root='/'+canvas.id;
|
||||
assert.equal((await call(root,'GET',undefined,identities[1])).status,404);
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],origin:'https://evil.example'})).status,403);
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],authorization:'Bearer invalid',origin:'https://evil.example'})).status,404,'A bearer request cannot fall back to an owner cookie');
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:{...doc,links:[['note','script'],['script','note']]}},identities[0])).status,400,'Imported cyclic graphs are rejected');
|
||||
const linked=await call(root+'/link','POST',{},identities[0]);assert.equal(linked.status,201);const credentials=await linked.json();const agent={authorization:'Bearer '+credentials.token};
|
||||
assert.equal((await call('','GET',undefined,agent)).status,401);
|
||||
const other=await (await call('','POST',{document:doc},identities[0])).json();assert.equal((await call('/'+other.id,'GET',undefined,agent)).status,404,'A link cannot read another canvas');
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,200);
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,409);
|
||||
async function queue(rev:number){const r=await call(root+'/tasks','POST',{revision:rev,targetId:'script'},identities[0]);assert.equal(r.status,201,await r.clone().text());return (await r.json()).id as string}
|
||||
const concurrent=await Promise.all([0,1].map(()=>call('/'+other.id,'PUT',{revision:1,document:doc},identities[0])));
|
||||
assert.deepEqual(concurrent.map(r=>r.status).sort(),[200,409],'Concurrent saves cannot lose an update');
|
||||
const first=await queue(2);
|
||||
assert.equal((await call(root+'/tasks','POST',{revision:2,targetId:'script'},identities[0])).status,409,'Only one live task per target');
|
||||
const recipe=await (await call(root+'/tasks/'+first,'GET',undefined,agent)).json();assert.equal(recipe.request.references[0].text,doc.cards[0].text);assert.equal(recipe.request.instruction,'写六个分镜');
|
||||
assert.equal((await call(root+'/tasks/'+first,'GET',undefined,identities[1])).status,404);
|
||||
const result={status:'succeeded',result:{text:'第一镜:城市醒来。'}};
|
||||
assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Result cannot arrive before claim');
|
||||
assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'running'},agent)).status,200);
|
||||
assert.equal((await call(root,'PUT',{revision:2,document:{...doc,cards:doc.cards.map(n=>({...n,x:n.x+10}))}},identities[0])).status,200);
|
||||
assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Stale result must not overwrite newer user edits');
|
||||
assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'failed',error:'依据新版本重新派发'},identities[0])).status,200);
|
||||
const second=await queue(3);assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'running'},agent)).status,200);
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:{invalid:true}}},agent)).status,400,'Malformed results do not mutate the canvas');
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{image:'data:image/png;base64,AAAA'}},agent)).status,400,'A script node cannot accept an image result');
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200);
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200,'Identical completion is idempotent');
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:'覆盖'}},agent)).status,409);
|
||||
const final=await (await call(root,'GET',undefined,identities[0])).json();assert.equal(final.revision,4);assert.equal(final.document.cards.find((n:{id:string})=>n.id==='script').text,'第一镜:城市醒来。');assert.deepEqual(final.document.links,doc.links);
|
||||
const png='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
|
||||
const imageDocument:CanvasDocument={version:1,cards:[
|
||||
{id:'source',kind:'image',title:'参考图',text:'保留原图',image:png,x:0,y:0},
|
||||
{id:'generated',kind:'image',title:'图生图',text:'',instruction:'保留构图,改为雪夜',operation:'generate',x:500,y:0},
|
||||
{id:'upscaled',kind:'image',title:'图片高清',text:'',operation:'upscale',x:1000,y:0},
|
||||
],links:[['source','generated'],['generated','upscaled']]};
|
||||
const imageCreate=await call('','POST',{document:imageDocument},identities[0]);assert.equal(imageCreate.status,201);
|
||||
const imageCanvas=await imageCreate.json(),imageRoot='/'+imageCanvas.id;
|
||||
const imageLink=await call(imageRoot+'/link','POST',{},identities[0]);assert.equal(imageLink.status,201);
|
||||
const imageAgent={authorization:'Bearer '+(await imageLink.json()).token};
|
||||
for(const [index,targetId] of ['generated','upscaled'].entries()){
|
||||
const queued=await call(imageRoot+'/tasks','POST',{revision:index+1,targetId},identities[0]);assert.equal(queued.status,201,await queued.clone().text());
|
||||
const taskId=(await queued.json()).id,taskPath=imageRoot+'/tasks/'+taskId;
|
||||
const snapshotResponse=await call(taskPath,'GET',undefined,imageAgent);assert.equal(snapshotResponse.status,200);
|
||||
const snapshot=await snapshotResponse.json();assert.equal(snapshot.request.operation,index===0?'generate':'upscale');
|
||||
assert.equal(snapshot.request.references[0].id,index===0?'source':'generated');
|
||||
assert.equal(snapshot.request.references[0].image,png,'Image tasks carry the referenced image bytes');
|
||||
assert.deepEqual(snapshot.request.result.allowedFields,['image','text']);
|
||||
assert.equal((await call(taskPath,'PATCH',{status:'running'},imageAgent)).status,200);
|
||||
const completion={status:'succeeded',result:{image:png,text:index===0?'雪夜图片结果':'高清图片结果'}};
|
||||
const completed=await call(taskPath,'PATCH',completion,imageAgent);assert.equal(completed.status,200,await completed.clone().text());
|
||||
assert.equal((await call(taskPath,'PATCH',completion,imageAgent)).status,200,'Image completion retries are idempotent');
|
||||
const stateResponse=await call(imageRoot,'GET',undefined,identities[0]);assert.equal(stateResponse.status,200);
|
||||
const state=await stateResponse.json();assert.equal(state.revision,index+2);
|
||||
const cards=(state.document as CanvasDocument).cards;
|
||||
assert.equal(cards.find(n=>n.id===targetId)?.image,png);assert.equal(cards.find(n=>n.id===targetId)?.text,completion.result.text);
|
||||
assert.deepEqual(cards.find(n=>n.id==='source'),imageDocument.cards[0],'Generation preserves the reference node');
|
||||
assert.deepEqual(state.document.links,imageDocument.links,'Image results preserve the reference graph');
|
||||
if(index===0)assert.deepEqual(cards.find(n=>n.id==='upscaled'),imageDocument.cards[2],'Generation only updates its target');
|
||||
else assert.equal(cards.find(n=>n.id==='generated')?.text,'雪夜图片结果','Upscaling preserves the previous generated image');
|
||||
}
|
||||
assert.equal((await call(imageRoot+'/link','DELETE',{},identities[0])).status,200);
|
||||
assert.equal((await call(imageRoot,'GET',undefined,imageAgent)).status,404);
|
||||
assert.equal((await call(root+'/link','POST',{},agent)).status,403,'A delegated agent cannot mint links');
|
||||
assert.equal((await call(root+'/link','DELETE',{},identities[0])).status,200);
|
||||
assert.equal((await call(root,'GET',undefined,agent)).status,404,'Revocation takes effect on the next request');
|
||||
const rotated=await (await call(root+'/link','POST',{},identities[0])).json();
|
||||
const expired={authorization:'Bearer '+rotated.token};
|
||||
await db().query("UPDATE canvas_agent_links SET expires_at=now()-interval '1 second' WHERE canvas_id=$1",[canvas.id]);
|
||||
assert.equal((await call(root,'GET',undefined,expired)).status,404,'Expired capabilities are rejected');
|
||||
const rotations=await Promise.all([call(root+'/link','POST',{},identities[0]),call(root+'/link','POST',{},identities[0])]);
|
||||
assert.ok(rotations.every(r=>r.status===201));
|
||||
assert.equal((await db().query('SELECT count(*)::int AS count FROM canvas_agent_links WHERE canvas_id=$1',[canvas.id])).rows[0].count,1,'Concurrent rotation leaves one active capability');
|
||||
const docs=await call(root+'?agent_mode=True');assert.equal(docs.status,200);assert.match(await docs.text(),/Authorization/);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {chromium,type Request} from 'playwright-core';
|
||||
import {db} from '../src/lib/db';
|
||||
import {hashPassword,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
|
||||
|
||||
export async function verifyCanvasAgentUI(base:string){
|
||||
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1');
|
||||
const owner=randomUUID(),email=owner+'@example.com',password='Canvas integration example1!';
|
||||
await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,$3,now())",[owner,email,await hashPassword(password)]);
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
const context=await browser.newContext({viewport:{width:1440,height:1000}}),errors:string[]=[];
|
||||
const page=await context.newPage(),canvasRequests:string[]=[],pending=new Set<Request>(),network:string[]=[];
|
||||
const started=Date.now(),navigation:{ms:number;event:string;path:string}[]=[];
|
||||
const mark=(event:string)=>{navigation.push({ms:Date.now()-started,event,path:new URL(page.url()).pathname});if(navigation.length>60)navigation.shift()};
|
||||
page.on('framenavigated',frame=>{if(frame===page.mainFrame())mark('framenavigated')});
|
||||
page.on('domcontentloaded',()=>mark('domcontentloaded'));page.on('load',()=>mark('load'));
|
||||
const requestName=(request:{method:()=>string;url:()=>string})=>request.method()+' '+new URL(request.url()).pathname;
|
||||
page.on('request',request=>pending.add(request));
|
||||
page.on('requestfinished',request=>pending.delete(request));
|
||||
page.on('requestfailed',request=>{pending.delete(request);network.push(requestName(request)+': '+request.failure()?.errorText)});
|
||||
page.on('response',response=>{const url=new URL(response.url());if(url.pathname.startsWith('/api/canvases'))canvasRequests.push(`${response.request().method()} ${url.pathname} ${response.status()}`)});
|
||||
try{
|
||||
page.setDefaultTimeout(20000);page.on('pageerror',e=>errors.push(e.message));
|
||||
mark('open landing');await page.goto(base+'/',{waitUntil:'domcontentloaded'});
|
||||
await page.getByRole('heading',{name:/想法不该停在对话里/,level:1}).waitFor();
|
||||
const accountSession=page.waitForResponse(response=>new URL(response.url()).pathname==='/api/account/session'&&response.request().method()==='GET');
|
||||
const [,sessionResponse]=await Promise.all([page.getByRole('link',{name:'开始创作',exact:true}).click(),accountSession]);
|
||||
assert.equal(sessionResponse.status(),200);assert.equal((await sessionResponse.json()).user,null);
|
||||
await page.getByRole('heading',{name:'欢迎回来',exact:true,level:2}).waitFor();
|
||||
mark('login form ready');
|
||||
await page.getByLabel('邮箱地址',{exact:true}).fill(email);
|
||||
await page.getByLabel('密码',{exact:true}).fill(password);
|
||||
mark('before login click');await page.locator('form').getByRole('button',{name:'登录',exact:true}).click();
|
||||
mark('after login click');
|
||||
await page.waitForURL(url=>url.pathname==='/workspace',{waitUntil:'commit'});
|
||||
mark('workspace URL accepted');
|
||||
await page.getByRole('heading',{name:'今天,让哪个想法成形?',exact:true}).waitFor();
|
||||
mark('workspace rendered');
|
||||
const session=(await context.cookies()).find(c=>c.name===ACCOUNT_COOKIE);assert.ok(session?.httpOnly,'The actual login response establishes an HTTP-only account session');
|
||||
const ownerHeaders={cookie:ACCOUNT_COOKIE+'='+session.value};
|
||||
await page.getByRole('link',{name:'无限画布',exact:true}).click();
|
||||
await page.getByRole('link',{name:email,exact:true}).waitFor({state:'attached'});
|
||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
||||
const shot=page.locator('[data-node-id="shot"] [data-node-content]');
|
||||
await shot.click();await shot.click();
|
||||
const composer=page.getByRole('region',{name:'节点编辑器',exact:true});
|
||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('根据参考资料写六个分镜');
|
||||
await composer.getByRole('button',{name:'交给 Agent',exact:true}).click();
|
||||
const panel=page.getByRole('region',{name:'Agent 连接',exact:true});
|
||||
await panel.getByText('任务已创建,等待你的 coding agent 领取',{exact:true}).waitFor();
|
||||
const list=await fetch(base+'/api/canvases',{headers:ownerHeaders}).then(r=>r.json());assert.equal(list.canvases.length,1);
|
||||
const root='/api/canvases/'+list.canvases[0].id;
|
||||
const state=await fetch(base+root,{headers:ownerHeaders}).then(r=>r.json());assert.equal(state.tasks.length,1);assert.equal(state.tasks[0].status,'queued');
|
||||
await panel.getByRole('button',{name:'生成连接凭据',exact:true}).click();
|
||||
const credentials=panel.getByRole('textbox',{name:'Agent 连接凭据',exact:true});await credentials.waitFor();
|
||||
await panel.getByRole('button',{name:'复制给 coding agent',exact:true}).click();
|
||||
await panel.getByRole('status').filter({hasText:/凭据已复制|已选中凭据/}).waitFor();
|
||||
const first=JSON.parse(await credentials.inputValue());assert.equal(first.canvasUrl,base+root);
|
||||
const agentHeaders={'Content-Type':'application/json',authorization:first.authorization};
|
||||
const task=root+'/tasks/'+state.tasks[0].id;
|
||||
const recipe=await fetch(base+task,{headers:agentHeaders}).then(r=>r.json());assert.equal(recipe.request.instruction,'根据参考资料写六个分镜');assert.equal(recipe.request.references[0].id,'reference');
|
||||
const patch=async(body:unknown)=>fetch(base+task,{method:'PATCH',headers:agentHeaders,body:JSON.stringify(body)});
|
||||
assert.equal((await patch({status:'running'})).status,200);
|
||||
await panel.getByText(/Agent 正在处理/).waitFor();
|
||||
// This fixture verifies the external-client protocol and UI delivery, not model quality.
|
||||
let generated='第一镜:晨光穿过窗边的植物。';
|
||||
assert.equal((await patch({status:'succeeded',result:{text:generated}})).status,200);
|
||||
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
|
||||
const updated=await fetch(base+root,{headers:ownerHeaders}).then(r=>r.json());
|
||||
assert.equal(updated.document.cards.find((n:{id:string})=>n.id==='shot').instruction,'根据参考资料写六个分镜');
|
||||
await panel.getByRole('button',{name:'关闭 Agent 连接',exact:true}).click();
|
||||
await shot.click();await shot.click();
|
||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('再写一个新镜头');
|
||||
const queued=page.waitForResponse(r=>r.request().method()==='POST'&&new URL(r.url()).pathname===root+'/tasks');
|
||||
await composer.getByRole('button',{name:'交给 Agent',exact:true}).click();
|
||||
const queuedResponse=await queued;assert.equal(queuedResponse.status(),201);const secondTask=await queuedResponse.json();
|
||||
await panel.getByRole('button',{name:'关闭 Agent 连接',exact:true}).click();
|
||||
const brief=page.locator('[data-node-id="brief"] [data-node-content]');await brief.click();await brief.click();
|
||||
const localText=page.getByRole('textbox',{name:'01 / 创意简报 内容',exact:true});await localText.fill('用户尚未同步的本机修改');
|
||||
const nextTask=root+'/tasks/'+secondTask.id;
|
||||
const nextPatch=(body:unknown)=>fetch(base+nextTask,{method:'PATCH',headers:agentHeaders,body:JSON.stringify(body)});
|
||||
assert.equal((await nextPatch({status:'running'})).status,200);
|
||||
generated='第二镜:城市在雨后醒来。';
|
||||
assert.equal((await nextPatch({status:'succeeded',result:{text:generated}})).status,200);
|
||||
await page.getByRole('button',{name:'Agent · 在线画布',exact:true}).click();
|
||||
await panel.getByRole('button',{name:'载入在线版本',exact:true}).waitFor();
|
||||
assert.equal(await localText.inputValue(),'用户尚未同步的本机修改','Polling must not replace unsaved local work');
|
||||
await panel.getByRole('button',{name:'载入在线版本',exact:true}).click();
|
||||
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
|
||||
assert.equal(await page.locator('[data-node-id="brief"] [data-node-content]').textContent(),state.document.cards.find((n:{id:string})=>n.id==='brief').text);
|
||||
await panel.getByRole('button',{name:'生成连接凭据',exact:true}).click();
|
||||
await page.waitForFunction(old=>{const field=document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Agent 连接凭据"]');return !!field&&JSON.parse(field.value).authorization!==old},first.authorization);
|
||||
const second=JSON.parse(await credentials.inputValue());assert.equal((await fetch(base+root,{headers:agentHeaders})).status,404);
|
||||
await panel.getByRole('button',{name:'撤销连接',exact:true}).click();
|
||||
await panel.getByText('Agent 访问已撤销',{exact:true}).waitFor();
|
||||
assert.equal((await fetch(base+root,{headers:{authorization:second.authorization}})).status,404);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
const bounds=await panel.boundingBox();assert.ok(bounds&&bounds.x>=0&&bounds.x+bounds.width<=390,'Agent panel stays within the mobile viewport');
|
||||
await page.reload({waitUntil:'domcontentloaded'});
|
||||
await page.getByRole('link',{name:email,exact:true,includeHidden:true}).waitFor({state:'attached'});
|
||||
await page.getByRole('button',{name:'Agent',exact:true}).click();
|
||||
const reopened=page.getByRole('region',{name:'Agent 连接',exact:true});
|
||||
await reopened.getByRole('combobox',{name:'打开在线画布',exact:true}).selectOption(list.canvases[0].id);
|
||||
await reopened.getByRole('button',{name:'载入在线版本',exact:true}).click();
|
||||
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('Canvas navigation timeline',JSON.stringify(navigation));
|
||||
console.log('Canvas UI dispatched a real stored task, received an external-client fixture result, rotated/revoked access and restored its online document');
|
||||
}catch(error){
|
||||
const statuses=await page.getByRole('status').allTextContents().catch(()=>[]);
|
||||
const headings=await page.getByRole('heading').allTextContents().catch(()=>[]);
|
||||
const documentState=await page.evaluate(()=>({readyState:document.readyState,navigation:performance.getEntriesByType('navigation').map(entry=>{const timing=entry as PerformanceNavigationTiming;return {type:timing.type,domContentLoadedEventEnd:timing.domContentLoadedEventEnd,loadEventEnd:timing.loadEventEnd}})})).catch(()=>null);
|
||||
console.error('Canvas agent UI diagnostics',JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),headings,statuses,documentState,navigation,pending:[...pending].map(requestName),network:network.slice(-20),canvasRequests:canvasRequests.slice(-20),errors}));
|
||||
throw error;
|
||||
}finally{await context.close();await browser.close()}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {verifyGuidanceWorkspaceUI} from './guidance-workspace-ui';
|
||||
import {chromium} from 'playwright-core';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
|
||||
@@ -30,12 +31,22 @@ export async function verifyGuidancePublishing(base:string){
|
||||
const own=await fetch(base+'/api/documents?author='+owner).then(r=>r.json());assert.ok(own.documents.some((d:{id:string})=>d.id===httpDoc.id));assert.ok(own.documents.every((d:{author:string})=>d.author===owner));assert.equal((await fetch(base+'/api/documents?author=invalid')).status,400);
|
||||
const xml=await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1&format=xml');assert.equal(xml.status,200);assert.match(await xml.text(),/<tag>往返标签<\/tag>/);
|
||||
const page=await fetch(base+'/documents/'+httpDoc.id+'?revision=1');assert.equal(page.status,200);assert.ok((await page.text()).includes('实际数据库文档'));
|
||||
// The homepage and discovery list hydrate from the real API, so verify them in Chromium on the CI runner.
|
||||
const directory=await fetch(base+'/api/documents');assert.equal(directory.status,200);assert.ok((await directory.json()).documents.some((d:{id:string})=>d.id===httpDoc.id),'Published document must appear in the public API');
|
||||
// The document discovery list hydrates from the real API; the homepage is the product landing page.
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
try{
|
||||
const page=await browser.newPage();
|
||||
const navigationDiagnostics:string[]=[],inflight=new Set<string>();
|
||||
const record=(value:string)=>{navigationDiagnostics.push(value.slice(0,1800));if(navigationDiagnostics.length>24)navigationDiagnostics.shift();};
|
||||
page.on('pageerror',error=>record('pageerror: '+error.message));
|
||||
page.on('console',message=>{if(message.type()==='error')record('console: '+message.text());});
|
||||
page.on('request',request=>inflight.add(request.url()));
|
||||
page.on('requestfinished',request=>inflight.delete(request.url()));
|
||||
page.on('requestfailed',request=>{inflight.delete(request.url());record('requestfailed: '+new URL(request.url()).pathname+' '+request.failure()?.errorText)});
|
||||
page.on('response',response=>{const path=new URL(response.url()).pathname;if(path.startsWith('/api/')||path.includes('/_next/data/'))void response.text().then(body=>record(`data ${response.status()} ${path}: ${body.slice(0,1400)}`)).catch(()=>{});else if(response.status()>=400||response.request().resourceType()==='script')record(`resource ${response.status()} ${path}`);});
|
||||
try{
|
||||
const link=`a[href="/documents/${httpDoc.id}?revision=1"]`;
|
||||
await page.goto(base+'/',{waitUntil:'domcontentloaded'});
|
||||
const navigation=await page.goto(base+'/documents',{waitUntil:'domcontentloaded'});record('directory HTTP '+navigation?.status());
|
||||
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title);
|
||||
// Published matches must not inherit the examples' empty state; submitted filters survive reload.
|
||||
const search=page.getByRole('textbox',{name:'搜索文档'});
|
||||
@@ -54,8 +65,15 @@ export async function verifyGuidancePublishing(base:string){
|
||||
await page.locator(link).waitFor({state:'visible'});
|
||||
assert.equal(await search.inputValue(),'');
|
||||
await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'});
|
||||
await page.locator(link).click();await page.locator('h1').waitFor();assert.equal(await page.locator('h1').textContent(),httpDoc.title);
|
||||
await page.locator(link).click();
|
||||
await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1');
|
||||
await page.getByRole('heading',{name:httpDoc.title,exact:true,level:1}).waitFor();
|
||||
assert.equal(await page.locator('h1').textContent(),httpDoc.title);
|
||||
await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档');
|
||||
}catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),body:(await page.locator('body').innerText()).slice(0,2200),inflight:[...inflight].map(url=>new URL(url).pathname),events:navigationDiagnostics}));throw error;}
|
||||
await page.close();
|
||||
await verifyGuidanceWorkspaceUI(base,browser);
|
||||
console.log('Workspace desktop/mobile, filters, reading, account and publishing assertions passed in a fresh browser context');
|
||||
}finally{await browser.close();}
|
||||
assert.equal((await request('/'+httpDoc.id,'DELETE',undefined,{cookie:''})).status,401);
|
||||
assert.equal((await request('/'+httpDoc.id,'DELETE')).status,200);
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/** UI fixtures run in a fresh context; real storage and authentication have separate coverage. */
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import type { Browser } from "playwright-core";
|
||||
import { documentInput, parseDocumentXml } from "../src/lib/guidance-input";
|
||||
import { guidanceXml, type Guidance } from "../src/content/guides/catalog";
|
||||
const owner = "11111111-1111-4111-8111-111111111111";
|
||||
const authored: Guidance = {
|
||||
id: "user-" + owner + "-review",
|
||||
revision: 1,
|
||||
kind: "guide",
|
||||
title: "准备一次清晰的代码审查",
|
||||
summary: "用明确的问题和证据,完成一次有用的代码审查。",
|
||||
applies: "提交合并请求前",
|
||||
body: "先理解改动目标,再核对实现与验证。",
|
||||
steps: [],
|
||||
checks: [],
|
||||
links: [],
|
||||
tags: [],
|
||||
};
|
||||
export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
let signedIn = false,
|
||||
previewError = false,
|
||||
requestedAuthor: string | null = null,
|
||||
published: Guidance | null = null;
|
||||
const errors: string[] = [];
|
||||
let sessionReplies = 0;
|
||||
try {
|
||||
await context.route("**/api/account/session", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: {
|
||||
user: signedIn
|
||||
? {
|
||||
id: owner,
|
||||
email: "editor@example.com",
|
||||
verified_at: "2026-10-29T00:00:00Z",
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
sessionReplies += 1;
|
||||
});
|
||||
await context.route(
|
||||
(url) => url.pathname === "/api/documents",
|
||||
async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
requestedAuthor = new URL(route.request().url()).searchParams.get(
|
||||
"author",
|
||||
);
|
||||
return route.fulfill({
|
||||
json: { documents: [{ ...authored, author: owner }] },
|
||||
});
|
||||
}
|
||||
published = documentInput.parse(route.request().postDataJSON());
|
||||
return route.fulfill({ status: 201, json: { document: published } });
|
||||
},
|
||||
);
|
||||
await context.route("**/api/documents/preview", (route) => {
|
||||
if (previewError)
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { error: "输入不符合协议", fields: ["applies"] },
|
||||
});
|
||||
let document: Guidance;
|
||||
try {
|
||||
document =
|
||||
route.request().headers()["content-type"] === "application/xml"
|
||||
? parseDocumentXml(route.request().postData() || "")
|
||||
: documentInput.parse(route.request().postDataJSON());
|
||||
} catch {
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { error: "输入不符合协议" },
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
document,
|
||||
xml: guidanceXml(document),
|
||||
links: document.links.map((link) => ({ ...link, resolved: true })),
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route("**/api/documents/" + authored.id, (route) =>
|
||||
route.fulfill({ json: { document: authored } }),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("crash", () => errors.push("Browser renderer crashed"));
|
||||
const pending = new Set<string>(), network: string[] = [];
|
||||
page.on("request", request => pending.add(request.url()));
|
||||
page.on("requestfinished", request => pending.delete(request.url()));
|
||||
page.on("requestfailed", request => {pending.delete(request.url());network.push(new URL(request.url()).pathname+": "+request.failure()?.errorText);});
|
||||
async function navigate(url: string, reload = false) {
|
||||
const previousSessionReplies = sessionReplies;
|
||||
try {
|
||||
const navigation = await (reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}));
|
||||
assert.ok(navigation?.ok(), "Navigation returns a successful document");
|
||||
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
|
||||
assert.ok(sessionReplies > previousSessionReplies, "The new document requests its account fixture and renders the resulting account state");
|
||||
} catch (error) {
|
||||
const probeStarted=Date.now();
|
||||
const server=await fetch(url,{signal:AbortSignal.timeout(3000)}).then(async response=>{await response.body?.cancel();return {status:response.status,elapsedMs:Date.now()-probeStarted}}).catch(error=>({error:error instanceof Error?error.name:"unknown",elapsedMs:Date.now()-probeStarted}));
|
||||
console.error("Workspace UI navigation diagnostics",JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),server,pending:[...pending].map(value=>new URL(value).pathname),network:network.slice(-20),errors,body:(await page.locator("body").innerText().catch(()=>"unavailable")).slice(0,1600)}));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await navigate(base + "/documents");
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.title(), "社区文档 · 织页");
|
||||
const search = page.getByRole("textbox", { name: "搜索文档" });
|
||||
await search.fill("不存在的文档");
|
||||
await search.press("Enter");
|
||||
await page
|
||||
.getByRole("heading", { name: "没有匹配文档", exact: true })
|
||||
.waitFor();
|
||||
await navigate(page.url(), true);
|
||||
await page.getByRole("heading",{name:"没有匹配文档",exact:true}).waitFor();
|
||||
assert.equal(await search.inputValue(), "不存在的文档");
|
||||
await page
|
||||
.getByRole("button", { name: "清除所有筛选", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
await page.getByRole("tab", { name: /事实依据/ }).focus();
|
||||
await page.keyboard.press("Home");
|
||||
await page.getByRole("tab", { name: /全部文档/ }).waitFor();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
|
||||
await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await navigate(base + "/guide/review-changes?revision=1");
|
||||
await page.getByRole("tab", { name: "XML 源文档" }).click();
|
||||
assert.match(await page.locator("pre").innerText(), /linked-guidance/);
|
||||
await page.getByRole("tab", { name: /版本记录/ }).click();
|
||||
await page.getByText("当前阅读", { exact: true }).waitFor();
|
||||
await page.getByRole("tab", { name: "文档概览" }).click();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await navigate(base + "/account");
|
||||
await page.getByRole("button", { name: "创建账户", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "创建你的账户" }).waitFor();
|
||||
await page
|
||||
.getByLabel("密码", { exact: true })
|
||||
.fill("a-long-example-password");
|
||||
await page.getByRole("button", { name: "显示密码" }).click();
|
||||
assert.equal(
|
||||
await page.getByLabel("密码", { exact: true }).getAttribute("type"),
|
||||
"text",
|
||||
);
|
||||
await page.getByRole("button", { name: "隐藏密码" }).click();
|
||||
await page.getByLabel("密码", { exact: true }).fill("");
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await navigate(base + "/write");
|
||||
await page.getByRole("heading", { name: "登录后开始编写" }).waitFor();
|
||||
signedIn = true;
|
||||
await navigate(page.url(), true);
|
||||
await page.getByLabel("文档短名", { exact: true }).fill("workspace-test");
|
||||
await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档");
|
||||
await page
|
||||
.getByLabel("一句话摘要", { exact: false })
|
||||
.fill("可独立使用的指导");
|
||||
await page
|
||||
.getByLabel("适用条件", { exact: false })
|
||||
.fill("检查界面流程时");
|
||||
await page
|
||||
.getByLabel("正文", { exact: false })
|
||||
.fill("先判断适用条件,再阅读正文。");
|
||||
await page
|
||||
.getByLabel("建议步骤", { exact: true })
|
||||
.fill("第一步\n\n第二步");
|
||||
previewError = true;
|
||||
await page
|
||||
.getByRole("button", { name: "校验并预览", exact: true })
|
||||
.click();
|
||||
await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor();
|
||||
previewError = false;
|
||||
await page
|
||||
.getByRole("button", { name: "校验并预览", exact: true })
|
||||
.click();
|
||||
await page.getByRole("dialog").waitFor({ state: "visible" });
|
||||
await page.getByRole("button", { name: "返回编辑" }).click();
|
||||
assert.equal(
|
||||
await page.getByLabel("正文", { exact: false }).inputValue(),
|
||||
"先判断适用条件,再阅读正文。",
|
||||
);
|
||||
await page.getByRole("button", { name: "添加条件链接" }).click();
|
||||
await page.getByRole("button", { name: "移除关联文档 1" }).click();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.getByRole("button", { name: "发布文档", exact: true }).click();
|
||||
await page
|
||||
.getByRole("status")
|
||||
.filter({ hasText: "已公开发布" })
|
||||
.waitFor();
|
||||
assert.ok(published);
|
||||
assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]);
|
||||
assert.equal(
|
||||
(published as Guidance).id,
|
||||
"user-" + owner + "-workspace-test",
|
||||
);
|
||||
await navigate(base + "/documents?scope=mine");
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
assert.equal(requestedAuthor, owner);
|
||||
await navigate(base + "/write?id=" + authored.id);
|
||||
await page.waitForFunction(title=>Array.from(document.querySelectorAll<HTMLInputElement>("input")).some(input=>input.value===title),authored.title);
|
||||
assert.equal(
|
||||
await page.getByLabel("文档短名", { exact: true }).isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel("标题", { exact: false }).inputValue(),
|
||||
authored.title,
|
||||
);
|
||||
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
|
||||
const xmlInput = page.getByLabel("XML 输入");
|
||||
assert.match(await xmlInput.inputValue(), /revision="2"/);
|
||||
await xmlInput.fill(
|
||||
(await xmlInput.inputValue()).replace(
|
||||
authored.title,
|
||||
"通过 XML 更新的文档",
|
||||
),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "可视化编辑", exact: true })
|
||||
.click();
|
||||
assert.equal(
|
||||
await page.getByLabel("标题", { exact: false }).inputValue(),
|
||||
"通过 XML 更新的文档",
|
||||
);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await navigate(base + "/documents");
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.getByRole("button", { name: "打开导航" }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "工作区导航" })
|
||||
.waitFor({ state: "visible" });
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(
|
||||
await page.getByRole("dialog", { name: "工作区导航" }).isVisible(),
|
||||
false,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await navigate(base + "/guide/review-changes?revision=1");
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await navigate(base + "/account");
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await navigate(base + "/write");
|
||||
await page.getByLabel("文档短名", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
|
||||
await page.getByLabel("XML 输入").waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await navigate(base + "/protocol");
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png",
|
||||
fullPage: true,
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
@@ -1,326 +1,13 @@
|
||||
/** UI contract checks use isolated API fixtures; real storage/auth remain covered by guidance-integration.ts. */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright-core";
|
||||
import { documentInput, parseDocumentXml } from "../src/lib/guidance-input";
|
||||
import { guidanceXml, type Guidance } from "../src/content/guides/catalog";
|
||||
import { verifyGuidanceWorkspaceUI } from "./guidance-workspace-ui";
|
||||
const base = process.env.WORKSPACE_TEST_URL;
|
||||
const owner = "11111111-1111-4111-8111-111111111111";
|
||||
const authored: Guidance = {
|
||||
id: "user-" + owner + "-review",
|
||||
revision: 1,
|
||||
kind: "guide",
|
||||
title: "准备一次清晰的代码审查",
|
||||
summary: "用明确的问题和证据,完成一次有用的代码审查。",
|
||||
applies: "提交合并请求前",
|
||||
body: "先理解改动目标,再核对实现与验证。",
|
||||
steps: [],
|
||||
checks: [],
|
||||
links: [],
|
||||
tags: [],
|
||||
};
|
||||
test(
|
||||
"workspace filters, reading tabs, account states and editor work on desktop and mobile",
|
||||
{ skip: !base },
|
||||
async () => {
|
||||
const browser = await chromium.launch({
|
||||
executablePath:
|
||||
process.env.CHROMIUM_PATH ||
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
let signedIn = false,
|
||||
previewError = false,
|
||||
requestedAuthor: string | null = null,
|
||||
published: Guidance | null = null;
|
||||
const errors: string[] = [];
|
||||
try {
|
||||
await context.route("**/api/account/session", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
user: signedIn
|
||||
? {
|
||||
id: owner,
|
||||
email: "editor@example.com",
|
||||
verified_at: "2026-10-29T00:00:00Z",
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await context.route(
|
||||
(url) => url.pathname === "/api/documents",
|
||||
async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
requestedAuthor = new URL(route.request().url()).searchParams.get(
|
||||
"author",
|
||||
);
|
||||
return route.fulfill({
|
||||
json: { documents: [{ ...authored, author: owner }] },
|
||||
});
|
||||
}
|
||||
published = documentInput.parse(route.request().postDataJSON());
|
||||
return route.fulfill({ status: 201, json: { document: published } });
|
||||
},
|
||||
);
|
||||
await context.route("**/api/documents/preview", (route) => {
|
||||
if (previewError)
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { error: "输入不符合协议", fields: ["applies"] },
|
||||
});
|
||||
let document: Guidance;
|
||||
try {
|
||||
document =
|
||||
route.request().headers()["content-type"] === "application/xml"
|
||||
? parseDocumentXml(route.request().postData() || "")
|
||||
: documentInput.parse(route.request().postDataJSON());
|
||||
} catch {
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { error: "输入不符合协议" },
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
document,
|
||||
xml: guidanceXml(document),
|
||||
links: document.links.map((link) => ({ ...link, resolved: true })),
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route("**/api/documents/" + authored.id, (route) =>
|
||||
route.fulfill({ json: { document: authored } }),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(base!, { waitUntil: "networkidle" });
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.title(), "探索文档 · 织页");
|
||||
const search = page.getByRole("textbox", { name: "搜索文档" });
|
||||
await search.fill("不存在的文档");
|
||||
await search.press("Enter");
|
||||
await page
|
||||
.getByRole("heading", { name: "没有匹配文档", exact: true })
|
||||
.waitFor();
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
assert.equal(await search.inputValue(), "不存在的文档");
|
||||
await page
|
||||
.getByRole("button", { name: "清除所有筛选", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
await page.getByRole("tab", { name: /事实依据/ }).focus();
|
||||
await page.keyboard.press("Home");
|
||||
await page.getByRole("tab", { name: /全部文档/ }).waitFor();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
|
||||
await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.goto(base + "/guide/review-changes?revision=1", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await page.getByRole("tab", { name: "XML 源文档" }).click();
|
||||
assert.match(await page.locator("pre").innerText(), /linked-guidance/);
|
||||
await page.getByRole("tab", { name: /版本记录/ }).click();
|
||||
await page.getByText("当前阅读", { exact: true }).waitFor();
|
||||
await page.getByRole("tab", { name: "文档概览" }).click();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/account", { waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "创建账户", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "创建你的账户" }).waitFor();
|
||||
await page
|
||||
.getByLabel("密码", { exact: true })
|
||||
.fill("a-long-example-password");
|
||||
await page.getByRole("button", { name: "显示密码" }).click();
|
||||
assert.equal(
|
||||
await page.getByLabel("密码", { exact: true }).getAttribute("type"),
|
||||
"text",
|
||||
);
|
||||
await page.getByRole("button", { name: "隐藏密码" }).click();
|
||||
await page.getByLabel("密码", { exact: true }).fill("");
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/write", { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "登录后开始编写" }).waitFor();
|
||||
signedIn = true;
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByLabel("文档短名", { exact: true }).fill("workspace-test");
|
||||
await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档");
|
||||
await page
|
||||
.getByLabel("一句话摘要", { exact: false })
|
||||
.fill("可独立使用的指导");
|
||||
await page
|
||||
.getByLabel("适用条件", { exact: false })
|
||||
.fill("检查界面流程时");
|
||||
await page
|
||||
.getByLabel("正文", { exact: false })
|
||||
.fill("先判断适用条件,再阅读正文。");
|
||||
await page
|
||||
.getByLabel("建议步骤", { exact: true })
|
||||
.fill("第一步\n\n第二步");
|
||||
previewError = true;
|
||||
await page
|
||||
.getByRole("button", { name: "校验并预览", exact: true })
|
||||
.click();
|
||||
await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor();
|
||||
previewError = false;
|
||||
await page
|
||||
.getByRole("button", { name: "校验并预览", exact: true })
|
||||
.click();
|
||||
await page.getByRole("dialog").waitFor({ state: "visible" });
|
||||
await page.getByRole("button", { name: "返回编辑" }).click();
|
||||
assert.equal(
|
||||
await page.getByLabel("正文", { exact: false }).inputValue(),
|
||||
"先判断适用条件,再阅读正文。",
|
||||
);
|
||||
await page.getByRole("button", { name: "添加条件链接" }).click();
|
||||
await page.getByRole("button", { name: "移除关联文档 1" }).click();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.getByRole("button", { name: "发布文档", exact: true }).click();
|
||||
await page
|
||||
.getByRole("status")
|
||||
.filter({ hasText: "已公开发布" })
|
||||
.waitFor();
|
||||
assert.ok(published);
|
||||
assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]);
|
||||
assert.equal(
|
||||
(published as Guidance).id,
|
||||
"user-" + owner + "-workspace-test",
|
||||
);
|
||||
await page.goto(base + "/documents?scope=mine", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
assert.equal(requestedAuthor, owner);
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
await page.goto(base + "/write?id=" + authored.id, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
assert.equal(
|
||||
await page.getByLabel("文档短名", { exact: true }).isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel("标题", { exact: false }).inputValue(),
|
||||
authored.title,
|
||||
);
|
||||
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
|
||||
const xmlInput = page.getByLabel("XML 输入");
|
||||
assert.match(await xmlInput.inputValue(), /revision="2"/);
|
||||
await xmlInput.fill(
|
||||
(await xmlInput.inputValue()).replace(
|
||||
authored.title,
|
||||
"通过 XML 更新的文档",
|
||||
),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "可视化编辑", exact: true })
|
||||
.click();
|
||||
assert.equal(
|
||||
await page.getByLabel("标题", { exact: false }).inputValue(),
|
||||
"通过 XML 更新的文档",
|
||||
);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(base!, { waitUntil: "networkidle" });
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.getByRole("button", { name: "打开导航" }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "工作区导航" })
|
||||
.waitFor({ state: "visible" });
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(
|
||||
await page.getByRole("dialog", { name: "工作区导航" }).isVisible(),
|
||||
false,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/guide/review-changes?revision=1", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/account", { waitUntil: "networkidle" });
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.goto(base + "/write", { waitUntil: "networkidle" });
|
||||
await page.getByLabel("文档短名", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
|
||||
await page.getByLabel("XML 输入").waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/protocol", { waitUntil: "networkidle" });
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png",
|
||||
fullPage: true,
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
test("workspace filters, reading tabs, account states and editor work on desktop and mobile", {skip: !base}, async () => {
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROMIUM_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
try { await verifyGuidanceWorkspaceUI(base!, browser); }
|
||||
finally { await browser.close(); }
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {verifyCanvasWorkspace} from './canvas-integration';
|
||||
import {verifyCanvasAgentUI} from './canvas-ui-integration';
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {spawn} from 'node:child_process';
|
||||
@@ -20,7 +22,7 @@ import {verifyFlatNoteMigration} from './flat-note-migration';
|
||||
import {staticEntries} from '../src/lib/explore';
|
||||
import {publishLearningSeries} from '../src/lib/publish-learning-series';
|
||||
|
||||
test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processing', {timeout:300000}, async()=>{
|
||||
test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processing', {timeout:600000}, async()=>{
|
||||
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1','Run only against an explicitly isolated test database');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const address=socket.address();assert.ok(address&&typeof address!=='string');const port=address.port;await new Promise<void>(r=>socket.close(()=>r()));
|
||||
const external=process.env.PROMPT_TEST_BASE_URL;
|
||||
@@ -32,9 +34,9 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready);
|
||||
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
|
||||
await verifyEmailAccounts();
|
||||
await verifyCanvasWorkspace(base);
|
||||
if(process.env.VERIFY_CANVAS_UI==='1')await verifyCanvasAgentUI(base);
|
||||
await verifyGuidancePublishing(base);
|
||||
const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:{...process.env,WORKSPACE_TEST_URL:base},timeout:90000,stdio:'inherit'});
|
||||
const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks');
|
||||
await verifyFlatNoteMigration();
|
||||
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'});
|
||||
const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag');
|
||||
@@ -230,8 +232,11 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
}
|
||||
const machineIndex=await fetch(base+'/api/guides');assert.equal(machineIndex.status,200);assert.equal((await machineIndex.json()).protocol,'linked-guidance/0.1');
|
||||
const home=await fetch(base+'/').then(r=>r.text());
|
||||
for(const href of ['/documents','/write','/protocol','/guide/review-changes?revision=1'])assert.ok(home.includes('href=\"'+href+'\"'),`Guidance homepage links to ${href}`);
|
||||
assert.ok(home.includes('社区发布'));assert.ok(home.includes('协议样例'));
|
||||
for(const href of ['/documents','/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes('href="'+href+'"'),`Landing page links to ${href}`);
|
||||
assert.ok(home.includes('让灵感,有形。'));
|
||||
for(const [oldPath,newPath] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
|
||||
const response=await fetch(base+oldPath,{redirect:'manual'});assert.equal(response.status,308);assert.equal(response.headers.get('location'),newPath);
|
||||
}
|
||||
// Legacy media and skill functionality stays covered by its own routes above and below.
|
||||
|
||||
const spec=await request('/api/skills/street-people');assert.equal(spec.generation.executor,'coding-agent');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Added only by isolated CI integration; no production ports or volumes.
|
||||
services:
|
||||
prompt-studio:
|
||||
image: ${STUDIO_REVIEW_IMAGE:?Build the Studio review image first}
|
||||
init: true
|
||||
environment:
|
||||
HOSTNAME: 0.0.0.0
|
||||
PORT: "3000"
|
||||
STUDIO_WIKI_ORIGIN: http://waterfall-wiki:3000
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop: [ALL]
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/studio/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
interval: 5s
|
||||
timeout: 8s
|
||||
retries: 24
|
||||
tests:
|
||||
environment:
|
||||
VERIFY_CANVAS_UI: "1"
|
||||
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,27 @@
|
||||
# Infinite canvas rebuild
|
||||
|
||||
The previous workspace displays node bodies but keeps editing in a fixed inspector. Header dragging exists in source, but the user's observed failure has not yet been reproduced in a browser. Do not claim otherwise.
|
||||
|
||||
References: https://docs.comfy.org/interface/overview (node canvas, parameters, canvas navigation); https://www.runninghub.cn/runninghub-api-doc-en/ (workflow API requires a real connected service). Image searches covered ComfyUI and RunningHub workspace layouts.
|
||||
|
||||
Implementation plan:
|
||||
1. Keep document coordinates independent from viewport translation and scale. Scale around the pointer, or viewport center for toolbar buttons.
|
||||
2. Put usable text/prompt editors and image actions inside movable nodes. Header is an explicit drag handle; bottom corner resizes. Inputs never initiate gestures.
|
||||
3. Store width/height and viewport alongside the existing v1 document; preserve old drafts and JSON imports. Links follow actual node dimensions.
|
||||
4. Add duplicate and gesture-level undo/redo, with keyboard shortcuts excluded from form fields.
|
||||
5. Browser acceptance must move two nodes before and after zoom, edit without moving, resize, check connections, undo/redo, pan, and reload persistence. Existing smoke test is insufficient.
|
||||
|
||||
Constraints: repository AGENTS requires server-only visual acceptance. Production redirects unauthenticated requests to /login. CI deploys only main; this branch must not merge or deploy without explicit authorization. Therefore a ready implementation alone is not completed visual acceptance.
|
||||
|
||||
Current checks: TypeScript noEmit (without next typegen/build) passed. Existing 13 unit tests passed; these do not validate canvas gestures. Browser script syntax and git diff checks passed. Browser acceptance and build have NOT run for this revision. Server unauthenticated HEAD returns 307 to /login?next=%2Fstudio%2Finfinite_canvas; in-app browser creation timed out. Waiting for user choice of permitted isolated acceptance environment before marking ready.
|
||||
|
||||
|
||||
## Final layout and acceptance
|
||||
User explicitly authorized temporary local runtime acceptance in this chat, overriding the server-only rule for this revision. A subsequent request requires the whole screen to be the dotted canvas. All reserved header/sidebar layout has been removed; compact floating chrome remains, and properties open on demand. Grid spacing adapts at low zoom to keep dots legible.
|
||||
|
||||
Browser acceptance ran in Chrome at 1600x1100 and 390x844. Passed: actual world-space movement of two nodes at two scales; editing without movement; resizing; gesture undo/redo; SVG connection start aligned to node geometry after resizing and panning; refresh document and viewport restoration; pointer-anchored Ctrl+wheel zoom; create/duplicate/delete/undo/redo; viewport equals window bounds and no horizontal mobile overflow. Production webpack compilation passed. Isolated-worktree dependency symlinks caused standalone tracing warnings (28 files outside tracing root), so deployment package completeness is not claimed. Local account-session request returns 404 because the shared account service runs in the full site; production authentication was not changed or validated here. Model generation remains explicitly unavailable.
|
||||
|
||||
Evidence screenshots are retained as intentional review deliverables under docs/evidence/canvas-rebuild/, not temporary output. Local server, dependency links and generated build/cache output are removed after acceptance. PR targets main for manual user merge; no deployment performed.
|
||||
|
||||
## Theme and unobstructed canvas follow-up
|
||||
Added persistent light/dark themes covering the grid, cards, input fields, floating chrome and connection lines. Removed the left tool strip and the inspector. The tool strip is now a centered bottom dock; zoom/history floats at top right. Clicking empty canvas opens add actions; node ellipsis or right-click opens copy/delete/connect actions near the pointer. Escape or clicking elsewhere dismisses the menu. Node editors remain inline. Browser checks cover dock placement, theme persistence and contextual CRUD in addition to the existing gesture suite. TypeScript, 13 canvas-branch tests and webpack compilation passed; 19 S3-branch tests passed separately. User explicitly authorized merging PR #23 and #24 in this follow-up. Shared account-service and standalone tracing limitations remain as noted above.
|
||||
@@ -7,6 +7,8 @@
|
||||
"dev:studio": "pnpm --filter @prompt/studio dev",
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "pnpm --filter @prompt/waterfall-wiki test"
|
||||
"test": "pnpm --filter @prompt/waterfall-wiki test",
|
||||
"test:brand": "pnpm --filter @endoleap/brand test",
|
||||
"build:brand": "pnpm --filter @endoleap/brand build"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:24-bookworm-slim
|
||||
WORKDIR /repo
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY packages/brand/package.json packages/brand/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY packages/brand/ ./
|
||||
RUN pnpm test && pnpm build && pnpm test:tailwind && pnpm test:types
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -m venv /opt/brand-python \
|
||||
&& /opt/brand-python/bin/pip install --only-binary=:all: Pillow==13.0.0 resvg-py==0.5.0
|
||||
RUN /opt/brand-python/bin/python tests/assets.py
|
||||
@@ -0,0 +1,41 @@
|
||||
# 啡跃 Endoleap 品牌包
|
||||
|
||||
本包把已选定的「摩卡奶霜」配色、四套正式主题和固定轮廓 Logo 放在同一个 workspace 包中。它提供 Tailwind CSS v4 / shadcn/ui 的主题接口及静态图片,不安装组件,也不自动重设应用页面。
|
||||
|
||||
## 文档入口
|
||||
|
||||
- [品牌与包边界](docs/overview.md)
|
||||
- [配色、组件状态与深色主题](docs/colors.md)
|
||||
- [Tailwind CSS、shadcn/ui 与包导入](docs/integration.md)
|
||||
- [Logo、平台图片与修改流程](docs/logo.md)
|
||||
|
||||
`tokens/palette.json` 是色值来源;四份主题 JSON 引用色板键;`tokens/geometry.json` 是唯一 Logo 路径来源。`src/themes.css` 是可直接导入的源样式,一致性由测试核对;`dist/` 仅在远程 CI 构建,不提交。
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "@endoleap/brand/themes.css";
|
||||
@import "@endoleap/brand/tailwind.css";
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 在主题根节点选择正式主题;应用负责持久化和切换。
|
||||
<main data-theme="canvas-dark" className="bg-background text-foreground">
|
||||
<button className="bg-primary text-primary-foreground hover:bg-primary-hover">
|
||||
开始创作
|
||||
</button>
|
||||
</main>
|
||||
```
|
||||
|
||||
主题名:`home-light`、`home-dark`、`canvas-light`、`canvas-dark`。没有 `drafts`、`proposed` 或隐式跟随系统的规则。`.dark` 兼容默认首页深色;显式 `data-theme` 优先。使用 `dark:` 工具类时,一个文档根节点只选择一个明暗主题,不在 `.dark` 祖先下嵌套浅色 `dark:` 变体。
|
||||
|
||||
## 验证
|
||||
|
||||
可进行的本地轻量检查:`node --test packages/brand/tests/brand.test.mjs`。它只读取源码和资产,检查主题、对比度、SVG 路径及 PNG/ICO 文件头,不编译和输出产物。
|
||||
|
||||
完整验证在已确认的 ces-control Docker runner 执行:
|
||||
|
||||
```sh
|
||||
docker build -f packages/brand/Dockerfile -t endoleap-brand-check .
|
||||
```
|
||||
|
||||
包含真实 Tailwind 编译、TypeScript 导入检查、品牌导出和 72 个 PNG 与 SVG 逐像素比较。不要在本机运行该 Docker 构建或 `build` / `test:tailwind` / `test:types` / `test:assets`。
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": "1.2",
|
||||
"brand": "啡跃 Endoleap",
|
||||
"colors": {
|
||||
"mocha": "#A47764",
|
||||
"brown": "#49372E",
|
||||
"cream": "#FFF6EF"
|
||||
},
|
||||
"geometry": "tokens/geometry.json",
|
||||
"svg_files": [
|
||||
"assets/svg/logo-light.svg",
|
||||
"assets/svg/logo-dark.svg",
|
||||
"assets/svg/mono-dark.svg",
|
||||
"assets/svg/mono-light.svg",
|
||||
"assets/svg/icon-dark.svg",
|
||||
"assets/svg/icon-light.svg",
|
||||
"assets/svg/app-square.svg",
|
||||
"assets/svg/avatar-circle.svg"
|
||||
],
|
||||
"png_sizes": [
|
||||
16,
|
||||
24,
|
||||
32,
|
||||
48,
|
||||
64,
|
||||
128,
|
||||
180,
|
||||
192,
|
||||
256,
|
||||
512,
|
||||
1024,
|
||||
2048
|
||||
],
|
||||
"ico_sizes": [
|
||||
16,
|
||||
24,
|
||||
32,
|
||||
48,
|
||||
64,
|
||||
128,
|
||||
256
|
||||
],
|
||||
"variants": {
|
||||
"logo-light": {
|
||||
"rim": "#A47764",
|
||||
"bowl": "#49372E"
|
||||
},
|
||||
"logo-dark": {
|
||||
"rim": "#A47764",
|
||||
"bowl": "#FFF6EF"
|
||||
},
|
||||
"mono-dark": {
|
||||
"rim": "#49372E",
|
||||
"bowl": "#49372E"
|
||||
},
|
||||
"mono-light": {
|
||||
"rim": "#FFF6EF",
|
||||
"bowl": "#FFF6EF"
|
||||
},
|
||||
"icon-dark": {
|
||||
"rim": "#A47764",
|
||||
"bowl": "#FFF6EF",
|
||||
"background": "#49372E"
|
||||
},
|
||||
"icon-light": {
|
||||
"rim": "#A47764",
|
||||
"bowl": "#49372E",
|
||||
"background": "#FFF6EF"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 527 B |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 755 B |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 981 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 742 B |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 943 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 613 B |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 780 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 12 KiB |