Compare commits

...

49 Commits

Author SHA1 Message Date
codex-bot fcc0aba810 Account for hidden mobile account link in canvas reload check
creative workspace review checks / verify (pull_request) Successful in 26m2s
2026-10-30 23:30:03 +08:00
codex-bot cdcd4c4b94 Record navigation timing to diagnose canvas integration failures
creative workspace review checks / verify (pull_request) Failing after 19m40s
2026-10-30 22:25:37 +08:00
codex-bot 041620e2a5 Merge main release sequencing into canvas workflow
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 22:14:08 +08:00
Zayden-Jung dd367e40ac Merge pull request 'Fix creative workspace release sequencing and complete UI verification' (#28) from codex/creative-release-fix into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 7m7s
Reviewed-on: #28
2026-10-30 14:08:28 +00:00
codex-bot 3d48f5042d Assert workspace readiness after navigation commit instead of window load
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 21:58:38 +08:00
codex-bot 71da117335 Wait for the real account session before testing the login form
creative workspace review checks / verify (pull_request) Failing after 33m55s
2026-10-30 21:21:18 +08:00
codex-bot d2eaabe0da Run canvas interaction acceptance against the deployed public server
creative workspace review checks / verify (pull_request) Failing after 25m58s
2026-10-30 20:39:07 +08:00
codex-bot 95673d5c54 Wait for canvas account readiness and capture dispatch failures
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 20:35:25 +08:00
codex-bot a0c20972ac Verify successful image generation and upscale task delivery 2026-10-30 20:30:25 +08:00
codex-bot 0c8cf7c4cd Share the integration browser while isolating complete workspace UI checks
creative workspace review checks / verify (pull_request) Failing after 20m31s
2026-10-30 19:39:33 +08:00
codex-bot 429e78c722 Run complete workspace UI assertions in an isolated context of the integration browser
creative workspace review checks / verify (pull_request) Successful in 23m26s
2026-10-30 19:37:59 +08:00
codex-bot f4f0d67306 Use account and content readiness throughout canvas integration navigation
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 19:07:58 +08:00
codex-bot 4faef8b766 Wait for visible application readiness in workspace UI regression checks
creative workspace review checks / verify (pull_request) Failing after 19m53s
2026-10-30 19:07:20 +08:00
codex-bot fb6f29db76 Verify landing login and workspace entry before canvas agent delivery
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:48:54 +08:00
codex-bot 2bcd5992eb Retain document directory failure diagnostics in canvas integration
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:37:17 +08:00
codex-bot d8c06d6cc6 Capture document directory network and hydration failures in CI
creative workspace review checks / verify (pull_request) Failing after 19m53s
2026-10-30 18:37:02 +08:00
codex-bot 15542e977a Verify unsaved canvas edits survive arriving agent results
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:30:20 +08:00
codex-bot 5ab0ca78b2 Support copying agent credentials on the public HTTP workspace
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:25:03 +08:00
codex-bot 8ab67def5f Verify external canvas agent delivery through both running applications in CI
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:23:03 +08:00
codex-bot 8944e59ead Align canvas integration with corrected workspace UI and auth checks
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:14:26 +08:00
codex-bot cad84f8a67 Run nested UI checks and align authentication assertions with public workspace routes
creative workspace review checks / verify (pull_request) Failing after 19m41s
2026-10-30 18:13:58 +08:00
codex-bot 33405b7d8b Exercise document directory at its canonical entry in UI regression tests 2026-10-30 18:07:21 +08:00
codex-bot 67dea99cd1 Cover canvas concurrency and invalid agent result isolation
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:02:12 +08:00
codex-bot de851085dd Validate imported reference graphs and report malformed canvas input
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:56:56 +08:00
codex-bot af4e9b13ed Keep agent panel within narrow canvas viewports
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:53:14 +08:00
codex-bot b1e6083996 Connect canvas editing to scoped external agent tasks
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:50:26 +08:00
codex-bot cfc6686904 Capture document client navigation failures in CI
creative workspace review checks / verify (pull_request) Failing after 20m32s
2026-10-30 17:44:05 +08:00
codex-bot 7f7fc28e57 Add scoped canvas agent API with versioned S3 state and task results
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:38:05 +08:00
codex-bot bd8ccbb4a1 Add selected-node editing, reference ports and fixed canvas composer
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:27:34 +08:00
codex-bot 40a6076961 Wait for document navigation before asserting its rendered heading
creative workspace review checks / verify (pull_request) Failing after 17m59s
2026-10-30 17:18:42 +08:00
codex-bot d072aeeaa0 Model canvas references, script nodes and agent task snapshots 2026-10-30 17:12:47 +08:00
codex-bot 53a4103308 Sequence creative workspace releases and verify document discovery at its new entry
creative workspace review checks / verify (pull_request) Failing after 17m42s
2026-10-30 16:57:48 +08:00
Zayden-Jung ea7bbebb3e Merge pull request 'Redesign creative landing page, workspace and editor entry points' (#27) from codex/landing-workspace into main
studio CI and deployment / verify-and-deploy (push) Failing after 11m29s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 19m46s
Reviewed-on: #27
2026-10-30 07:17:43 +00:00
codex-bot 94193d485b Cover public creative entries while retaining protected Studio API checks
creative workspace review checks / verify (pull_request) Successful in 13m24s
2026-10-30 14:43:06 +08:00
codex-bot 265cd7444b Describe external agent workflow in creator-facing editor copy
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:38:28 +08:00
codex-bot f1c794654e Edit canvas text and replace images directly on double click
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:32:32 +08:00
codex-bot fd9b8640b7 Keep graphic toolbar and header within narrow viewports
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:29:36 +08:00
codex-bot 4791729b65 Make graphic panels collapsible and fix hand panning over layers
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:28:28 +08:00
codex-bot d362e1a975 Verify landing redirects and wait for cross-app route deployment
creative workspace review checks / verify (push) Has been cancelled
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:21:12 +08:00
codex-bot 924724c5e4 Align server visual acceptance with canonical editor routes and canvas onboarding
creative workspace review checks / verify (pull_request) Has been cancelled
creative workspace review checks / verify (push) Has been cancelled
2026-10-30 14:16:14 +08:00
codex-bot eed5d43e21 Fetch review history before checking the patch
creative workspace review checks / verify (push) Failing after 27m59s
creative workspace review checks / verify (pull_request) Failing after 2m20s
2026-10-30 14:13:03 +08:00
codex-bot 5a0554b3b1 Introduce creative landing page and workspace with focused editor layouts
creative workspace review checks / verify (push) Failing after 4s
creative workspace review checks / verify (pull_request) Failing after 2s
2026-10-30 14:10:40 +08:00
Zayden-Jung 82bef38536 Merge pull request 'Allow whole-card dragging outside explicit edit mode' (#26) from codex/canvas-card-drag into main
studio CI and deployment / verify-and-deploy (push) Successful in 8m42s
Reviewed-on: #26
2026-10-29 18:14:12 +00:00
codex-bot d050e06870 Drag canvas cards from their whole surface outside explicit edit mode 2026-10-30 02:03:34 +08:00
Zayden-Jung 045c3d8188 Merge pull request 'Fix Studio release smoke after S3 template migration' (#25) from codex/studio-release-fix into main
studio CI and deployment / verify-and-deploy (push) Successful in 8m53s
Reviewed-on: #25
2026-10-29 17:08:33 +00:00
codex-bot f3e9f5a3fb Use isolated Wiki media fixtures in Studio release smoke checks 2026-10-30 00:59:58 +08:00
codex-bot 922c7bd0d0 Merge pull request 'Move repository images and embedded template bitmaps to S3' (#23) from codex/s3-media into main
studio CI and deployment / verify-and-deploy (push) Failing after 4m49s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 18m8s
2026-10-29 16:43:28 +00:00
codex-bot 3f8bd11a17 Merge pull request 'Rebuild Studio infinite canvas as full-screen movable tools' (#24) from codex/canvas-rebuild into main
studio CI and deployment / verify-and-deploy (push) Failing after 8m37s
2026-10-29 16:43:24 +00:00
codex-bot 19bba57561 Move repository content images to S3-backed media references 2026-10-29 23:03:11 +08:00
676 changed files with 6983 additions and 895 deletions
+37
View File
@@ -0,0 +1,37 @@
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'
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
- 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
-51
View File
@@ -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
+30 -2
View File
@@ -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'
@@ -18,11 +22,19 @@ 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: |
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 +44,19 @@ 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
+8
View File
@@ -10,3 +10,11 @@ dist/
__pycache__/
*.egg-info/
.venv/
# Content images belong in S3; metadata lives in managed-media.json.
*.png
*.jpg
*.jpeg
*.webp
*.gif
*.avif
+1
View File
@@ -34,6 +34,7 @@ COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+2 -2
View File
@@ -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`。
+1
View File
@@ -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
+7 -1
View File
@@ -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();
+81 -62
View File
@@ -1,74 +1,93 @@
// Supply authorized browser state for the server. Local acceptance was explicitly authorized for this revision.
// Isolated browser context against the designated deployed server only.
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs/promises');
(async()=>{
const base=process.env.BASE_URL;
assert.ok(['http://8.211.154.134:18131','http://127.0.0.1:3024'].includes(base),'Use the designated server or the explicitly authorized local preview');
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true}:{channel:'chrome',headless:true});
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});
try{
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base+'/studio/infinite_canvas');
assert.equal(new URL(page.url()).pathname,'/studio/infinite_canvas','Login required; authenticate without weakening protection');
await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();
// A fresh context is used so this test cannot overwrite a human browser draft.
const page=await context.newPage(),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 close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
async function drag(handle,dx,dy){const box=await handle.boundingBox();assert.ok(box);await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();await page.mouse.move(box.x+box.width/2+dx,box.y+box.height/2+dy,{steps:12});await page.mouse.up()}
for(const scale of [0,1]){
await page.getByRole('button',{name:'适应画布',exact:true}).click();
if(scale)await page.getByRole('button',{name:'放大',exact:true}).click();
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
for(const id of ['brief','reference']){
const before=(await snapshot()).cards.find(c=>c.id===id);
await drag(node(id).locator('[title="拖动手柄 · 移动工具"]'),45,25);
const after=(await snapshot()).cards.find(c=>c.id===id);close(after.x-before.x,45/z);close(after.y-before.y,25/z);
}
}
const before=(await snapshot()).cards.find(c=>c.id==='brief');
await node('brief').locator('textarea').fill('真实工具内编辑 · 输入不能移动节点');
const edited=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(edited.x,before.x);assert.equal(edited.y,before.y);
const resize=node('brief').getByRole('button',{name:/调整/});await drag(resize,60,40);
assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320);
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(c=>c.id==='brief').width,undefined);
await page.getByRole('button',{name:'重做',exact:true}).click();assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320);
// Each SVG endpoint must agree with the actual transformed node edge, after resize and pan.
async function wires(){const mismatch=await page.evaluate(()=>{
return [...document.querySelectorAll('svg path')].filter(p=>p.getAttribute('d')?.startsWith('M ')).slice(0,1).some(p=>{const d=p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),world=p.closest('div').getBoundingClientRect();const matrix=p.getScreenCTM();const pt=new DOMPoint(d[0],d[1]).matrixTransform(matrix);const card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(pt.x-card.right)>3||Math.abs(pt.y-(card.top+card.height/2))>3});
});assert.equal(mismatch,false)}
// The first link starts at brief; check it specifically.
await wires();
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 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 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);
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 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();
const viewport=page.locator('[class*="viewport"]');const box=await viewport.boundingBox();await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+100,box.y+80,{steps:8});await page.mouse.up();
await wires();
const saved=await snapshot(),view=await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view'));
await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.deepEqual(await snapshot(),saved);assert.equal(await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view')),view);
// Zoom must preserve the world coordinate beneath the pointer.
const vp=await viewport.boundingBox();assert.equal(vp.x,0);assert.equal(vp.y,0);assert.equal(vp.width,1600);assert.equal(vp.height,1100);
const anchor={x:vp.x+vp.width*.65,y:vp.y+vp.height*.25};
const worldAt=()=>page.locator('[class*=world]').evaluate((el,p)=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(p.x-m.e)/m.a,y:(p.y-m.f)/m.d}},anchor);
const point=await worldAt();await page.mouse.move(anchor.x,anchor.y);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
await page.waitForFunction(old=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1.view')).zoom!==JSON.parse(old).zoom,view);
const pointAfter=await worldAt();close(point.x,pointAfter.x);close(point.y,pointAfter.y);
await page.getByRole('button',{name:'选择工具',exact:true}).click();
await page.getByRole('button',{name:'添加笔记',exact:true}).click();
assert.equal((await snapshot()).cards.length,saved.cards.length+1);
await page.getByRole('button',{name:/操作$/}).last().click();
await page.getByRole('button',{name:'复制节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2);
await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1);
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2);
await page.getByRole('button',{name:'重做',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1);
if(await page.getByRole('button',{name:'关闭画布操作',exact:true}).isVisible())await page.getByRole('button',{name:'关闭画布操作',exact:true}).click();
// Return to the saved three-node document through undo.
await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).click();
await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();
const dock=await page.getByRole('navigation',{name:'画布工具'}).boundingBox();assert.ok(dock.y>950);assert.ok(dock.x>500);
await page.getByRole('button',{name:'切换明暗主题'}).click();assert.equal(await page.locator('main').getAttribute('data-theme'),'light');await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light');
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-light.png'});
await page.getByRole('button',{name:'切换明暗主题'}).click();
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-rebuild.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));const mobile=await viewport.boundingBox();assert.equal(mobile.width,390);assert.equal(mobile.height,844);await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png'});}
assert.deepEqual(errors,[]);console.log('Passed: two-node drag at two scales, inline input, resize, undo/redo, wires, pan, reload, pointer zoom, CRUD and full-screen desktop/mobile');
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');
}finally{await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
+4 -4
View File
@@ -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('&lt;'));}
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('&lt;'));}
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>;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+72
View File
@@ -0,0 +1,72 @@
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 result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id);
return parseCanvasDocument(result);
}
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']}};
}
+17 -1
View File
@@ -4,6 +4,22 @@ 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;
}
/** Export a self-contained SVG without storing image bytes in template JSON. */
export async function embedSvgMedia(req:NextApiRequest,svg:string){
const paths=[...new Set(svg.match(/\/api\/media\/[0-9a-f-]{36}/g)||[])];
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((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();
try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('模板图片总计超限');chunks.push(value);}}finally{await reader.cancel();}
svg=svg.replaceAll(path,'data:'+mime+';base64,'+Buffer.concat(chunks).toString('base64'));
}
return svg;
}
+3 -2
View File
@@ -1,3 +1,4 @@
import {embedSvgMedia} from '../../../lib/layout-images';
import {renderToStaticMarkup} from 'react-dom/server';
import {serveApiDocumentation} from '../../../lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
@@ -5,7 +6,7 @@ import {ideas,ideaMarkdown} from '../../../content/ideas';
import {selections} from '../../../components/SourceCover';
import {renderCover} from '../../../lib/render-cover';
import {tutorialPost} from '../../../content/tool-links';
export default function handler(req:NextApiRequest,res:NextApiResponse){
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;
if(!['GET','POST'].includes(req.method||'')){res.setHeader('Allow','GET, POST');return res.status(405).end();}
const slug=String(req.query.slug||''),idea=ideas.find(i=>i.slug===slug);if(!idea)return res.status(404).end();
@@ -17,7 +18,7 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){
const result=renderCover(slug,input);res.setHeader('Cache-Control','no-store');
res.setHeader('Content-Disposition',`attachment; filename="${slug}.${req.query.format==='json'?'json':'svg'}"`);
if(req.query.format==='json')return res.json(result.data);
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(result.node));
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(result.node)));
}catch(error){return res.status(400).json({error:error instanceof Error?error.message:'无效参数'});}
}
if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send(ideaMarkdown(idea)+'\n\n使用教程:'+tutorialPost+(idea.tool?'\n\n```json\n'+JSON.stringify(selections,null,2)+'\n```':''));}
+1 -1
View File
@@ -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'));}
+2 -1
View File
@@ -1,3 +1,4 @@
import {embedSvgMedia} from '../../../lib/layout-images';
import type {NextApiRequest,NextApiResponse} from 'next';
import {embedLayoutImages} from '../../../lib/layout-images';
import {createElement} from 'react';
@@ -25,6 +26,6 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
}
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}const layout=layouts.find(l=>l.id===req.query.id);if(!layout)return res.status(404).json({error:'排版不存在'});
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)})))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)}))))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
}
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
+11 -8
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[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=''}}/>
+31 -15
View File
@@ -2,41 +2,57 @@ 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,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;width?:number;height?:number};
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.width===undefined||(Number.isFinite(c.width)&&c.width>=260&&c.width<=1200))&&(c.height===undefined||(Number.isFinite(c.height)&&c.height>=300&&c.height<=1200))&&(!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(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[theme,setTheme]=useState<'light'|'dark'>('dark');
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 恢复')}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(()=>{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);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(()=>{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:((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',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...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+(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} data-theme={theme}><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}/><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>
<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} 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} 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);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 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}}>
<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=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} data-node-id={c.id} style={{left:c.x,top:c.y,width:c.width||320,height:c.height||350}} onClick={()=>select(c.id)}><div className={s.cardHead} title="拖动手柄 · 移动工具" aria-label={`移动 ${c.title}`} onPointerDown={e=>{if(tool==='hand'||e.button!==0)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={()=>{if(tool==='hand')return;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><input aria-label={`${c.title} 名称`} value={c.title} maxLength={200} onFocus={beginEdit} onChange={e=>change('title',e.target.value,c.id)}/><textarea aria-label={`${c.title} 内容`} value={c.text} maxLength={10000} placeholder="在工具内直接编辑…" onFocus={beginEdit} onChange={e=>change('text',e.target.value,c.id)}/></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<button aria-label={`${c.title} 操作`} onClick={e=>{e.stopPropagation();select(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}}>•••</button></footer><span className={s.inputPort}/><span className={s.outputPort}/><button className={s.resize} aria-label={`调整 ${c.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();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}}}>↘</button></article>)}
{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}>{active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{active?<><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('');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={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[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('prompt');setMenu(null)}}>添加提示词</button></>}</div>}</div>
{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}
File diff suppressed because one or more lines are too long
@@ -7,3 +7,7 @@
/* Dock and contextual actions leave both sides of the canvas clear. */
.rail{left:50%;top:auto;bottom:18px;transform:translateX(-50%);width:auto;height:58px;flex-direction:row;padding:7px 10px;gap:8px;border-radius:19px}.rail hr{width:1px;height:27px;border:0;background:#465059}.rail button{width:40px;height:40px;border-radius:11px;transition:transform .16s,background .16s}.rail button:hover{transform:translateY(-5px) scale(1.08)}.canvasBottom{top:16px;bottom:auto;left:auto;right:16px;flex-direction:column;align-items:flex-end}.canvasBottom>span{display:none}.contextMenu{position:absolute;z-index:10;width:240px;max-height:min(420px,calc(100dvh - 24px));overflow:auto;padding:8px;background:#252b30f5;border:1px solid #47525b;border-radius:14px;box-shadow:0 16px 50px #0005;backdrop-filter:blur(18px)}.contextMenu>button{display:flex;width:100%;justify-content:flex-start;border:0;background:transparent}.menuTitle{display:flex;align-items:center;justify-content:space-between;padding:3px 6px 8px;font-size:11px;color:#aebac4}.menuTitle button{padding:3px 8px}.contextMenu label{display:block;padding:8px;font-size:11px}.contextMenu select{display:block;width:100%;padding:8px;margin-top:6px;background:#171c20;color:inherit;border:1px solid #46515a;border-radius:6px}.card footer{display:flex;align-items:center;justify-content:space-between;padding:7px 12px}.card footer button{padding:2px 8px;border:0;background:transparent}.app[data-theme=light]{color-scheme:light;color:#25323b;background:#f5f6f8}.app[data-theme=dark]{color-scheme:dark}.app[data-theme=light] .viewport{background-color:#f5f6f8;background-image:radial-gradient(circle,#b8c3cc 1px,transparent 1px)}.app[data-theme=light] .header,.app[data-theme=light] .rail,.app[data-theme=light] .controls,.app[data-theme=light] .contextMenu{background:#ffffffed;border-color:#d3dce3;box-shadow:0 8px 28px #3c52601a}.app[data-theme=light] button{background:#edf1f4;border-color:#d5dfe5;color:#344650}.app[data-theme=light] button:hover{background:#dde6eb}.app[data-theme=light] .card{background:#fff;border-color:#b8c8d2;box-shadow:0 10px 30px #31465918}.app[data-theme=light] .cardHead{background:#eef3f6;color:#405663}.app[data-theme=light] .cardBody input,.app[data-theme=light] .cardBody textarea,.app[data-theme=light] .contextMenu select{background:#f7f9fa;color:#243641;border-color:#d6e0e6}.app[data-theme=light] .nodeType,.app[data-theme=light] .disconnected,.app[data-theme=light] .menuTitle{color:#567047}.app[data-theme=light] .selected{border-color:#60863b}.app[data-theme=light] .card footer{border-color:#e1e7eb;color:#526878}.app[data-theme=light] .wires path{stroke:#628e3e;opacity:.9}.app[data-theme=light] .brand span{color:#60863b}.app[data-theme=light] .rail button[aria-pressed=true]{background:#d8e9c4;color:#34501d}@media(max-width:700px){.rail{left:50%;bottom:12px;gap:4px;height:54px;padding:5px 8px}.rail button{width:36px;height:36px}.canvasBottom{top:70px;right:10px;bottom:auto}.controls{gap:0}.controls button{padding:5px}}
.rail button{background:transparent;color:#bfcbd3}.rail button[aria-pressed=true]{background:#d9ef9b;color:#24301d}.app[data-theme=light] .rail button{background:transparent;color:#405663}.app[data-theme=light] .rail button[aria-pressed=true]{background:#d8e9c4;color:#34501d}
/* Preview cards drag from any content area; editing is an explicit mode. */
.preview{cursor:grab;user-select:none;touch-action:none}.preview:active{cursor:grabbing}.preview input,.preview textarea{pointer-events:none;user-select:none}.preview .image{cursor:grab}.preview img{-webkit-user-drag:none;pointer-events:none}.editing{cursor:default}.editing .cardBody{user-select:text}.editing .image{cursor:pointer}.editing{outline:2px solid #93b569;outline-offset:3px}.card footer{gap:6px}.card footer>button:first-of-type{margin-left:auto}
.startPanel{position:absolute;z-index:3;left:50%;top:46%;transform:translate(-50%,-50%);width:min(700px,calc(100% - 40px));text-align:center;pointer-events:none}.startPanel>span{font-size:9px;letter-spacing:3px;color:#8d9884}.startPanel h2{font-size:30px;font-weight:400;letter-spacing:-1px;margin:18px 0}.startPanel p{font-size:12px;opacity:.5;margin-bottom:30px}.startPanel>div{display:flex;justify-content:center;gap:12px}.startPanel button,.startPanel a{pointer-events:auto}.startPanel>div button{padding:18px 24px;border-radius:12px;gap:15px}.startPanel>button{margin-top:25px;border:0;background:transparent;font-size:11px;opacity:.65}.startPanel>a{display:block;font-size:10px;margin-top:25px;opacity:.5;text-decoration:none}.app[data-theme=light] .startPanel>button{background:transparent}@media(max-width:700px){.startPanel h2{font-size:23px}.startPanel>div{flex-wrap:wrap;gap:8px}.startPanel>div button{padding:12px;font-size:11px}}
@@ -0,0 +1,41 @@
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);
});
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'));
});
+27
View File
@@ -0,0 +1,27 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {embedSvgMedia} from '../src/lib/layout-images.ts';
const id='a05dc841-240a-5a3a-b01b-d589fc21cd36';
test('SVG export fetches each internal media once with the caller authentication',async()=>{
const original=globalThis.fetch;let calls=0;
try{
globalThis.fetch=async(url,options)=>{calls++;assert.equal(url,'http://waterfall-wiki:3000/api/media/'+id);assert.equal(options.headers.cookie,'session=fixture');assert.equal(options.redirect,'error');return new Response(Buffer.from('fixture'),{headers:{'content-type':'image/png'}});};
const svg=await embedSvgMedia({headers:{cookie:'session=fixture'}},`<svg><image href="/api/media/${id}"/><image href="/api/media/${id}"/></svg>`);
assert.equal(calls,1);assert.equal((svg.match(/data:image\/png;base64,Zml4dHVyZQ==/g)||[]).length,2);assert.ok(!svg.includes('/api/media/'));
}finally{globalThis.fetch=original;}
});
test('SVG export rejects unavailable or non-image objects',async()=>{
const original=globalThis.fetch;
try{for(const response of [new Response('missing',{status:404}),new Response('<svg/>',{headers:{'content-type':'image/svg+xml'}})]){
globalThis.fetch=async()=>response;await assert.rejects(()=>embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`),/模板图片不可访问/);
}}finally{globalThis.fetch=original;}
});
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;}
});
+1 -1
View File
@@ -1,7 +1,7 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
import {parameters} from '../src/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^data:image\/png;base64,/);if(s.kind==='image')assert.match(s.shape,/^data:image\/png;base64,/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
+1
View File
@@ -32,6 +32,7 @@ COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
@@ -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;
+9
View File
@@ -1,8 +1,17 @@
import type { NextConfig } from "next";
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"},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

+4 -2
View File
@@ -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);
@@ -1,17 +1,19 @@
import {readFile,readdir} from 'node:fs/promises';
import {readFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {join,dirname} from 'node:path';
import {createHash} from 'node:crypto';
import {requiredPackImages} from './verify-character-pack.mjs';
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
const media=JSON.parse(await readFile(join(root,'..','..','src','content','managed-media.json'),'utf8'));
const packFiles=pack=>Object.entries(media).filter(([name])=>name.startsWith(`apps/waterfall-wiki/seed/characters/${pack.directory}/`)).sort(([a],[b])=>a.localeCompare(b));
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
async function verify(pack){
let cursor='',post;const seen=new Set();
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
if(!post)throw Error('Character pack missing: '+pack.slug);
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
const hashes=await Promise.all(files.map(async file=>createHash('sha256').update(await readFile(join(root,pack.directory,file))).digest('hex')));
const files=packFiles(pack);if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
const hashes=files.map(([,asset])=>asset.sha256);
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
@@ -20,10 +22,14 @@ for(const pack of packs){
const existing=await fetch(`${base}/p/${pack.slug}`);
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();
const files=packFiles(pack);
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
for(const file of files)data.append('files',new Blob([await readFile(join(root,pack.directory,file))],{type:'image/png'}),file);
for(const [name,asset] of files){
const response=await fetch(`${base}/api/media/${asset.id}`);if(!response.ok)throw Error('Cannot read migrated original: '+name);
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length!==asset.bytes||createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw Error('Original checksum mismatch: '+name);
data.append('files',new Blob([bytes],{type:'image/png'}),name.split('/').pop());
}
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
+13 -2
View File
@@ -1,3 +1,5 @@
import {writeFile,unlink} from 'node:fs/promises';
import sharp from 'sharp';
import { spawn } from "node:child_process";
import assert from "node:assert/strict";
import { setTimeout } from "node:timers/promises";
@@ -15,6 +17,8 @@ async function availablePort() {
const port = process.env.SMOKE_PORT || await availablePort();
const release = "smoke-test";
const previewFixture='.next/standalone/public/__smoke-preview.png';
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
const child = spawn(process.execPath, [".next/standalone/server.js"], {
// This loopback-only child checks retained resources without production credentials.
// Production keeps failing closed; the bypass requires both explicit test settings.
@@ -42,10 +46,17 @@ try {
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
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('/skills/shadow-puppet/egret-example.png')}&width=480`);
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) {
const exited = once(child, "exit");
child.kill("SIGTERM");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

File diff suppressed because it is too large Load Diff
@@ -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,72 @@
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 result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id);
return parseCanvasDocument(result);
}
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}
}
+6 -1
View File
@@ -1,4 +1,6 @@
import sharp from 'sharp';
import managedMedia from '../content/managed-media.json';
import {getObject} from './storage';
import {createHash} from 'node:crypto';
import {mkdir,readFile,writeFile,rename,stat,readdir,unlink,realpath} from 'node:fs/promises';
import {resolve,sep,join} from 'node:path';
@@ -11,4 +13,7 @@ async function limited<T>(work:()=>Promise<T>){if(active>=3){if(waiting.length>=
const pending=new Map<string,Promise<Buffer>>();const cache=join(tmpdir(),'prompt-previews-v1');let writes=0;
export async function compressPreview(input:Buffer,width:number){const max=width<=480?100_000:220_000;let size=width,quality=72;let output:Buffer;do{output=await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width:size,withoutEnlargement:true}).webp({quality,effort:3}).toBuffer();if(output.length<=max)return output;size=Math.max(240,Math.floor(size*.8));quality=Math.max(30,quality-10);}while(size>240||quality>30);return sharp(input,{limitInputPixels:40_000_000}).rotate().resize({width:240,withoutEnlargement:true}).webp({quality:30}).toBuffer();}
export async function cachedPreview(identity:string,width:number,load:()=>Promise<Buffer>,quality=false){const key=createHash('sha256').update(identity+':'+width+(quality?':export-v1':':webp-v1')).digest('hex');const file=join(cache,key+'.webp');try{return {bytes:await readFile(file),etag:'"'+key+'"'};}catch{/* cache miss */}let task=pending.get(key);if(!task){task=limited(async()=>{await mkdir(cache,{recursive:true});const input=await load();const bytes=quality?await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width,withoutEnlargement:true}).webp({quality:95,effort:4}).toBuffer():await compressPreview(input,width);const temp=file+'.'+process.pid+'.tmp';await writeFile(temp,bytes);await rename(temp,file);if(++writes%32===0){const files=await readdir(cache);if(files.length>1024){const dates=await Promise.all(files.map(async name=>({name,time:(await stat(join(cache,name)).catch(()=>({mtimeMs:0}))).mtimeMs})));await Promise.all(dates.sort((a,b)=>a.time-b.time).slice(0,files.length-1024).map(f=>unlink(join(cache,f.name)).catch(()=>{})));}}return bytes;});pending.set(key,task);}try{return {bytes:await task,etag:'"'+key+'"'};}finally{pending.delete(key);}}
export async function loadStaticPreview(source:string){const root=await realpath(join(process.cwd(),'public'));const file=await realpath(staticPreviewPath(source,root));if(!file.startsWith(root+sep))throw Error('Invalid source');const info=await stat(file);if(info.size>64*1024*1024)throw Error('Image too large');return {identity:file+':'+info.mtimeMs,load:()=>readFile(file)};}
export async function loadStaticPreview(source:string){
const asset=(managedMedia as Record<string,{key:string;sha256:string;bytes:number}>)["apps/waterfall-wiki/public"+source];
if(asset){return {identity:asset.key,load:async()=>{const object=await getObject(asset.key);if(asset.bytes>64*1024*1024)throw Error("Image too large");const bytes=Buffer.from(await object.Body!.transformToByteArray());if(bytes.length!==asset.bytes||createHash("sha256").update(bytes).digest("hex")!==asset.sha256)throw Error("Stored image checksum mismatch");return bytes;}};}
const root=await realpath(join(process.cwd(),'public'));const file=await realpath(staticPreviewPath(source,root));if(!file.startsWith(root+sep))throw Error('Invalid source');const info=await stat(file);if(info.size>64*1024*1024)throw Error('Image too large');return {identity:file+':'+info.mtimeMs,load:()=>readFile(file)};}
@@ -1,10 +1,10 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import managedMedia from "../content/managed-media.json";
import {guidanceDocuments} from "../content/skills/guidance";
import {wikiPages} from "../content/wiki";
import { chapters } from "../content/learning-skills/data";
import { transaction } from "./db";
import { putFile } from "./storage";
import { getObject } from "./storage";
function stableId(name: string) {
const bytes=createHash("sha256").update(`prompt:learning-series:${name}`).digest().subarray(0,16);
@@ -13,12 +13,13 @@ function stableId(name: string) {
}
// Explicit editorial publication, never part of the generic storage bootstrap.
// Re-running updates these three authored posts without duplicating posts or media.
export async function publishLearningSeries() {
const path="public/learning-skills/cover.png";
const cover=await readFile(path);const digest=createHash("sha256").update(cover).digest("hex");
const storageKey=`editorial/learning-skills/${digest}.png`;
const coverId=stableId("cover");
await putFile(storageKey,path,"image/png");
export async function publishLearningSeries(asset=managedMedia["apps/waterfall-wiki/public/learning-skills/cover.png"]) {
const object=await getObject(asset.key);
const cover=Buffer.from(await object.Body!.transformToByteArray());
const digest=createHash("sha256").update(cover).digest("hex");
if(digest!==asset.sha256||cover.length!==asset.bytes)throw Error("Learning cover checksum mismatch");
const storageKey=asset.key;
const coverId=asset.id;
return transaction(async client=>{
await client.query("SELECT pg_advisory_xact_lock(18131002)");
await client.query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height)
+4 -3
View File
@@ -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:'在线画布暂时不可用'});
}
}
+10 -4
View File
@@ -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>}
+2 -2
View File
@@ -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}); }
File diff suppressed because one or more lines are too long
@@ -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',
+8 -3
View File
@@ -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,325 @@
/** 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[] = [];
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));
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 session = page.waitForResponse(response => new URL(response.url()).pathname === "/api/account/session" && response.status() === 200);
try {
const [navigation] = await Promise.all([reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}),session]);
assert.ok(navigation?.ok(), "Navigation returns a successful document");
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
} 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(); }
});
+27 -8
View File
@@ -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';
@@ -7,7 +9,9 @@ import {setTimeout} from 'node:timers/promises';
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {randomUUID} from 'node:crypto';
import {randomUUID,createHash} from 'node:crypto';
import {putFile} from '../src/lib/storage';
import managedMedia from '../src/content/managed-media.json';
import sharp from 'sharp';
import {db} from '../src/lib/db';
import {probe,run} from '../src/lib/probe';
@@ -18,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;
@@ -30,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');
@@ -134,7 +138,14 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
for(const body of [{layoutId:'ref-28',name:'bad',parameters:{}},{layoutId:'ref-01',name:'bad',parameters:{texts:{I01:{value:'wrong kind'}}}},{layoutId:'ref-01',name:'bad',parameters:{images:{I01:{assetId:randomUUID()}}}}])assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).status,400);
assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID())).status,404);
assert.ok((await fetch(base+'/api/studio/designs?agent_mode=True').then(r=>r.text())).includes('revision'));
const published=await publishLearningSeries();await publishLearningSeries();
// Isolated S3 uses synthetic fixtures, never credentials or production objects.
const testCoverFile=join(dir,'learning-cover.png');
const testCoverBytes=await sharp({create:{width:1080,height:1440,channels:3,background:'#e8eadc'}}).png().toBuffer();
await writeFile(testCoverFile,testCoverBytes);
const testCover={...managedMedia['apps/waterfall-wiki/public/learning-skills/cover.png'],id:randomUUID(),key:'integration/learning-cover.png',sha256:createHash('sha256').update(testCoverBytes).digest('hex'),bytes:testCoverBytes.length};
await putFile(testCover.key,testCoverFile,'image/png');
const publishTestSeries=()=>publishLearningSeries(testCover);
const published=await publishTestSeries();await publishTestSeries();
const articles=(await request('/api/feed?section=articles&path=learning-skills')).posts;
assert.equal(articles.length,3,'Repeated publication must not duplicate posts');
assert.equal(new Set(articles.map((p:{cover_asset_id:string})=>p.cover_asset_id)).size,1,'Three posts share one independent image asset');
@@ -173,7 +184,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
assert.equal((await request('/api/notes/'+captionReuse.id)).entry.editableMarkdown,'可以复用的独立文字','Editing one note must not mutate shared text');
const authored=(await request('/api/feed?section=articles')).posts.find((p:{slug:string})=>p.slug===published.published[0]);const authoredState=await request('/api/notes/'+authored.id);
assert.equal((await edit(authored.id,{title:'保留用户修改',description:'部署不能覆盖',markdown:'手动修改的正文',tags:[newTag.id],revision:authoredState.entry.revision})).status,200);
await publishLearningSeries();const retained=await request('/api/notes/'+authored.id);assert.equal(retained.entry.title,'保留用户修改');assert.equal(retained.entry.editableMarkdown,'手动修改的正文');
await publishTestSeries();const retained=await request('/api/notes/'+authored.id);assert.equal(retained.entry.title,'保留用户修改');assert.equal(retained.entry.editableMarkdown,'手动修改的正文');
assert.equal((await request('/api/notes/'+note.id)).entry.title,payload.title,'Static edits outlive content publication');
assert.equal((await fetch(base+'/api/notes/missing')).status,404);
assert.equal((await fetch(base+'/api/notes/'+note.id,{method:'DELETE'})).status,405);
@@ -208,6 +219,11 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
const head=await fetch(base+path,{method:'HEAD'});assert.equal(head.status,200);assert.equal(await head.text(),'');
if(doc!=='SKILL')assert.equal((await fetch(base+'/tools/skills/shadow-puppet/'+doc)).status,200);
}
for(const image of ['egret-example','dancer-example']){
const asset=managedMedia[`apps/waterfall-wiki/public/skills/shadow-puppet/${image}.png` as keyof typeof managedMedia];
const key='integration/legacy-'+asset.id+'.png';await putFile(key,testCoverFile,'image/png');
await db().query("INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256) VALUES($1,'image',$2,$3,'image/png',$4,$5)",[asset.id,image+'.png',key,testCoverBytes.length,testCover.sha256]);
}
for(const image of ['egret-example','dancer-example'])assert.equal((await fetch(base+`/skills/shadow-puppet/${image}.png`,{method:'HEAD'})).status,200);
for(const path of ['/skills/missing/SKILL.md','/skills/shadow-puppet/missing.md','/tools/skills/shadow-puppet/missing'])assert.equal((await fetch(base+path)).status,404,path);
assert.equal((await fetch(base+'/api/skill-docs/shadow-puppet/SKILL',{method:'POST'})).status,405);
@@ -216,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');
+20
View File
@@ -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"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Some files were not shown because too many files have changed in this diff Show More