From 174e85fb81d4bde5a8eb33448b1e497dd77cd328 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 21:31:13 +0800 Subject: [PATCH 01/18] build(frontend): add React Flow dependency The workflow editor needs a maintained graph renderer for its controlled canvas. Add @xyflow/react and record its exact dependency closure in the lockfile. The frontend can build the editor without loading graph code on unrelated routes. --- frontend/package-lock.json | 246 ++++++++++++++++++++++++++++++++++++- frontend/package.json | 1 + 2 files changed, 244 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d513d30c..7be3279f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "windup-frontend", "version": "0.0.0", "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.3.0" @@ -2019,6 +2020,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2047,7 +2097,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2057,7 +2107,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2202,6 +2252,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2268,6 +2360,12 @@ "node": ">=18" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2299,9 +2397,114 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -3376,6 +3579,15 @@ "dev": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -3625,6 +3837,34 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 0a845f78..330d0fbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc -b" }, "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.3.0" From 34ae8c3e805c252c26b05e1f3285ac45e3851f12 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 21:31:24 +0800 Subject: [PATCH 02/18] fix(character): align workflow run ownership The backend binds every Character to the WorkflowRun that produced it. Map and serialize workflow_run_id, then update typed fixtures that consume Character. Editors can select the correct character without guessing from project order. --- frontend/src/entities/character/index.test.ts | 4 ++++ frontend/src/entities/character/index.ts | 6 ++++++ .../src/pages/playtest/workbench/minimal-workbench.test.tsx | 1 + frontend/src/pages/playtest/workbench/model.test.ts | 1 + frontend/src/test/project-assets-backend.ts | 2 ++ 5 files changed, 14 insertions(+) diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index af0a21de..1b731b11 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const characterDto = { id: 51, project_id: 42, + workflow_run_id: 77, name: '轻装信使', description: null, reference_image_url: 'https://cdn.windup.test/reference.png', @@ -86,6 +87,7 @@ describe('characterApis', () => { { id: '51', projectId: '42', + workflowRunId: '77', name: '轻装信使', description: null, referenceImageUrl: 'https://cdn.windup.test/reference.png', @@ -140,6 +142,7 @@ describe('characterApis', () => { await characterApis.create({ projectId: '42', + workflowRunId: '77', name: '轻装信使', description: '项目角色', referenceImageUrl: null, @@ -148,6 +151,7 @@ describe('characterApis', () => { expect(request?.method).toBe('POST') await expect(request?.json()).resolves.toEqual({ project_id: 42, + workflow_run_id: 77, name: '轻装信使', description: '项目角色', reference_image_url: null, diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 43c2cf6c..6d101e71 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -38,6 +38,8 @@ export interface Outfit { export interface Character { id: string projectId: string + /** 角色由哪一条制作流程产出;Workflow Editor 以它落实单画布单角色。 */ + workflowRunId: string name: string | null description: string | null referenceImageUrl: string | null @@ -50,6 +52,7 @@ export interface Character { /** 创建 Character 记录的字段;生成流程由 Workflow Editor 负责。 */ export interface CreateCharacterInput { projectId: string + workflowRunId: string name?: string | null description?: string | null referenceImageUrl?: string | null @@ -99,6 +102,7 @@ interface CharacterDataDto { interface CharacterDto { id: number project_id: number + workflow_run_id: number name: string | null description: string | null reference_image_url: string | null @@ -149,6 +153,7 @@ function mapCharacter(dto: CharacterDto): Character { return { id: characterId, projectId: String(dto.project_id), + workflowRunId: String(dto.workflow_run_id), name: dto.name, description: dto.description, referenceImageUrl: dto.reference_image_url, @@ -215,6 +220,7 @@ export const characterApis: CharacterApis = { method: 'POST', json: { project_id: toBackendId(input.projectId, 'projectId'), + workflow_run_id: toBackendId(input.workflowRunId, 'workflowRunId'), name: input.name, description: input.description, reference_image_url: input.referenceImageUrl, diff --git a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx index e2b81e3f..0d0806eb 100644 --- a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx +++ b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx @@ -16,6 +16,7 @@ function frame(index: number, imageUrl: string) { const character: Character = { id: '51', projectId: '42', + workflowRunId: 'workflow-run-51', name: '轻装信使', description: null, referenceImageUrl: null, diff --git a/frontend/src/pages/playtest/workbench/model.test.ts b/frontend/src/pages/playtest/workbench/model.test.ts index 377a7a5c..b762dd33 100644 --- a/frontend/src/pages/playtest/workbench/model.test.ts +++ b/frontend/src/pages/playtest/workbench/model.test.ts @@ -7,6 +7,7 @@ import { createPlaytestModel } from './model' const character: Character = { id: '51', projectId: '42', + workflowRunId: 'workflow-run-51', name: '轻装信使', description: null, referenceImageUrl: null, diff --git a/frontend/src/test/project-assets-backend.ts b/frontend/src/test/project-assets-backend.ts index f6cdf526..0d65afad 100644 --- a/frontend/src/test/project-assets-backend.ts +++ b/frontend/src/test/project-assets-backend.ts @@ -49,6 +49,7 @@ const characterDtos = [ { id: 51, project_id: 42, + workflow_run_id: 501, name: '轻装信使', description: '负责远途投递的年轻信使', reference_image_url: 'https://cdn.windup.test/messenger-reference.png', @@ -115,6 +116,7 @@ const characterDtos = [ { id: 52, project_id: 42, + workflow_run_id: 502, name: '待定角色', description: null, reference_image_url: null, From e0674594c350f2bf95433ebac09fa1d59e852a2a Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 21:31:38 +0800 Subject: [PATCH 03/18] docs(character): document workflow run ownership The existing contract text omitted the backend ownership field and described a missing run property. Document workflow_run_id and the action outfit lookup through the bound Character. Future adapters have an accurate source of truth for character and outfit identity. --- frontend/API_CONTRACT.md | 3 ++- frontend/src/features/generation/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 9aa32e8a..65774e82 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -47,12 +47,13 @@ Character 前端只映射后端真实字段: -- Character:`id`、`project_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status` +- Character:`id`、`project_id`、`workflow_run_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status` - Outfit:`id`、`name`、`description`、`preview_url`、`actions` - Action:`id`、`type`、`name`、`loop`、`fps`、`frame_count`、`frames` - Frame:`index`、`image_url`、`duration_ms` Outfit、Action、Frame 没有独立端点。`outfit.characterId` 与 `action.outfitId` 仅由嵌套关系推导;修改任一子项时通过 `PATCH Character` 提交完整 `character_data`。 +创建 Character 时必须传入 `workflow_run_id`;编辑器也只读取与当前 WorkflowRun 绑定的角色,不能从同项目角色中按顺序猜测。 ## 二、本轮明确不实现 diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts index 20157522..fe628fdd 100644 --- a/frontend/src/features/generation/index.ts +++ b/frontend/src/features/generation/index.ts @@ -3,8 +3,8 @@ export interface GenerationProps { runId: string /** * 目标动作,生成母版等非动作任务可以省略。 - * 动作 ID 只在造型内唯一,所以造型由 runId 对应的 WorkflowRun.outfitId 决定, - * 不能脱离 run 单独使用这个字段。 + * 动作 ID 只在造型内唯一;调用方需用 runId 找到绑定 Character,再从 + * WorkflowRun 动作节点的 input.outfitId 确定造型,不能脱离两者单独使用此字段。 */ actionId?: string } From 2f29a4ff927a843e525dd744d9b3d174000005c1 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 21:33:36 +0800 Subject: [PATCH 04/18] feat(workflow-editor): compose real workflow session The editor must recover one persisted WorkflowRun through the main repository interfaces. Compose the controller with project context, the run-bound Character, and an explicit Generation failure boundary. Production routes no longer depend on demo data or a second workflow state machine. --- .../src/pages/workflow-editor/runtime.test.ts | 212 ++++++++++++++++++ frontend/src/pages/workflow-editor/runtime.ts | 125 +++++++++++ 2 files changed, 337 insertions(+) create mode 100644 frontend/src/pages/workflow-editor/runtime.test.ts create mode 100644 frontend/src/pages/workflow-editor/runtime.ts diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts new file mode 100644 index 00000000..5505c4e4 --- /dev/null +++ b/frontend/src/pages/workflow-editor/runtime.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { Character, GenerationApis, Project, WorkflowRun, WorkflowRunApis } from '@/entities' +import { createRealWorkflowEditorSession, createUnavailableGenerationApis } from './runtime' + +describe('createRealWorkflowEditorSession', () => { + it('只用主仓库公开接口恢复 WorkflowRun 并装配 Controller', async () => { + const workflow = workflowFixture() + const project = projectFixture() + const character = characterFixture() + const workflowRunApis: WorkflowRunApis = { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(async (run) => ({ ...structuredClone(run), version: run.version + 1 })), + remove: vi.fn(), + } + const generationApis: GenerationApis = { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(), + subscribe: vi.fn(() => () => undefined), + } + const projectApis = { get: vi.fn().mockResolvedValue(project) } + const unrelatedCharacter = { ...characterFixture(), id: '10', workflowRunId: '99' } + const characterApis = { + listByProject: vi + .fn() + .mockResolvedValueOnce({ + items: [unrelatedCharacter], + total: 101, + page: 1, + pageSize: 100, + }) + .mockResolvedValueOnce({ + items: [character], + total: 101, + page: 2, + pageSize: 100, + }), + } + + const session = await createRealWorkflowEditorSession('42', { + workflowRunApis, + generationApis, + projectApis, + characterApis, + onAsyncError: vi.fn(), + }) + + expect(workflowRunApis.get).toHaveBeenCalledWith('42') + expect(projectApis.get).toHaveBeenCalledWith('1') + expect(characterApis.listByProject).toHaveBeenNthCalledWith(1, '1', { + page: 1, + pageSize: 100, + }) + expect(characterApis.listByProject).toHaveBeenNthCalledWith(2, '1', { + page: 2, + pageSize: 100, + }) + expect(session.controller.getWorkflow()).toEqual(workflow) + expect(session.project).toEqual(project) + expect(session.character).toEqual(character) + expect('mode' in session).toBe(false) + expect('playtestTarget' in session).toBe(false) + }) + + it('拒绝把同一 WorkflowRun 绑定到多个角色', async () => { + const workflow = workflowFixture() + const characterApis = { + listByProject: vi.fn().mockResolvedValue({ + items: [characterFixture(), { ...characterFixture(), id: '10' }], + total: 2, + page: 1, + pageSize: 100, + }), + } + + await expect( + createRealWorkflowEditorSession('42', { + workflowRunApis: { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(), + remove: vi.fn(), + }, + generationApis: { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(), + subscribe: vi.fn(() => () => undefined), + }, + projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, + characterApis, + onAsyncError: vi.fn(), + }), + ).rejects.toThrow('WorkflowRun 42 关联了多个角色') + }) + + it('把 Controller 异步错误同时交给装配层和页面订阅者', async () => { + const onAsyncError = vi.fn() + const workflow = workflowFixture() + const session = await createRealWorkflowEditorSession('42', { + workflowRunApis: { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(async (run) => ({ ...structuredClone(run), version: run.version + 1 })), + remove: vi.fn(), + }, + generationApis: { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(), + subscribe: vi.fn(() => () => undefined), + }, + projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, + characterApis: { + listByProject: vi.fn().mockResolvedValue({ + items: [], + total: 0, + page: 1, + pageSize: 100, + }), + }, + onAsyncError, + }) + const pageError = vi.fn() + session.subscribeErrors(pageError) + let notificationCount = 0 + session.controller.subscribe(() => { + notificationCount += 1 + if (notificationCount > 1) throw new Error('异步保存回调失败') + }) + + await session.controller.restartFromNode('setup') + + expect(onAsyncError).toHaveBeenCalledWith( + expect.objectContaining({ message: '异步保存回调失败' }), + ) + expect(pageError).toHaveBeenCalledWith(expect.objectContaining({ message: '异步保存回调失败' })) + }) +}) + +describe('createUnavailableGenerationApis', () => { + it('在 main 尚无 Generation HTTP 适配器时明确失败,不返回演示结果', async () => { + const apis = createUnavailableGenerationApis() + + await expect(apis.create(characterGenerationInput())).rejects.toThrow( + 'GenerationApis 尚未接入真实后端', + ) + await expect(apis.get('1', '9')).rejects.toThrow('GenerationApis 尚未接入真实后端') + expect(() => apis.subscribe('1', '9', vi.fn())).not.toThrow() + }) +}) + +function workflowFixture(): WorkflowRun { + return { + id: '42', + projectId: '1', + version: 3, + storageStatus: 'active', + nodes: [ + { + id: 'setup', + type: 'character-setup', + status: 'active', + phase: 'configuring', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '冒险家', referenceMedia: [] }, + }, + ], + } +} + +function projectFixture(): Project { + return { + id: '1', + ownerId: '7', + workflowId: null, + name: '正式项目', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 64, height: 64 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-10T00:00:00.000Z', + updatedAt: '2026-08-10T00:00:00.000Z', + } +} + +function characterFixture(): Character { + return { + id: '9', + projectId: '1', + workflowRunId: '42', + name: '正式角色', + description: null, + referenceImageUrl: null, + dataVersion: 1, + status: 1, + outfits: [], + } +} + +function characterGenerationInput() { + return { + type: 'character_template' as const, + projectId: '1', + prompt: '冒险家', + spriteWidth: 64, + spriteHeight: 64, + referenceMedia: [], + } +} diff --git a/frontend/src/pages/workflow-editor/runtime.ts b/frontend/src/pages/workflow-editor/runtime.ts new file mode 100644 index 00000000..c0188d20 --- /dev/null +++ b/frontend/src/pages/workflow-editor/runtime.ts @@ -0,0 +1,125 @@ +import type { + Character, + CharacterApis, + GenerationApis, + Project, + ProjectApis, + WorkflowRunApis, +} from '@/entities' +import { characterApis, projectApis, workflowRunApis } from '@/entities' +import { createWorkflowController, type WorkflowController } from '@/features/workflow-controller' + +export interface WorkflowEditorSession { + controller: WorkflowController + project: Project + /** 后端用 workflow_run_id 建立的唯一角色;尚未产出正式角色时为 null。 */ + character: Character | null + subscribeErrors(listener: (error: Error) => void): () => void + dispose(): void +} + +export interface RealWorkflowEditorDependencies { + workflowRunApis: WorkflowRunApis + generationApis: GenerationApis + projectApis: Pick + characterApis: Pick + onAsyncError(error: Error): void +} + +/** + * 页面只消费这一份正式会话:WorkflowRun 决定流程状态,Project / Character 只提供 + * 只读上下文,所有业务推进都交给同一个 WorkflowController。 + */ +export async function createRealWorkflowEditorSession( + runId: string, + dependencies: RealWorkflowEditorDependencies, +): Promise { + const workflow = await dependencies.workflowRunApis.get(runId) + const [project, character] = await Promise.all([ + dependencies.projectApis.get(workflow.projectId), + loadWorkflowCharacter(dependencies.characterApis, workflow.projectId, workflow.id), + ]) + const errorListeners = new Set<(error: Error) => void>() + const reportAsyncError = (error: Error) => { + try { + dependencies.onAsyncError(error) + } catch { + // 错误上报器不能反过来破坏已经完成的 WorkflowRun 持久化。 + } + for (const listener of errorListeners) { + try { + listener(error) + } catch { + // 页面卸载竞态或错误边界异常不应中断其他订阅者。 + } + } + } + const controller = createWorkflowController({ + workflow, + workflowRunApis: dependencies.workflowRunApis, + generationApis: dependencies.generationApis, + onAsyncError: reportAsyncError, + }) + + return { + controller, + project, + character, + subscribeErrors(listener) { + errorListeners.add(listener) + return () => errorListeners.delete(listener) + }, + dispose() { + errorListeners.clear() + controller.dispose() + }, + } +} + +/** + * main 已提供真实 WorkflowRun、Project 与 Character 适配器;Generation 只有公开接口, + * 尚无可合入的 HTTP 实现。这里明确拒绝生成,避免用演示数据污染正式 WorkflowRun。 + */ +export function createDefaultRealWorkflowEditorSession( + runId: string, +): Promise { + return createRealWorkflowEditorSession(runId, { + workflowRunApis, + generationApis: createUnavailableGenerationApis(), + projectApis, + characterApis, + onAsyncError: () => undefined, + }) +} + +export function createUnavailableGenerationApis(): GenerationApis { + const unavailable = () => + Promise.reject(new Error('GenerationApis 尚未接入真实后端,不能执行或恢复生成任务')) + + return { + create: unavailable as GenerationApis['create'], + get: unavailable, + subscribe: () => () => undefined, + } +} + +async function loadWorkflowCharacter( + apis: Pick, + projectId: Project['id'], + workflowRunId: string, +): Promise { + const pageSize = 100 + const matches: Character[] = [] + + for (let page = 1; ; page += 1) { + const result = await apis.listByProject(projectId, { page, pageSize }) + matches.push(...result.items.filter((character) => character.workflowRunId === workflowRunId)) + if (matches.length > 1) { + throw new Error(`WorkflowRun ${workflowRunId} 关联了多个角色,无法进入单角色画布`) + } + const totalPages = Math.ceil(result.total / result.pageSize) + if (page >= totalPages || result.items.length === 0) break + } + + return matches[0] ?? null +} From 6636aa1b9ad3f4957ca62266f50e080bdc221b9b Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 21:33:51 +0800 Subject: [PATCH 05/18] feat(workflow-editor): render controlled workflow canvas The production editor needs to project persisted workflow state without owning business transitions. Render six system node types and parallel action branches through one controlled React Flow canvas. Commands, recovery paths, and transient UI state remain bounded by WorkflowController and the active run. --- .../src/pages/workflow-editor/index.test.tsx | 733 +++++++++++- frontend/src/pages/workflow-editor/index.tsx | 1037 ++++++++++++++++- .../pages/workflow-editor/workflow-editor.css | 467 ++++++++ 3 files changed, 2153 insertions(+), 84 deletions(-) create mode 100644 frontend/src/pages/workflow-editor/workflow-editor.css diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx index bb752c0d..827b3832 100644 --- a/frontend/src/pages/workflow-editor/index.test.tsx +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -1,39 +1,732 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MemoryRouter, Route, Routes } from 'react-router' +import type { ReactNode } from 'react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Link, MemoryRouter, Route, Routes } from 'react-router' +import type { + Character, + Generation, + GenerationApis, + Project, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' +import { createWorkflowController, type WorkflowController } from '@/features/workflow-controller' import { WorkflowEditorPage } from './index' +import type { WorkflowEditorSession } from './runtime' +const { defaultSessionLoader, flowProps } = vi.hoisted(() => ({ + defaultSessionLoader: vi.fn(), + flowProps: { current: null as Record | null }, +})) + +vi.mock('./runtime', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createDefaultRealWorkflowEditorSession: defaultSessionLoader, + } +}) + +vi.mock('@xyflow/react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Controls: () => null, + Handle: () => null, + ReactFlow: ({ nodes, children, ...props }: TestFlowProps) => { + flowProps.current = { nodes, ...props } + return ( +
+ {nodes.map((node) => ( +
+ {node.data.content} +
+ ))} + {children} +
+ ) + }, + useReactFlow: () => ({ fitView: vi.fn() }), + } +}) + +interface TestCanvasNode { + id: string + deletable?: boolean + draggable?: boolean + dragHandle?: string + data: { title: string; content: ReactNode } +} + +interface TestFlowProps extends Record { + nodes: TestCanvasNode[] + children?: ReactNode +} + +beforeEach(() => { + defaultSessionLoader.mockReset() + flowProps.current = null + window.history.replaceState({}, '', '/') +}) afterEach(cleanup) -describe('WorkflowEditorPage', () => { - it('按六节点顺序展示资产生成方式选择', () => { - const onSelect = vi.fn() +describe('WorkflowEditorPage real runtime boundary', () => { + it('默认路由只恢复真实 WorkflowRun 会话', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + window.history.replaceState({}, '', '/workflow-editor/42') + + renderEditor('/workflow-editor/42') + + expect(await screen.findByText('真实 WorkflowRun 接口')).toBeTruthy() + expect(defaultSessionLoader).toHaveBeenCalledWith('42') + }) + + it('只展示 WorkflowRun 已保存的角色提示词,不伪造未入主仓库契约的编辑字段', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + + renderEditor('/workflow-editor/42') + + expect(await screen.findByText('短发少年冒险家')).toBeTruthy() + expect(screen.queryByRole('textbox', { name: /角色名称/ })).toBeNull() + expect(screen.queryByRole('textbox', { name: /角色描述/ })).toBeNull() + expect(screen.getByRole('button', { name: '生成角色候选' })).toBeTruthy() + }) + + it('真实 Generation 尚未实现时展示接口错误,不回退到演示候选', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + expect(screen.queryByRole('button', { name: /选择角色候选/ })).toBeNull() + }) + + it('切换 WorkflowRun 时清空上一条任务的临时动作菜单', async () => { + defaultSessionLoader + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('42'))) + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('43'))) + + render( + + 下一条 WorkflowRun + + } /> + + , + ) + + fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' })) + expect(screen.getByRole('button', { name: '生成动作 ›' })).toBeTruthy() + + fireEvent.click(screen.getByRole('link', { name: '下一条 WorkflowRun' })) + await waitFor(() => + expect(screen.getByRole('img', { name: '已确认身份母版' }).getAttribute('src')).toBe( + 'https://assets.windup.test/43.png', + ), + ) + + expect(screen.queryByRole('button', { name: '生成动作 ›' })).toBeNull() + }) + + it('切换 WorkflowRun 后忽略上一条任务迟到的命令错误', async () => { + const pendingGeneration = deferred() + const createGeneration = vi.fn(() => pendingGeneration.promise) + defaultSessionLoader + .mockResolvedValueOnce( + createSession(workflowFixture(), { + generationApis: generationApisFixture({ create: createGeneration }), + }), + ) + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('43'))) + render( - + + 下一条 WorkflowRun - } - /> + } /> , ) - expect(screen.getAllByRole('listitem')).toHaveLength(6) - fireEvent.click(screen.getByRole('button', { name: /视频裁剪/ })) - fireEvent.click(screen.getByRole('button', { name: /3D 转 2D/ })) - expect(onSelect.mock.calls).toEqual([['video-cropping'], ['3d-to-2d']]) + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + await waitFor(() => expect(createGeneration).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('link', { name: '下一条 WorkflowRun' })) + await waitFor(() => + expect(screen.getByRole('img', { name: '已确认身份母版' }).getAttribute('src')).toBe( + 'https://assets.windup.test/43.png', + ), + ) + + await act(async () => { + pendingGeneration.reject(new Error('旧 WorkflowRun 的迟到错误')) + await pendingGeneration.promise.catch(() => undefined) + }) + + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('只采用同一 WorkflowRun 内最后一次 Generation 读取结果', async () => { + const oldRead = deferred() + const latestRead = deferred() + defaultSessionLoader.mockResolvedValue(createGenerationRaceSession(oldRead, latestRead)) + + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + + await act(async () => { + latestRead.resolve(characterGeneration('new')) + await latestRead.promise + }) + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ) + + await act(async () => { + oldRead.resolve(characterGeneration('old')) + await oldRead.promise + }) + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ) + }) + + it('从 WorkflowRun 绑定角色中明确选择造型,并只提交后端支持的动作类型', async () => { + const session = createSession(completedTemplateWorkflow('42'), { + character: characterFixture(), + }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' })) + fireEvent.click(screen.getByRole('button', { name: '生成动作 ›' })) + fireEvent.click(screen.getByRole('button', { name: '选择造型 夜行装' })) + + expect(screen.queryByRole('button', { name: /Jump/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Attack 攻击/ })) + + await waitFor(() => + expect(session.controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'action-first-frame', + input: expect.objectContaining({ outfitId: 'night', type: 'attack' }), + }), + ]), + ), + ) + }) + + it('失败节点可以从当前节点重做', async () => { + const session = createSession(failedTemplateWorkflow()) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + + expect(await screen.findByRole('button', { name: '生成角色候选' })).toBeTruthy() + expect(session.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'ready', + error: null, + }) + }) + + it('节点重做后不会沿用上一轮 Generation 的候选选择', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(false) + + act(() => controlled.emit(failedTemplateWorkflow())) + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + act(() => controlled.emit(selectingTemplateWorkflow(6, 'new-task'))) + await waitFor(() => + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ), + ) + + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('Generation 候选集合变化时使旧选择失效', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + act(() => controlled.emit(selectingTemplateWorkflow(4, 'new-task'))) + await waitFor(() => + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ), + ) + + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('生成接口提交失败后仍保留角色候选重试入口', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + expect(screen.getByRole('button', { name: '生成角色候选' })).toBeTruthy() + }) + + it('恢复候选读取失败时展示错误并允许重新读取', async () => { + const generationApis = generationApisFixture() + defaultSessionLoader.mockResolvedValue( + createSession(selectingTemplateWorkflow(3, 'existing-task'), { generationApis }), + ) + renderEditor('/workflow-editor/42') + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + fireEvent.click(screen.getByRole('button', { name: '重试读取生成结果' })) + + await waitFor(() => expect(generationApis.get).toHaveBeenCalledTimes(2)) + }) + + it('恢复生成中节点失败后仍可从该节点重做', async () => { + const session = createSession(generatingTemplateWorkflow()) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + + expect(await screen.findByRole('button', { name: '生成角色候选' })).toBeTruthy() + expect(session.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'ready', + error: null, + }) + }) + + it('展示 Controller 的异步错误订阅', async () => { + let reportError: ((error: Error) => void) | null = null + const session = createSession() + session.subscribeErrors = vi.fn((listener) => { + reportError = listener + return () => undefined + }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + await screen.findByText('真实 WorkflowRun 接口') + + act(() => reportError?.(new Error('异步保存失败'))) + + expect(screen.getByRole('alert').textContent).toContain('异步保存失败') + }) + + it('把 React Flow 限定为系统节点的拖动画布,不允许自由连线、重连或删除', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + + expect(latestFlowProps()).toMatchObject({ + nodesConnectable: false, + edgesReconnectable: false, + deleteKeyCode: null, + }) + expect('onConnect' in latestFlowProps()).toBe(false) + expect(latestFlowProps().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + deletable: false, + draggable: true, + dragHandle: '.workflow-card__handle', + }), + ]), + ) + expect(latestFlowProps().edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'default', + animated: false, + className: 'workflow-edge--flowing', + style: expect.objectContaining({ + stroke: 'rgba(72, 101, 80, 0.42)', + strokeWidth: 3, + strokeDasharray: '9 8', + }), + }), + ]), + ) }) - it('没有 Controller 装配时不在页面内伪造选择', () => { + it('没有 runId 时不创建替代 WorkflowRun', () => { render( - - + + + } /> + , ) - expect(screen.getByRole('button', { name: /视频裁剪/ })).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: /3D 转 2D/ })).toHaveProperty('disabled', true) + + expect(screen.getByRole('heading', { name: '工作流编辑器' })).toBeTruthy() + expect(screen.getByText('需要从已有 WorkflowRun 进入')).toBeTruthy() + expect(defaultSessionLoader).not.toHaveBeenCalled() }) }) + +function renderEditor(path: string) { + return render( + + + } /> + + , + ) +} + +interface SessionFixtureOptions { + character?: Character | null + generationApis?: GenerationApis +} + +function createSession( + initialWorkflow = workflowFixture(), + options: SessionFixtureOptions = {}, +): WorkflowEditorSession { + let workflow = structuredClone(initialWorkflow) + const workflowRunApis: WorkflowRunApis = { + async create() { + return structuredClone(workflow) + }, + async get() { + return structuredClone(workflow) + }, + async update(next) { + workflow = { ...structuredClone(next), version: workflow.version + 1 } + return structuredClone(workflow) + }, + async remove() {}, + } + const generationApis = options.generationApis ?? generationApisFixture() + const controller = createWorkflowController({ + workflow, + workflowRunApis, + generationApis, + onAsyncError: vi.fn(), + }) + + return { + controller, + project: projectFixture(), + character: options.character ?? null, + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + } +} + +function generationApisFixture(overrides: Partial = {}): GenerationApis { + return { + create: vi.fn(() => + Promise.reject(new Error('Generation 后端尚未实现')), + ) as GenerationApis['create'], + get: vi.fn(() => Promise.reject(new Error('Generation 后端尚未实现'))), + subscribe: vi.fn(() => () => undefined), + ...overrides, + } +} + +function latestFlowProps() { + if (!flowProps.current) throw new Error('React Flow 尚未渲染') + return flowProps.current as { + nodes: TestCanvasNode[] + edges: Array> + nodesConnectable?: boolean + edgesReconnectable?: boolean + deleteKeyCode?: null + } & Record +} + +function workflowFixture(): WorkflowRun { + return { + id: '42', + projectId: '1', + version: 3, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'active', + phase: 'configuring', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '短发少年冒险家', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'locked', + phase: 'ready', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: null, + }, + ], + } +} + +function completedTemplateWorkflow(id: string): WorkflowRun { + return { + id, + projectId: '1', + version: 3, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '短发少年冒险家', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: `https://assets.windup.test/${id}.png`, + }, + ], + } +} + +function failedTemplateWorkflow(): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'failed', + phase: 'generating', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId: 'failed-task', role: 'character_template' }], + error: '候选生成失败', + selectedImageUrl: null, + } + return workflow +} + +function selectingTemplateWorkflow(version: number, taskId: string): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.version = version + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'selecting', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId, role: 'character_template' }], + error: null, + selectedImageUrl: null, + } + return workflow +} + +function generatingTemplateWorkflow(): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'generating', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId: 'in-flight-task', role: 'character_template' }], + error: null, + selectedImageUrl: null, + } + return workflow +} + +function createGenerationRaceSession( + oldRead: Deferred, + latestRead: Deferred, +): WorkflowEditorSession { + const first = selectingTemplateWorkflow(3, 'old-task') + const latest = selectingTemplateWorkflow(4, 'new-task') + let listener: ((workflow: WorkflowRun) => void) | null = null + let readCount = 0 + const controller = { + getWorkflow: () => structuredClone(latest), + subscribe(nextListener: (workflow: WorkflowRun) => void) { + listener = nextListener + nextListener(structuredClone(first)) + return () => { + listener = null + } + }, + async resume() { + listener?.(structuredClone(latest)) + }, + getGeneration: vi.fn(() => { + readCount += 1 + return readCount === 1 ? oldRead.promise : latestRead.promise + }), + dispose: vi.fn(), + } as unknown as WorkflowController + + return { + controller, + project: projectFixture(), + character: null, + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + } +} + +function createRestartSelectionSession(): { + session: WorkflowEditorSession + emit(workflow: WorkflowRun): void +} { + let current = selectingTemplateWorkflow(3, 'old-task') + let listener: ((workflow: WorkflowRun) => void) | null = null + const emit = (workflow: WorkflowRun) => { + current = structuredClone(workflow) + listener?.(structuredClone(current)) + } + const controller = { + getWorkflow: () => structuredClone(current), + subscribe(nextListener: (workflow: WorkflowRun) => void) { + listener = nextListener + nextListener(structuredClone(current)) + return () => { + listener = null + } + }, + resume: vi.fn(async () => undefined), + getGeneration: vi.fn(async () => { + const taskId = current.nodes[1]?.generations[0]?.taskId + return taskId ? characterGeneration(taskId.startsWith('new') ? 'new' : 'old') : null + }), + restartFromNode: vi.fn(async () => { + const ready = completedTemplateWorkflow('42') + ready.version = current.version + 1 + ready.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'ready', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: null, + } + emit(ready) + }), + confirmCharacterTemplate: vi.fn(async () => undefined), + dispose: vi.fn(), + } as unknown as WorkflowController + + return { + session: { + controller, + project: projectFixture(), + character: null, + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + }, + emit, + } +} + +function characterGeneration(label: string): Generation { + return { + id: `${label}-task`, + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: `https://assets.windup.test/${label}.png` }], + }, + error: null, + } +} + +function characterFixture(): Character { + return { + id: '9', + projectId: '1', + workflowRunId: '42', + name: '正式角色', + description: null, + referenceImageUrl: null, + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'day', + characterId: '9', + name: '常态装', + description: null, + previewUrl: null, + actions: [], + }, + { + id: 'night', + characterId: '9', + name: '夜行装', + description: null, + previewUrl: null, + actions: [], + }, + ], + } +} + +interface Deferred { + promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function projectFixture(): Project { + return { + id: '1', + ownerId: '7', + workflowId: null, + name: '正式项目', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 64, height: 64 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-10T00:00:00.000Z', + updatedAt: '2026-08-10T00:00:00.000Z', + } +} diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx index ef527e41..221169ac 100644 --- a/frontend/src/pages/workflow-editor/index.tsx +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -1,90 +1,999 @@ +import { + Controls, + Handle, + Position, + ReactFlow, + applyNodeChanges, + type Edge, + type Node, + type NodeChange, + type NodeProps, + useReactFlow, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useParams } from 'react-router' -import type { ActionGenerationMethod } from '@/entities' -import { PageContainer } from '@/shared/ui' - -const WORKFLOW_CARDS = [ - { index: '01', title: '角色设定', detail: '输入身份、外观与参考图' }, - { index: '02', title: '角色母版', detail: '生成并确认角色母版' }, - { index: '03', title: '动作首帧', detail: '生成并确认动作起始帧' }, - { index: '04', title: '资产生成方式', detail: '选择视频裁剪或 3D 转 2D' }, - { index: '05', title: '完整动画', detail: '按所选路线生成 32 帧动画' }, - { index: '06', title: '审核', detail: '核验结果并进入 Playtest' }, -] as const +import { + CHARACTER_PERSPECTIVE, + DIRECTIONAL_MOVEMENT, + type ActionFirstFrameWorkflowNode, + type ActionFullFrameWorkflowNode, + type ActionGenerationMethodWorkflowNode, + type Character, + type CharacterSetupWorkflowNode, + type CharacterTemplateWorkflowNode, + type Generation, + type Project, + type ReviewWorkflowNode, + type WorkflowNode, + type WorkflowRun, +} from '@/entities' +import type { WorkflowController } from '@/features/workflow-controller' +import { createDefaultRealWorkflowEditorSession, type WorkflowEditorSession } from './runtime' +import './workflow-editor.css' export interface WorkflowEditorPageProps { - selectedGenerationMethod?: ActionGenerationMethod | null - onSelectGenerationMethod?: (method: ActionGenerationMethod) => void + loadSession?: (runId: string) => Promise +} + +type WorkflowCardData = { + title: string + eyebrow: string + status: WorkflowNode['status'] + content: ReactNode } -/** 工作流画布骨架;真实状态只来自 WorkflowRun,不在页面内伪造节点推进。 */ -export function WorkflowEditorPage({ - selectedGenerationMethod = null, - onSelectGenerationMethod, -}: WorkflowEditorPageProps = {}) { +type WorkflowCardNode = Node +type ActionMenuLevel = 'root' | 'outfits' | 'actions' + +const nodeTypes = { 'workflow-card': WorkflowCard } + +/** + * 页面只订阅 Controller 的 WorkflowRun 并把它投影为画布;选择、菜单、位置和 busy + * 都是临时 UI 状态,不会写出第二份流程状态机。 + */ +export function WorkflowEditorPage({ loadSession }: WorkflowEditorPageProps = {}) { const { runId } = useParams<{ runId: string }>() + const [session, setSession] = useState(null) + const [run, setRun] = useState(null) + const [generations, setGenerations] = useState>({}) + const [selectedImages, setSelectedImages] = useState>({}) + const [actionMenuOpen, setActionMenuOpen] = useState(false) + const [actionMenuLevel, setActionMenuLevel] = useState('root') + const [selectedOutfitId, setSelectedOutfitId] = useState(null) + const [busy, setBusy] = useState(null) + const [error, setError] = useState(null) + const [resumeError, setResumeError] = useState(null) + const [generationReadError, setGenerationReadError] = useState(null) + const [canvasNodes, setCanvasNodes] = useState([]) + const sessionEpochRef = useRef(0) + const generationReadEpochRef = useRef(0) + + const requestGenerations = useCallback( + (targetSession: WorkflowEditorSession, targetRun: WorkflowRun, sessionEpoch: number) => { + const readEpoch = generationReadEpochRef.current + 1 + generationReadEpochRef.current = readEpoch + void readGenerations(targetSession.controller, targetRun) + .then((results) => { + if ( + sessionEpochRef.current === sessionEpoch && + generationReadEpochRef.current === readEpoch + ) { + setGenerations(results) + setGenerationReadError(null) + } + }) + .catch((cause: unknown) => { + if ( + sessionEpochRef.current === sessionEpoch && + generationReadEpochRef.current === readEpoch + ) { + setGenerationReadError(errorMessage(cause, '读取生成结果失败')) + } + }) + }, + [], + ) + + const runCommand = useCallback( + (name: string, command: () => Promise) => { + if (busy) return + const commandEpoch = sessionEpochRef.current + setBusy(name) + setError(null) + void command() + .catch((cause: unknown) => { + if (sessionEpochRef.current === commandEpoch) { + setError(errorMessage(cause, '工作流命令执行失败')) + } + }) + .finally(() => { + if (sessionEpochRef.current === commandEpoch) setBusy(null) + }) + }, + [busy], + ) + + useEffect(() => { + const sessionEpoch = sessionEpochRef.current + 1 + sessionEpochRef.current = sessionEpoch + generationReadEpochRef.current += 1 + setSession(null) + setRun(null) + setGenerations({}) + setSelectedImages({}) + setActionMenuOpen(false) + setActionMenuLevel('root') + setSelectedOutfitId(null) + setBusy(null) + setError(null) + setResumeError(null) + setGenerationReadError(null) + setCanvasNodes([]) + if (!runId) return + + let active = true + let loaded: WorkflowEditorSession | null = null + let unsubscribe: () => void = () => undefined + let unsubscribeErrors: () => void = () => undefined + const loader = loadSession ?? defaultSessionLoader + + void loader(runId) + .then(async (nextSession) => { + if (!active || sessionEpochRef.current !== sessionEpoch) { + nextSession.dispose() + return + } + loaded = nextSession + setSession(nextSession) + unsubscribeErrors = nextSession.subscribeErrors((nextError) => { + if (active && sessionEpochRef.current === sessionEpoch) { + setError(errorMessage(nextError, '工作流异步处理失败')) + } + }) + unsubscribe = nextSession.controller.subscribe((nextRun) => { + if (!active || sessionEpochRef.current !== sessionEpoch) return + setRun(nextRun) + requestGenerations(nextSession, nextRun, sessionEpoch) + }) + try { + await nextSession.controller.resume() + } catch (cause: unknown) { + if (active && sessionEpochRef.current === sessionEpoch) { + setResumeError(errorMessage(cause, '恢复 WorkflowRun 失败')) + } + } + }) + .catch((cause: unknown) => { + if (active && sessionEpochRef.current === sessionEpoch) { + setError(errorMessage(cause, '恢复 WorkflowRun 失败')) + } + }) + + return () => { + active = false + generationReadEpochRef.current += 1 + unsubscribe() + unsubscribeErrors() + loaded?.dispose() + } + }, [loadSession, requestGenerations, runId]) + + const projected = useMemo( + () => + run && session + ? projectCanvas({ + run, + controller: session.controller, + project: session.project, + character: session.character, + generations, + selectedImages, + actionMenuOpen, + actionMenuLevel, + selectedOutfitId, + busy, + resumeBlocked: Boolean(resumeError), + setSelectedImages, + setActionMenuOpen, + setActionMenuLevel, + setSelectedOutfitId, + runCommand, + }) + : { nodes: [] as WorkflowCardNode[], edges: [] as Edge[] }, + [ + actionMenuOpen, + actionMenuLevel, + busy, + generations, + run, + runCommand, + resumeError, + selectedImages, + selectedOutfitId, + session, + ], + ) + + useEffect(() => { + setCanvasNodes((previous) => + projected.nodes.map((node) => ({ + ...node, + position: previous.find((candidate) => candidate.id === node.id)?.position ?? node.position, + })), + ) + }, [projected.nodes]) + + useEffect(() => { + if ( + resumeError && + run && + !run.nodes.some( + (node) => !node.deletedAt && node.status === 'active' && node.phase === 'generating', + ) + ) { + setResumeError(null) + } + }, [resumeError, run]) + + function onNodesChange(changes: NodeChange[]) { + const safeChanges = changes.filter((change) => change.type !== 'remove') + setCanvasNodes((nodes) => applyNodeChanges(safeChanges, nodes)) + } + + if (!runId) { + return + } + + if (error && !run) { + return + } + + if (!session || !run) { + return + } + + const constraints = [ + CHARACTER_PERSPECTIVE[session.project.perspective], + DIRECTIONAL_MOVEMENT[session.project.directionalMovement], + `${session.project.spriteSize.width} × ${session.project.spriteSize.height}`, + session.project.gameStyle ?? '未设置画风', + ] + const visibleError = error ?? resumeError ?? generationReadError + return ( - -
-
-

WORKFLOW EDITOR

-

- {runId ? `工作流 ${runId.slice(0, 8)}` : '工作流画布'} -

-

- 首帧确认后先选择资产生产路线,再进入完整动画生成。 -

-
- -
    - {WORKFLOW_CARDS.map((card) => ( -
  1. - {card.index} -

    {card.title}

    -

    {card.detail}

    - {card.index === '04' ? ( - - ) : null} -
  2. - ))} -
+
+ + {visibleError ? ( +
+ {visibleError} + {!error && !resumeError && generationReadError ? ( + + ) : null} +
+ ) : null} +
+ + nodes={canvasNodes} + edges={projected.edges} + nodeTypes={nodeTypes} + onNodesChange={onNodesChange} + nodesDraggable + nodesConnectable={false} + edgesReconnectable={false} + elementsSelectable + deleteKeyCode={null} + fitView + fitViewOptions={{ padding: 0.14, maxZoom: 0.82 }} + minZoom={0.3} + maxZoom={1.2} + > + + +
- +
+ ) +} + +function FitViewOnChange({ signature }: { signature: string }) { + const { fitView } = useReactFlow() + useEffect(() => { + const timer = window.setTimeout(() => { + void fitView({ padding: 0.14, maxZoom: 0.82, duration: 180 }) + }, 32) + return () => window.clearTimeout(timer) + }, [fitView, signature]) + return null +} + +interface ProjectionInput { + run: WorkflowRun + controller: WorkflowController + project: Project + character: Character | null + generations: Record + selectedImages: Record + actionMenuOpen: boolean + actionMenuLevel: ActionMenuLevel + selectedOutfitId: string | null + busy: string | null + resumeBlocked: boolean + setSelectedImages: React.Dispatch>> + setActionMenuOpen(open: boolean): void + setActionMenuLevel(level: ActionMenuLevel): void + setSelectedOutfitId(outfitId: string | null): void + runCommand(name: string, command: () => Promise): void +} + +function projectCanvas(input: ProjectionInput): { nodes: WorkflowCardNode[]; edges: Edge[] } { + const activeNodes = input.run.nodes.filter((node) => !node.deletedAt) + const actionRootIds = activeNodes + .filter((node) => node.type === 'action-first-frame') + .map((node) => node.id) + const nodes = activeNodes.map((node) => + toCanvasNode(node, branchIndexFor(node, activeNodes, actionRootIds), input), + ) + const edges: Edge[] = activeNodes.flatMap((node) => { + const confirmed = node.status === 'passed' + return node.dependsOnNodeIds.map((source) => ({ + id: `${source}->${node.id}`, + source, + target: node.id, + selectable: false, + deletable: false, + type: 'default', + animated: false, + className: confirmed ? undefined : 'workflow-edge--flowing', + style: { + stroke: confirmed ? '#69866f' : 'rgba(72, 101, 80, 0.42)', + strokeWidth: 3, + strokeDasharray: confirmed ? undefined : '9 8', + }, + })) + }) + + return { nodes, edges } +} + +function toCanvasNode( + node: WorkflowNode, + branchIndex: number, + input: ProjectionInput, +): WorkflowCardNode { + return { + id: node.id, + type: 'workflow-card', + position: positionFor(node.type, branchIndex), + zIndex: node.type === 'character-template' && input.actionMenuOpen ? 1000 : 0, + draggable: true, + dragHandle: '.workflow-card__handle', + deletable: false, + data: { + eyebrow: eyebrowFor(node.type), + title: titleFor(node.type), + status: node.status, + content: contentFor(node, input), + }, + } +} + +function contentFor(node: WorkflowNode, input: ProjectionInput): ReactNode { + if (node.type === 'character-setup') return + if (node.type === 'character-template') { + return + } + if (node.type === 'action-first-frame') return + if (node.type === 'action-generation-method') return + if (node.type === 'action-full-frame') return + return +} + +function CharacterSetupContent({ + node, + input, +}: { + node: CharacterSetupWorkflowNode + input: ProjectionInput +}) { + if (node.status === 'failed') return + if (node.status === 'passed') return

角色描述已确认

+ return ( +
+
+ 角色描述 +

{node.input.prompt || '未提供角色描述'}

+ {node.input.referenceMedia.length > 0 ? ( + 已关联 {node.input.referenceMedia.length} 个参考媒体 + ) : null} +
+ +
+ ) +} + +function CharacterTemplateContent({ + node, + input, +}: { + node: CharacterTemplateWorkflowNode + input: ProjectionInput +}) { + if (node.status === 'failed') return + if (node.phase === 'ready' && node.status === 'active') { + const setupNode = findDependency(input.run, node, 'character-setup') + return ( + + ) + } + if (node.phase === 'selecting') { + const result = input.generations[node.id]?.result + const images = result?.type === 'character_template' ? result.images : [] + const selectedImageUrl = + images.find((image) => image.url === input.selectedImages[node.id])?.url ?? null + return ( +
+
+ {images.map((image, index) => ( + + ))} +
+ +
+ ) + } + if (node.status === 'passed' && node.selectedImageUrl) { + return ( +
+ 已确认身份母版 + 身份已锁定 + + {input.actionMenuOpen ? ( +
+ +
+ ) : null} +
+ ) + } + return +} + +function ActionMenu({ input, templateNodeId }: { input: ProjectionInput; templateNodeId: string }) { + const outfits = input.character?.outfits ?? [] + const selectedOutfit = outfits.find((outfit) => outfit.id === input.selectedOutfitId) ?? null + const presets = [ + { + type: 'idle', + name: 'Idle 待机', + hint: '预设动作 · 逐帧生成', + prompt: '平稳呼吸,重心轻微起伏', + }, + { + type: 'walk', + name: 'Walk 行走', + hint: '预设动作 · 逐帧生成', + prompt: '轻快地向前行走', + }, + { + type: 'attack', + name: 'Attack 攻击', + hint: '预设动作 · 逐帧生成', + prompt: '蓄力后向前攻击并回到准备姿态', + }, + ] as const + + if (input.actionMenuLevel === 'root') { + return ( +
+ + + +
+ ) + } + + if (input.actionMenuLevel === 'outfits') { + return ( +
+ + {outfits.map((outfit) => ( + + ))} +
+ ) + } + + return ( +
+ + {presets.map((preset) => ( + + ))} + +
) } -function GenerationMethodChoice({ - selected, - onSelect, +function FirstFrameContent({ + node, + input, }: { - selected: ActionGenerationMethod | null - onSelect?: (method: ActionGenerationMethod) => void + node: ActionFirstFrameWorkflowNode + input: ProjectionInput }) { + const result = input.generations[node.id]?.result + const image = result?.type === 'first_frame' ? result.image : null + if (node.status === 'failed') return + if (node.phase === 'configuring') { + const character = findCharacterForOutfit(input.character, node.input.outfitId) + return ( +
+

+ {node.input.name} · {node.input.fps} FPS +

+

{node.input.prompt ?? '无额外动作描述'}

+ +
+ ) + } + if (node.phase === 'selecting' && image) { + const selectedImageUrl = input.selectedImages[node.id] === image.url ? image.url : null + return ( +
+ + +
+ ) + } + if (node.phase === 'completed' && node.selectedFirstFrameUrl) { + return ( + 已确认动作首帧 + ) + } + return +} + +function MethodContent({ + node, + input, +}: { + node: ActionGenerationMethodWorkflowNode + input: ProjectionInput +}) { + if (node.status === 'failed') return + if (node.phase === 'completed') return

视频裁剪

+ if (node.status !== 'active') return return ( -
+
+ + +
+ ) +} + +function AnimationContent({ + node, + input, +}: { + node: ActionFullFrameWorkflowNode + input: ProjectionInput +}) { + const result = input.generations[node.id]?.result + const frames = result?.type === 'complete_animation' ? result.frames : [] + if (node.status === 'failed') return + if (node.phase === 'ready' && node.status === 'active') { + const firstFrame = findDependency(input.run, node, 'action-generation-method') + const actionNode = firstFrame + ? findDependency(input.run, firstFrame, 'action-first-frame') + : null + const character = + actionNode?.type === 'action-first-frame' + ? findCharacterForOutfit(input.character, actionNode.input.outfitId) + : null + return ( + ) + } + if (node.phase === 'completed' && frames.length) { + return ( +
+ {frames.map((frame, index) => ( + {`动画帧 + ))} +
+ ) + } + return +} + +function ReviewContent({ node, input }: { node: ReviewWorkflowNode; input: ProjectionInput }) { + if (node.status === 'failed') return + if (node.phase === 'completed') return

审核已通过

+ if (node.status !== 'active') return + return ( +
+

确认完整动画后完成本动作审核。

) } + +function WorkflowCard({ data }: NodeProps) { + return ( +
+ +
+ {data.eyebrow} + {data.title} +
+
{data.content}
+ +
+ ) +} + +function StatusText({ node, input }: { node: WorkflowNode; input: ProjectionInput }) { + const resumeBlocked = + input.resumeBlocked && node.status === 'active' && node.phase === 'generating' + if (node.status === 'failed' || resumeBlocked) { + return ( +
+

+ {node.status === 'failed' ? (node.error ?? '生成失败') : '生成任务恢复失败'} +

+ +
+ ) + } + const label = + node.status === 'locked' ? '等待上游节点' : node.phase === 'generating' ? '生成中…' : '处理中…' + return

{label}

+} + +function EditorBoundary({ message }: { message: string }) { + return ( +
+

MANUAL WORKFLOW

+

工作流编辑器

+ {message} +
+ ) +} + +async function readGenerations( + controller: WorkflowController, + run: WorkflowRun, +): Promise> { + const entries = await Promise.all( + run.nodes.flatMap((node) => + node.generations.map( + async (reference) => + [node.id, await controller.getGeneration(node.id, reference.role)] as const, + ), + ), + ) + return Object.fromEntries(entries) +} + +function getController(input: ProjectionInput): WorkflowController { + return input.controller +} + +function branchIndexFor( + node: WorkflowNode, + nodes: WorkflowNode[], + actionRootIds: string[], +): number { + const nodesById = new Map(nodes.map((candidate) => [candidate.id, candidate])) + let current: WorkflowNode | undefined = node + const visited = new Set() + while (current && !visited.has(current.id)) { + visited.add(current.id) + if (current.type === 'action-first-frame') { + return Math.max(0, actionRootIds.indexOf(current.id)) + } + current = current.dependsOnNodeIds + .map((dependencyId) => nodesById.get(dependencyId)) + .find((dependency) => dependency?.type !== 'character-template') + } + return 0 +} + +function positionFor(type: WorkflowNode['type'], branchIndex: number) { + const x: Record = { + 'character-setup': 70, + 'character-template': 510, + 'action-first-frame': 950, + 'action-generation-method': 1390, + 'action-full-frame': 1820, + review: 2250, + } + const isActionBranch = type.startsWith('action') || type === 'review' + return { + x: x[type], + y: isActionBranch ? 60 + branchIndex * 510 : 280, + } +} + +function titleFor(type: WorkflowNode['type']) { + return { + 'character-setup': '角色设定', + 'character-template': '身份母版', + 'action-first-frame': '动作首帧', + 'action-generation-method': '生产方式', + 'action-full-frame': '完整动画', + review: '动画审核', + }[type] +} + +function eyebrowFor(type: WorkflowNode['type']) { + return { + 'character-setup': '01 · ORIGIN', + 'character-template': '02 · MASTER', + 'action-first-frame': '03 · FIRST FRAME', + 'action-generation-method': '04 · METHOD', + 'action-full-frame': '05 · ANIMATION', + review: '06 · REVIEW', + }[type] +} + +function findCharacterForOutfit(character: Character | null, outfitId: string) { + return character?.outfits.some((outfit) => outfit.id === outfitId) ? character : null +} + +function findDependency( + run: WorkflowRun, + node: WorkflowNode, + type: T, +): Extract | null { + const dependencies = run.nodes.filter((candidate) => node.dependsOnNodeIds.includes(candidate.id)) + const match = dependencies.find((candidate) => candidate.type === type) + return (match as Extract | undefined) ?? null +} + +async function defaultSessionLoader(runId: string) { + return createDefaultRealWorkflowEditorSession(runId) +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message ? cause.message : fallback +} diff --git a/frontend/src/pages/workflow-editor/workflow-editor.css b/frontend/src/pages/workflow-editor/workflow-editor.css new file mode 100644 index 00000000..86ff5693 --- /dev/null +++ b/frontend/src/pages/workflow-editor/workflow-editor.css @@ -0,0 +1,467 @@ +.workflow-editor-shell { + --editor-ink: #1d1d1f; + --editor-muted: #737378; + --editor-paper: #dfe3df; + --editor-line: rgb(27 38 30 / 20%); + --editor-shadow: 0 14px 36px rgb(30 39 32 / 13%), 0 2px 5px rgb(30 39 32 / 8%); + position: fixed; + z-index: 30; + inset: 0; + overflow: hidden; + color: var(--editor-ink); + background: var(--editor-paper); +} + +.workflow-editor-project { + position: absolute; + z-index: 15; + right: auto; + bottom: 18px; + left: 18px; + display: grid; + min-width: 250px; + max-width: min(380px, calc(100vw - 112px)); + gap: 4px; + padding: 12px 15px; + border: 1px solid rgb(36 49 39 / 12%); + border-radius: 10px; + background: rgb(249 250 247 / 90%); + box-shadow: 0 8px 24px rgb(31 40 33 / 9%); + backdrop-filter: blur(14px); + pointer-events: none; +} + +.workflow-editor-project p, +.workflow-editor-project h1 { + margin: 0; +} + +.workflow-editor-project > div:first-child p { + margin: 0 0 5px; + color: #7b827b; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.12em; +} + +.workflow-editor-project h1 { + color: #29342c; + font-size: 14px; + font-weight: 700; +} + +.workflow-editor-project__constraints { + overflow: hidden; + color: #777f78; + font-size: 9px; + line-height: 1.5; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-editor-contract { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 4px; +} + +.workflow-editor-contract span { + padding: 3px 7px; + border: 1px solid rgb(61 83 67 / 12%); + border-radius: 999px; + color: #385040; + background: rgb(229 237 231 / 78%); + font-size: 8px; + font-weight: 700; +} + +.workflow-editor-contract .is-real { + color: var(--editor-ink); + background: rgb(255 255 255 / 72%); +} + +.workflow-editor-contract small { + color: var(--editor-muted); + font: + 700 8px ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.workflow-editor-canvas { + position: absolute; + inset: 0; + background-color: var(--editor-paper); + background-image: radial-gradient(circle, rgba(46, 62, 50, 0.18) 1px, transparent 1.25px); + background-size: 21px 21px; +} + +.workflow-editor-canvas .react-flow__controls { + overflow: hidden; + border: 1px solid rgb(36 49 39 / 12%); + border-radius: 10px; + background: rgb(249 250 247 / 90%); + box-shadow: 0 8px 24px rgb(31 40 33 / 9%); + backdrop-filter: blur(14px); +} + +.workflow-editor-canvas .react-flow__controls-button { + border-color: var(--editor-line); + color: var(--editor-ink); + background: rgb(255 255 255 / 76%); +} + +.workflow-editor-error { + position: absolute; + z-index: 10; + top: 150px; + left: 50%; + display: flex; + align-items: center; + gap: 12px; + transform: translateX(-50%); + padding: 10px 14px; + border: 1px solid #b66e62; + color: #7b3027; + background: #f7e9e5; + font-size: 12px; +} + +.workflow-editor-error button { + padding: 5px 8px; + border: 1px solid currentColor; + border-radius: 6px; + color: inherit; + background: transparent; + font: inherit; + font-weight: 700; +} + +.workflow-card { + width: 368px; + overflow: visible; + border: 1px solid rgb(27 38 30 / 20%); + border-radius: 12px; + background: rgba(249, 250, 247, 0.98); + box-shadow: var(--editor-shadow); +} + +.workflow-card.is-locked { + opacity: 0.45; +} + +.workflow-card.is-failed { + border-style: dashed; +} + +.workflow-card__handle { + display: grid; + gap: 2px; + min-height: 62px; + align-content: center; + padding: 12px 18px; + border-bottom: 0; + cursor: grab; + color: #f2f5f1; + background: #26352b; + border-radius: 11px 11px 0 0; + user-select: none; +} + +.workflow-card__handle:active { + cursor: grabbing; +} + +.workflow-card__handle span { + color: #aebcb1; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.12em; +} + +.workflow-card__handle strong { + font-size: 14px; + font-weight: 700; +} + +.workflow-card__body { + padding: 21px; + border-radius: 0 0 11px 11px; + background: rgba(249, 250, 247, 0.98); +} + +.workflow-card__body p { + margin: 0; + color: var(--editor-muted); + font-size: 11px; + line-height: 1.6; +} + +.workflow-card__stack { + display: grid; + gap: 17px; +} + +.workflow-card__saved-input { + display: grid; + gap: 7px; +} + +.workflow-card__saved-input > span, +.workflow-card__saved-input > small { + color: #626b64; + font-size: 9px; + font-weight: 750; +} + +.workflow-card button { + min-height: 42px; + padding: 9px 12px; + border: 1px solid #34533d; + border-radius: 8px; + color: #edf4ee; + background: #34533d; + font-size: 11px; + font-weight: 750; +} + +.workflow-card button:not(:disabled):hover, +.workflow-card button[aria-pressed='true'] { + border-color: #263f2e; + background: #263f2e; +} + +.workflow-card button:disabled { + color: #969d97; + border-color: #e2e5e1; + background: #e2e5e1; + cursor: not-allowed; + opacity: 1; +} + +.workflow-card .workflow-card__plus { + position: absolute; + z-index: 8; + right: -16px; + bottom: -16px; + display: grid; + width: 32px; + min-height: 32px; + padding: 0; + place-items: center; + border: 1px solid var(--editor-ink); + border-radius: 50%; + color: var(--editor-ink); + background: #fff; + box-shadow: var(--editor-shadow); + font-size: 15px; + line-height: 1; +} + +.workflow-card .workflow-card__plus:hover { + color: #fff; + background: var(--editor-ink); +} + +.workflow-card__plus-menu { + position: absolute; + z-index: 7; + top: calc(100% - 16px); + left: calc(100% + 24px); + display: flex; + min-width: 190px; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--editor-line); + border-radius: 12px; + background: #fff; + box-shadow: var(--editor-shadow); +} + +.workflow-editor-canvas .workflow-edge--flowing path { + animation: workflow-edge-flow 900ms linear infinite; + will-change: stroke-dashoffset; +} + +@keyframes workflow-edge-flow { + to { + stroke-dashoffset: -17; + } +} + +.workflow-editor-canvas .react-flow__handle { + top: 38px; + width: 22px; + height: 22px; + border: 4px solid #f5f7f4; + background: #68836e; + box-shadow: 0 0 0 1px #435849; +} + +.workflow-candidate-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 7px; +} + +.workflow-candidate-grid button, +.workflow-first-frame { + padding: 4px; + border-color: var(--editor-line); + background: #fbfcfb; +} + +.workflow-candidate-grid img, +.workflow-first-frame img, +.workflow-master-image { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 8px; +} + +.workflow-candidate-grid button[aria-pressed='true'], +.workflow-first-frame[aria-pressed='true'] { + border-color: var(--editor-ink); + background: #fff; + box-shadow: 0 0 0 2px rgb(31 35 41 / 12%); +} + +.workflow-master-image { + border: 1px solid var(--editor-line); + border-radius: 12px; + background: #f6f7f6; + image-rendering: pixelated; +} + +.workflow-lock { + color: var(--editor-muted); + font-size: 11px; + text-align: center; +} + +.workflow-action-menu { + display: contents; +} + +.workflow-card .workflow-action-menu button { + display: flex; + min-height: 0; + flex-direction: column; + gap: 2px; + padding: 9px 12px; + border: 0; + border-radius: 0; + color: var(--editor-ink); + background: transparent; + text-align: left; + cursor: pointer; +} + +.workflow-card .workflow-action-menu button + button { + border-top: 1px solid rgb(31 35 41 / 6%); +} + +.workflow-card .workflow-action-menu button:hover:not(:disabled) { + color: var(--editor-ink); + background: #f2f4f2; +} + +.workflow-card .workflow-action-menu button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.workflow-action-menu b { + font-size: 12px; + font-weight: 600; +} + +.workflow-action-menu small { + color: var(--editor-muted); + font-size: 10px; +} + +.workflow-action-menu button:first-child:not(:only-child) { + color: var(--editor-muted); + font-weight: 500; +} + +.workflow-action-menu button:first-child:not(:only-child) b { + font-size: 12px; +} + +.workflow-frame-strip { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 3px; + max-height: 160px; + overflow: auto; +} + +.workflow-frame-strip img { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border: 1px solid var(--editor-line); + border-radius: 4px; +} + +.workflow-card__summary { + padding: 10px 12px; + border: 1px solid var(--editor-line); + border-radius: 10px; + background: #f6f7f6; +} + +.workflow-editor-boundary { + display: grid; + min-height: 100vh; + place-content: center; + gap: 8px; + text-align: center; + background: var(--editor-paper, #dfe3df); +} + +.workflow-editor-boundary p, +.workflow-editor-boundary h1, +.workflow-editor-boundary span { + margin: 0; +} + +.workflow-editor-boundary p { + color: #737378; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.16em; +} + +.workflow-editor-boundary h1 { + font-size: 30px; + font-weight: 600; +} + +.workflow-editor-boundary span { + color: #737378; + font-size: 13px; +} + +@media (max-width: 760px) { + .workflow-editor-project { + right: 72px; + bottom: 12px; + left: 12px; + min-width: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .workflow-editor-canvas .workflow-edge--flowing path { + animation: none; + } +} From 571633b73b11c4cd7b74fdf3e3c5b1b120ef6b02 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 21:34:02 +0800 Subject: [PATCH 06/18] feat(app): lazy-load workflow editor route React Flow should not increase the initial bundle cost of unrelated application routes. Load the editor behind Suspense while preserving both protected WorkflowRun route forms. Authenticated deep links keep their existing behavior and load the canvas only when requested. --- frontend/src/app/app.tsx | 18 ++++++++-- .../src/app/workflow-editor-route.test.tsx | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/workflow-editor-route.test.tsx diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index c8acde9b..be68bf6a 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router' import { AssetLibraryPage } from '@/pages/asset-library' @@ -9,10 +10,13 @@ import { ProjectDetailPage } from '@/pages/project-detail' import { ProjectCreatePage } from '@/pages/project-create' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' -import { WorkflowEditorPage } from '@/pages/workflow-editor' import { ProtectedRoute } from '@/features/auth-guard' import { AppShellRoute } from './layout' +const WorkflowEditorPage = lazy(() => + import('@/pages/workflow-editor').then(({ WorkflowEditorPage: Page }) => ({ default: Page })), +) + /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 @@ -27,6 +31,14 @@ export function App() { ) } +function LazyWorkflowEditorPage() { + return ( + }> + + + ) +} + /** 路由声明独立导出,测试用 MemoryRouter 验证直达地址。 */ export function AppRoutes() { return ( @@ -38,8 +50,8 @@ export function AppRoutes() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/app/workflow-editor-route.test.tsx b/frontend/src/app/workflow-editor-route.test.tsx new file mode 100644 index 00000000..11e799c4 --- /dev/null +++ b/frontend/src/app/workflow-editor-route.test.tsx @@ -0,0 +1,35 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { MemoryRouter, Outlet } from 'react-router' + +import { AuthenticatedAuthSession } from '@/test/auth-session' + +afterEach(() => { + cleanup() + vi.doUnmock('@/pages/workflow-editor') + vi.doUnmock('./layout') + vi.resetModules() +}) + +it('只有进入 Workflow Editor 路由时才加载 React Flow 页面', async () => { + const pageModuleFactory = vi.fn(() => ({ + WorkflowEditorPage: () =>
懒加载 Workflow Editor
, + })) + vi.doMock('@/pages/workflow-editor', pageModuleFactory) + vi.doMock('./layout', () => ({ AppShellRoute: () => })) + + const { AppRoutes } = await import('./app') + expect(pageModuleFactory).not.toHaveBeenCalled() + + render( + + + + + , + ) + + expect(await screen.findByText('懒加载 Workflow Editor')).toBeTruthy() + expect(pageModuleFactory).toHaveBeenCalledTimes(1) +}) From 8463cc2db349fc88c200e732f08187ad8fa6566b Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 23:43:25 +0800 Subject: [PATCH 07/18] feat(workflow-controller): accept character setup input Manual editor submissions need to persist the configured prompt before generation. Allow configuring nodes to override their input while keeping completed nodes immutable. Generation receives only canonical fields from the persisted setup snapshot. --- .../workflow-controller/controller.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 251f85a8..a5e3ff91 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -15,6 +15,7 @@ import type { MediaReference, ReviewWorkflowNode, WorkflowActionInput, + WorkflowCharacterInput, WorkflowGenerationRef, WorkflowGenerationRole, WorkflowNode, @@ -35,6 +36,8 @@ export interface AddActionInput { export interface GenerateCharacterTemplateOptions { spriteWidth: number spriteHeight: number + /** 手动编辑器提交时覆盖 configuring 节点的初始输入;节点通过后不再改写。 */ + input?: WorkflowCharacterInput } export interface GenerateActionOptions { @@ -315,8 +318,20 @@ export function createWorkflowController({ if (setupNode.status !== 'active' || setupNode.phase !== 'configuring') { throw new Error('角色设定节点当前不能提交') } + const input = options.input + ? { + prompt: nonEmpty(options.input.prompt, '角色描述'), + referenceMedia: [...options.input.referenceMedia], + } + : setupNode.input return unlockReadyNodes( - replaceNode(run, { ...setupNode, status: 'passed', phase: 'completed', error: null }), + replaceNode(run, { + ...setupNode, + input, + status: 'passed', + phase: 'completed', + error: null, + }), ) }) const templateNode = findSingleDependentNode(advanced, nodeId, 'character-template') @@ -329,7 +344,8 @@ export function createWorkflowController({ projectId: run.projectId, prompt: setupNode.input.prompt, referenceMedia: setupNode.input.referenceMedia, - ...options, + spriteWidth: options.spriteWidth, + spriteHeight: options.spriteHeight, } return input }) From 4acd99ba195e033ab5994104ab9025cd3df71c21 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 23:45:44 +0800 Subject: [PATCH 08/18] feat(workflow-editor): add character setup form Manual WorkflowRun entry needs an editable prompt before the setup node is fixed. Render a controlled description field and submit its value through WorkflowController. The form becomes read-only after persistence and keeps the existing visual system. --- frontend/src/pages/workflow-editor/index.tsx | 21 ++++++++++++--- .../pages/workflow-editor/workflow-editor.css | 26 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx index 221169ac..cf15ecd3 100644 --- a/frontend/src/pages/workflow-editor/index.tsx +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -416,25 +416,38 @@ function CharacterSetupContent({ node: CharacterSetupWorkflowNode input: ProjectionInput }) { + const [prompt, setPrompt] = useState(node.input.prompt) + useEffect(() => setPrompt(node.input.prompt), [node.id, node.input.prompt]) + if (node.status === 'failed') return if (node.status === 'passed') return

角色描述已确认

return (
-
+