diff --git a/frontend/README.md b/frontend/README.md index ea57d546..4975a785 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -49,3 +49,7 @@ Vercel 部署路径不受影响,`vercel.json` 照旧。 前后端接口契约以仓库根目录自动生成的 `openapi.json` 为准,也可在本地后端的 FastAPI `/docs` 页面中查看。 + +## 前端架构 + +浏览器直接打开 [前端架构与创作链路图](./docs/architecture/windup-frontend.html),可以按“双入口合流 / 状态闭环 / 资产交付”聚焦阅读,并从节点详情跳到固定 Git revision 的源码锚点。可读源文件与重新生成命令见 [架构图说明](./docs/architecture/README.md)。 diff --git a/frontend/docs/architecture/README.md b/frontend/docs/architecture/README.md new file mode 100644 index 00000000..4c97fb33 --- /dev/null +++ b/frontend/docs/architecture/README.md @@ -0,0 +1,43 @@ +# Windup 前端架构图 + +这张图回答三件事:用户如何从两种入口进入同一条创作流程,浏览器如何把后端任务收口为可恢复的 `WorkflowRun`,以及审核结果如何成为可核验、可导出的 Character 资产。 + +| 产物 | 用途 | +|---|---| +| [windup-frontend.html](./windup-frontend.html) | 独立查看器;支持亮/暗主题、四种阅读视图、节点源码索引和 SVG / PNG 导出 | +| [windup-frontend.architecture.json](./windup-frontend.architecture.json) | 人可读的架构源文件;保存节点、边界、连线、阅读视图、事实说明和源码锚点 | +| [render.mjs](./render.mjs) | 无第三方依赖的确定性渲染器;校验源文件后生成独立 HTML,也可额外导出 SVG | + +## 怎么读 + +- **全景**:从 App 外壳、双入口、共享编排、生成恢复走到发布和交付。 +- **双入口合流**:Quick Start 自动连续调用,Workflow Editor 等待用户逐步操作;两者共享 `WorkflowController` 和 `WorkflowRun.nodes`。 +- **状态闭环**:任务在后端执行,但浏览器仍要订阅终态、对账并把节点变化持久化回 `WorkflowRun`。 +- **资产交付**:已审核动作进入 Character → Outfit → Action → Sequence → Frame 资产树,再被资产库、Playtest 和导出包消费。 + +图中刻意保留 Quick Start 与 Workflow Editor 的发布分叉:它们共用生成编排,但当前仍通过两条适配路径写入 Character。这里不把“共享 Controller”夸成尚未实现的端到端统一。 + +## 重新生成 + +从仓库根目录执行: + +```bash +node frontend/docs/architecture/render.mjs +``` + +需要同时得到普通 SVG 时: + +```bash +node frontend/docs/architecture/render.mjs \ + frontend/docs/architecture/windup-frontend.architecture.json \ + frontend/docs/architecture/windup-frontend.html \ + --svg /tmp/windup-frontend.svg +``` + +渲染器会拒绝重复组件或连线 ID、不存在的连接端点、缺少几何路径的连线,以及引用未知组件的阅读视图。架构发生变化时,先更新 JSON 中的源码锚点和 `meta.repository.revision`,再重新生成 HTML。 + +## 文档边界 + +- 这不是 React 组件目录树,也不枚举每个页面内部状态。 +- 这不是后端部署或服务拓扑图;蓝色虚线只表示前端实际消费的公开 HTTP / SSE 合同。 +- 图中的源码链接固定到 JSON 声明的 Git revision,避免后续行号漂移让旧图指向错误实现。 diff --git a/frontend/docs/architecture/render.mjs b/frontend/docs/architecture/render.mjs new file mode 100644 index 00000000..f1e73013 --- /dev/null +++ b/frontend/docs/architecture/render.mjs @@ -0,0 +1,977 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const sourcePath = resolve( + process.argv[2] ?? `${scriptDirectory}/windup-frontend.architecture.json`, +) +const outputPath = resolve(process.argv[3] ?? `${scriptDirectory}/windup-frontend.html`) +const svgFlagIndex = process.argv.indexOf('--svg') +const svgOutputPath = + svgFlagIndex >= 0 + ? resolve(process.argv[svgFlagIndex + 1] ?? `${scriptDirectory}/windup-frontend.svg`) + : null + +const LIGHT = { + canvas: '#f3f2ec', + surface: '#ffffff', + ink: '#1d251f', + inkSoft: '#303a32', + muted: '#687169', + faint: '#778078', + line: '#cbd1ca', + lineStrong: '#8fa092', + core: '#284331', + coreSoft: '#dce9df', + entry: '#8a672a', + entrySoft: '#f3e8cb', + contract: '#2a5284', + contractSoft: '#e4edf7', + caution: '#6f3928', + cautionSoft: '#f4e8e1', + grid: '#dfe3dc', +} + +const data = JSON.parse(await readFile(sourcePath, 'utf8')) +validateArchitecture(data) + +const svg = renderSvg(data) +await writeFile(outputPath, formatGeneratedHtml(renderHtml(data, svg), outputPath), 'utf8') +if (svgOutputPath) await writeFile(svgOutputPath, svg, 'utf8') + +function validateArchitecture(architecture) { + if (architecture?.schema_version !== 1) throw new Error('只支持 schema_version 1') + if (!Array.isArray(architecture?.meta?.canvas) || architecture.meta.canvas.length !== 2) { + throw new Error('meta.canvas 必须是 [width, height]') + } + const componentIds = new Set() + for (const component of architecture.components ?? []) { + if (!component.id || componentIds.has(component.id)) { + throw new Error(`组件 ID 缺失或重复:${component.id ?? ''}`) + } + componentIds.add(component.id) + if (!Array.isArray(component.pos) || !Array.isArray(component.size)) { + throw new Error(`组件 ${component.id} 缺少 pos / size`) + } + } + const connectionIds = new Set() + for (const connection of architecture.connections ?? []) { + if (!connection.id || connectionIds.has(connection.id)) { + throw new Error(`连线 ID 缺失或重复:${connection.id ?? ''}`) + } + connectionIds.add(connection.id) + if (!componentIds.has(connection.from) || !componentIds.has(connection.to)) { + throw new Error(`连线 ${connection.id} 引用了不存在的组件`) + } + if (!Array.isArray(connection.route) || connection.route.length < 2) { + throw new Error(`连线 ${connection.id} 缺少 route`) + } + } + for (const view of architecture.meta.views ?? []) { + for (const id of view.focus ?? []) { + if (!componentIds.has(id)) throw new Error(`视图 ${view.id} 引用了不存在的组件 ${id}`) + } + } +} + +function formatGeneratedHtml(html, filePath) { + const formatter = resolve(scriptDirectory, '../../node_modules/.bin/oxfmt') + if (!existsSync(formatter)) return html + const result = spawnSync(formatter, ['--stdin-filepath', filePath], { + input: html, + encoding: 'utf8', + }) + if (result.status !== 0) { + throw new Error(result.stderr.trim() || 'oxfmt 无法格式化生成的 HTML') + } + return result.stdout +} + +function escapeXml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function wrapText(value, maxUnits) { + const text = String(value).trim() + if (!text) return [] + const units = [...text].map((character) => ({ + character, + width: /[\u2e80-\u9fff\uff00-\uffef]/u.test(character) ? 1 : 0.58, + })) + const lines = [] + let line = '' + let width = 0 + let lastSoftBreak = -1 + for (const unit of units) { + line += unit.character + width += unit.width + if (unit.character === ' ' || unit.character === '·' || unit.character === '/') { + lastSoftBreak = line.length + } + if (width <= maxUnits) continue + if (lastSoftBreak > 0) { + lines.push(line.slice(0, lastSoftBreak).trim()) + line = line.slice(lastSoftBreak).trimStart() + } else { + lines.push(line.slice(0, -1)) + line = unit.character + } + width = [...line].reduce( + (sum, character) => sum + (/[\u2e80-\u9fff\uff00-\uffef]/u.test(character) ? 1 : 0.58), + 0, + ) + lastSoftBreak = -1 + } + if (line) lines.push(line.trim()) + return lines.filter(Boolean) +} + +function textBlock(lines, x, y, options = {}) { + const { + className = 'node-copy', + lineHeight = 17, + anchor = 'start', + maxLines = lines.length, + fill = className === 'node-label' + ? LIGHT.ink + : className === 'node-tag' + ? LIGHT.faint + : LIGHT.muted, + } = options + return `${lines + .slice(0, maxLines) + .map( + (line, index) => + `${escapeXml(line)}`, + ) + .join('')}` +} + +function renderSvg(architecture) { + const [width, height] = architecture.meta.canvas + const componentById = new Map( + architecture.components.map((component) => [component.id, component]), + ) + const connectionMarkup = architecture.connections + .map((connection) => renderConnection(connection)) + .join('\n') + const componentMarkup = architecture.components + .map((component) => renderComponent(component, architecture)) + .join('\n') + const boundaries = architecture.boundaries.map(renderBoundary).join('\n') + const header = renderHeader(architecture) + const legend = renderLegend() + const grid = renderGrid(width, height) + + for (const connection of architecture.connections) { + if (!componentById.has(connection.from) || !componentById.has(connection.to)) { + throw new Error(`无法渲染连线 ${connection.id}`) + } + } + + return ` + ${escapeXml(architecture.meta.title)} + ${escapeXml(architecture.meta.subtitle)}。Quick Start 与 Workflow Editor 汇入同一个 WorkflowController 和 WorkflowRun,再把审核后的 Character 资产交给资产库、Playtest 与导出包。 + + + + + + + + + ${grid} + ${header} + ${boundaries} + ${legend} + ${connectionMarkup} + ${componentMarkup} +` +} + +function renderGrid(width, height) { + const lines = [] + for (let x = 50; x < width; x += 50) { + lines.push( + ``, + ) + } + for (let y = 180; y < height; y += 50) { + lines.push( + ``, + ) + } + return `` +} + +function renderHeader(architecture) { + const truths = architecture.truths.slice(0, 3) + const truthMarkup = truths + .map((truth, index) => { + const x = 1120 + index * 220 + const lines = wrapText(truth.title, 14) + return `` + }) + .join('') + return ` + FRONTEND ARCHITECTURE · 2026.08 + ${escapeXml(architecture.meta.title)} + ${escapeXml(architecture.meta.subtitle)} + + ${truthMarkup} + ` +} + +function renderBoundary(boundary) { + const [x, y] = boundary.pos + const [width, height] = boundary.size + const backend = boundary.id === 'backend' + return `` +} + +function renderLegend() { + return `` +} + +function connectionPaint(variant) { + if (variant === 'entry') return { stroke: LIGHT.entry, marker: 'arrow-entry', dash: null } + if (variant === 'contract') + return { stroke: LIGHT.contract, marker: 'arrow-contract', dash: '6 7' } + if (variant === 'caution') return { stroke: LIGHT.caution, marker: 'arrow-caution', dash: '6 5' } + if (variant === 'feedback') return { stroke: LIGHT.core, marker: 'arrow-core', dash: '4 5' } + if (variant === 'optional') return { stroke: LIGHT.core, marker: 'arrow-core', dash: '2 7' } + return { stroke: LIGHT.core, marker: 'arrow-core', dash: null } +} + +function renderConnection(connection) { + const points = connection.route.map(([x, y]) => `${x},${y}`).join(' ') + const [labelX, labelY] = connection.label_pos + const paint = connectionPaint(connection.variant) + return ` + + ${escapeXml(connection.label)} + ` +} + +function renderComponent(component, architecture) { + if (component.shape === 'rail') return renderRail(component) + if (component.shape === 'controller') return renderController(component) + if (component.shape === 'workflow') return renderWorkflow(component, architecture.workflow_stages) + if (component.shape === 'asset-tree') return renderAssetTree(component, architecture.asset_tree) + return renderCard(component) +} + +function renderNodeWrapper(component, innerMarkup, focusRadius = 12) { + const [x, y] = component.pos + const [width, height] = component.size + return ` + ${escapeXml(component.label)}:${escapeXml(component.sublabel ?? '')} + + ${innerMarkup} + ` +} + +function nodePaint(kind) { + if (kind === 'entry') return { fill: LIGHT.entrySoft, stroke: LIGHT.entry } + if (kind === 'core') return { fill: LIGHT.coreSoft, stroke: LIGHT.core } + if (kind === 'caution') return { fill: LIGHT.cautionSoft, stroke: LIGHT.caution } + if (kind === 'contract') return { fill: LIGHT.contractSoft, stroke: LIGHT.contract } + if (kind === 'platform') return { fill: LIGHT.surface, stroke: LIGHT.line } + return { fill: LIGHT.surface, stroke: LIGHT.lineStrong } +} + +function renderRail(component) { + const [x, y] = component.pos + const [width, height] = component.size + const parts = component.label.split(' · ') + const paint = nodePaint(component.kind) + const inner = ` + + ${escapeXml(parts.join(' / ').toUpperCase())} + ${escapeXml(component.sublabel)}` + return renderNodeWrapper(component, inner, 12) +} + +function renderCard(component) { + const [x, y] = component.pos + const [width, height] = component.size + const radius = + component.shape === 'endpoint' ? height / 2 : component.shape === 'surface-small' ? 12 : 18 + const labelLines = wrapText(component.label, Math.max(9, (width - 58) / 15)) + const copyLines = wrapText(component.sublabel, Math.max(12, (width - 28) / 11.5)) + const glyph = renderGlyph(component, x + 24, y + 25) + const labelY = y + (component.shape === 'endpoint' ? 28 : 39) + const copyY = component.shape === 'endpoint' ? y + 47 : y + 65 + const paint = nodePaint(component.kind) + const inner = ` + ${glyph} + ${component.tag ? `${escapeXml(component.tag)}` : ''} + ${textBlock(labelLines, x + (component.shape === 'endpoint' ? width / 2 : 46), labelY, { className: 'node-label', lineHeight: 19, anchor: component.shape === 'endpoint' ? 'middle' : 'start', maxLines: 2, fill: LIGHT.ink })} + ${textBlock(copyLines, x + (component.shape === 'endpoint' ? width / 2 : 18), copyY, { className: 'node-copy', lineHeight: 16, anchor: component.shape === 'endpoint' ? 'middle' : 'start', maxLines: component.shape === 'surface-small' ? 1 : 2, fill: LIGHT.muted })}` + return renderNodeWrapper(component, inner, radius) +} + +function renderGlyph(component, x, y) { + const paint = nodePaint(component.kind) + if (component.shape === 'endpoint') return '' + if (component.shape === 'entry') { + return `` + } + if (component.shape === 'publisher') { + return `` + } + if (component.shape === 'surface' || component.shape === 'surface-small') { + return `` + } + return `` +} + +function renderController(component) { + const [x, y] = component.pos + const [width, height] = component.size + const copyLines = wrapText(component.sublabel, 22) + const paint = nodePaint(component.kind) + const inner = ` + + + + ${escapeXml(component.tag)} + ${escapeXml(component.label)} + ${textBlock(copyLines, x + 22, y + 118, { className: 'node-copy', lineHeight: 17, maxLines: 2, fill: LIGHT.muted })} + COMMANDS · PERSIST · SUBSCRIBE · RESUME` + return renderNodeWrapper(component, inner, 34) +} + +function renderWorkflow(component, stages) { + const [x, y] = component.pos + const [width, height] = component.size + const startX = x + 55 + const endX = x + width - 55 + const railY = y + 120 + const stageStep = (endX - startX) / (stages.length - 1) + const stageMarkup = stages + .map((stage, index) => { + const stageX = startX + index * stageStep + return `` + }) + .join('') + const branchStart = startX + stageStep + const paint = nodePaint(component.kind) + const inner = ` + ${escapeXml(component.tag)} + ${escapeXml(component.label)} + ${escapeXml(component.sublabel)} + + ${stageMarkup} + + + 每个 Action 重复 03 → 06;多个动作共享角色母版后可并行 + dependsOnNodeIds · nodeId + taskId · phase / status` + return renderNodeWrapper(component, inner, 26) +} + +function renderAssetTree(component, items) { + const [x, y] = component.pos + const [width, height] = component.size + const startY = y + 70 + const rows = items + .map((item, index) => { + const rowY = startY + index * 19 + const pixelX = x + 25 + item.depth * 18 + return `` + }) + .join('') + const paint = nodePaint(component.kind) + const inner = ` + ${escapeXml(component.tag)} + ${escapeXml(component.label)} + ${rows} + + character_data` + return renderNodeWrapper(component, inner, 26) +} + +function renderHtml(architecture, svg) { + const safeData = JSON.stringify(architecture).replaceAll('<', '\\u003c') + const shortRevision = architecture.meta.repository.revision.slice(0, 7) + return ` + + + + + + + + + ${escapeXml(architecture.meta.title)} · ${escapeXml(architecture.meta.subtitle)} + + + + +
+
WindupFrontend Map
+ + + main@${escapeXml(shortRevision)} +
+ + + + + + +
+
+
+
${svg}
+
+

+ + + + + + +` +} diff --git a/frontend/docs/architecture/windup-frontend.architecture.json b/frontend/docs/architecture/windup-frontend.architecture.json new file mode 100644 index 00000000..f7e555ce --- /dev/null +++ b/frontend/docs/architecture/windup-frontend.architecture.json @@ -0,0 +1,903 @@ +{ + "schema_version": 1, + "diagram_type": "frontend-architecture", + "meta": { + "title": "Windup 前端架构", + "subtitle": "双入口,一张节点图,一棵可交付资产树", + "locale": "zh-CN", + "quality_profile": "showcase", + "canvas": [1800, 1200], + "repository": { + "url": "https://github.com/1024XEngineer/Windup", + "revision": "992dadf2aa91f4904c1e61c9621adb11dc0f68d3" + }, + "views": [ + { + "id": "overview", + "label": "全景", + "note": "从产品入口、浏览器编排、生成恢复走到资产发布与交付。" + }, + { + "id": "convergence", + "label": "双入口合流", + "focus": [ + "quick-start", + "agent-runtime", + "workflow-editor", + "editor-session", + "workflow-controller", + "workflow-run" + ], + "note": "Quick Start 自动连续调用;Workflow Editor 等待用户逐步操作。两者不维护第二套状态机。" + }, + { + "id": "state", + "label": "状态闭环", + "focus": [ + "workflow-controller", + "workflow-run", + "generation-apis", + "sse-recovery", + "client-bake", + "workflow-api", + "generation-api" + ], + "note": "任务由后端执行,浏览器订阅终态、对账并把节点变化写回 WorkflowRun。" + }, + { + "id": "delivery", + "label": "资产交付", + "focus": [ + "quickstart-publisher", + "editor-publisher", + "asset-tree", + "asset-library", + "character-detail", + "playtest", + "export-package", + "assets-api" + ], + "note": "已审核动作进入 Character 资产树,再被资产库、核验台和导出包消费。" + } + ] + }, + "palette": { + "canvas": "#f3f2ec", + "surface": "#ffffff", + "ink": "#1d251f", + "muted": "#687169", + "line": "#cbd1ca", + "core": "#284331", + "core_soft": "#dce9df", + "entry": "#8a672a", + "entry_soft": "#f3e8cb", + "contract": "#2a5284", + "contract_soft": "#e4edf7", + "caution": "#6f3928", + "caution_soft": "#f4e8e1" + }, + "boundaries": [ + { + "id": "browser", + "label": "BROWSER · React + TypeScript", + "pos": [50, 175], + "size": [1700, 825], + "note": "路由、会话、工作流推进、SSE 收口、WebGL 出帧与浏览器导出都在此边界内。" + }, + { + "id": "backend", + "label": "BACKEND CONTRACTS · FastAPI", + "pos": [50, 1030], + "size": [1700, 125], + "note": "前端只经公开 HTTP/SSE 合同访问任务、流程与资产;密钥与模型调用不进入浏览器。" + } + ], + "components": [ + { + "id": "app-shell", + "kind": "platform", + "shape": "rail", + "label": "AppRoutes · ProtectedRoute · AuthSession", + "sublabel": "公开页 / 登录产品 / 项目工作区的外壳边界;shared/api 惰性读取 token 并处理 401 恢复", + "pos": [100, 205], + "size": [1580, 45], + "details": [ + "路由表定义公开宣传页、受保护产品页与项目工作区,不由页面反向决定全局外壳。", + "AuthSession 持有访问令牌;shared/api 只注册读取函数和 401 恢复函数,不拥有登录状态。" + ], + "sources": [ + { "path": "frontend/src/app/app.tsx", "line": 29, "label": "路由与外壳边界" }, + { + "path": "frontend/src/features/auth-session/index.tsx", + "line": 202, + "label": "注册 token 与 401 恢复" + }, + { "path": "frontend/src/shared/api/index.ts", "line": 41, "label": "共享 API 认证边界" } + ] + }, + { + "id": "quick-start", + "kind": "entry", + "shape": "entry", + "label": "Quick Start", + "sublabel": "对话式入口 · 自动选择与连续推进", + "tag": "AI-GUIDED", + "pos": [100, 285], + "size": [270, 105], + "details": [ + "用户用文字或参考媒体开始角色创作。", + "页面把 Agent 提案翻译为同一组 WorkflowController 命令,而不是维护独立流程模型。" + ], + "sources": [ + { "path": "frontend/src/app/app.tsx", "line": 59, "label": "生产 Agent 装配" }, + { + "path": "frontend/src/pages/quick-start/service.ts", + "line": 320, + "label": "创建共享 Controller" + } + ] + }, + { + "id": "agent-runtime", + "kind": "entry", + "shape": "adapter", + "label": "Agent Runtime", + "sublabel": "Planner · 授权闸 · 自动推进", + "tag": "BROWSER SANDBOX", + "pos": [430, 285], + "size": [245, 105], + "details": [ + "AI SDK 只负责协议,适配器补 Windup JWT 并接到 /ai/chat。", + "真正的生成授权与业务推进仍由浏览器里的 Agent Runtime 和 Controller 协作完成。" + ], + "sources": [ + { + "path": "frontend/src/features/quick-start-agent/production.ts", + "line": 84, + "label": "Agent HTTP 适配器" + }, + { + "path": "frontend/src/features/quick-start-agent/production.ts", + "line": 143, + "label": "生产依赖装配" + }, + { + "path": "frontend/src/pages/quick-start/service.ts", + "line": 775, + "label": "订阅并自动推进" + } + ] + }, + { + "id": "workflow-editor", + "kind": "entry", + "shape": "entry", + "label": "Workflow Editor", + "sublabel": "节点画布 · 用户逐步生成、确认与审核", + "tag": "USER-DRIVEN", + "pos": [100, 455], + "size": [270, 105], + "details": [ + "用户直接看见六类工作流节点与依赖边。", + "页面只消费 WorkflowEditorSession,不直接拼装 API 或另建状态机。" + ], + "sources": [ + { "path": "frontend/src/app/app.tsx", "line": 81, "label": "编辑器路由" }, + { + "path": "frontend/src/pages/workflow-editor/runtime.ts", + "line": 71, + "label": "真实编辑器会话" + } + ] + }, + { + "id": "editor-session", + "kind": "entry", + "shape": "adapter", + "label": "Editor Session", + "sublabel": "Project / Character 上下文 · Controller 装配", + "tag": "SESSION ADAPTER", + "pos": [430, 455], + "size": [245, 105], + "details": [ + "会话读取 WorkflowRun、项目与其唯一 Character,再创建同一个 WorkflowController。", + "上传、三渲二、发布和错误订阅均通过会话暴露,页面不直连传输协议。" + ], + "sources": [ + { + "path": "frontend/src/pages/workflow-editor/runtime.ts", + "line": 33, + "label": "会话公开边界" + }, + { + "path": "frontend/src/pages/workflow-editor/runtime.ts", + "line": 75, + "label": "读取上下文并装配" + } + ] + }, + { + "id": "workflow-controller", + "kind": "core", + "shape": "controller", + "label": "WorkflowController", + "sublabel": "一实例 · 一条 WorkflowRun", + "tag": "ORCHESTRATION CORE", + "pos": [760, 350], + "size": [260, 165], + "details": [ + "Quick Start 自动连续调用;Workflow Editor 等待用户逐步点击。Controller 不识别入口。", + "命令覆盖生成、确认、重做、方向级重试、审核、恢复与中断。", + "保存串行化;只有后端确认落库后才替换内存快照,避免页面假报成功。" + ], + "sources": [ + { + "path": "frontend/src/features/workflow-controller/controller.ts", + "line": 177, + "label": "双入口共享约定" + }, + { + "path": "frontend/src/features/workflow-controller/controller.ts", + "line": 338, + "label": "Controller 状态与队列" + }, + { + "path": "frontend/src/features/workflow-controller/controller.ts", + "line": 409, + "label": "持久化后再通知页面" + } + ] + }, + { + "id": "workflow-run", + "kind": "core", + "shape": "workflow", + "label": "WorkflowRun.nodes", + "sublabel": "前端感知并推进 · 后端原样持久化", + "tag": "SINGLE SOURCE OF FLOW STATE", + "pos": [1090, 270], + "size": [600, 310], + "details": [ + "节点通过 dependsOnNodeIds 保存直接依赖,边随节点一起落库。", + "角色母版通过后,每个 Action 展开首帧 → 路线 → 完整动画 → 审核四节点分支。", + "生成中、选择中等是节点 phase;重做覆盖结果并用 nodeId + taskId 拒绝迟到任务。" + ], + "sources": [ + { + "path": "frontend/src/entities/workflow-run/README.md", + "line": 7, + "label": "统一节点模型" + }, + { "path": "frontend/src/entities/workflow-run/README.md", "line": 12, "label": "动作分支" }, + { + "path": "frontend/src/entities/workflow-run/README.md", + "line": 20, + "label": "前后端状态边界" + } + ] + }, + { + "id": "generation-apis", + "kind": "core", + "shape": "adapter", + "label": "Generation APIs", + "sublabel": "图片 · 首帧 · 动画 · 任务快照", + "tag": "AUTHENTICATED ADAPTER", + "pos": [760, 610], + "size": [230, 100], + "details": [ + "把节点语义映射为 /generation/image、/first-frame 与 /action 请求。", + "候选数、方向、路线和任务预期都在前端适配器中显式校验。" + ], + "sources": [ + { "path": "frontend/src/entities/generation/api.ts", "line": 989, "label": "完整动画请求" }, + { + "path": "frontend/src/entities/generation/api.ts", + "line": 1029, + "label": "动作首帧请求" + }, + { "path": "frontend/src/entities/generation/api.ts", "line": 1071, "label": "角色母版请求" } + ] + }, + { + "id": "sse-recovery", + "kind": "core", + "shape": "adapter", + "label": "SSE + Recovery", + "sublabel": "先订阅再查询 · 断线对账 · 轮询降级", + "tag": "FINALIZATION LOOP", + "pos": [1045, 610], + "size": [230, 100], + "details": [ + "受保护 SSE 用 fetch 流携带 Authorization,不能使用原生 EventSource。", + "Controller 先订阅再查询任务快照,关闭查询与订阅之间的丢事件窗口。", + "刷新恢复时复用 WorkflowRun 中记录的 taskId,不盲目创建新任务。" + ], + "sources": [ + { "path": "frontend/src/shared/api/stream.ts", "line": 187, "label": "鉴权 SSE" }, + { + "path": "frontend/src/features/workflow-controller/controller.ts", + "line": 1761, + "label": "订阅、快照与结算" + }, + { + "path": "frontend/src/features/workflow-controller/controller.ts", + "line": 1991, + "label": "刷新恢复" + } + ] + }, + { + "id": "client-bake", + "kind": "core", + "shape": "adapter", + "label": "Client Bake", + "sublabel": "可选三渲二 · 浏览器 WebGL 出帧", + "tag": "GPU ON DEVICE", + "pos": [1330, 610], + "size": [230, 100], + "details": [ + "完整动画任务需要浏览器出帧时,Controller 拉起本地 WebGL runner。", + "浏览器逐帧渲染并上传;帧数、空帧、脚线和成色最终仍由服务端把关。" + ], + "sources": [ + { + "path": "frontend/src/features/client-bake/index.ts", + "line": 1, + "label": "浏览器出帧边界" + }, + { + "path": "frontend/src/features/client-bake/index.ts", + "line": 47, + "label": "逐帧渲染与交付" + }, + { + "path": "frontend/src/features/workflow-controller/controller.ts", + "line": 1802, + "label": "Controller 挂起出帧" + } + ] + }, + { + "id": "quickstart-publisher", + "kind": "caution", + "shape": "publisher", + "label": "Quick Start 发布适配", + "sublabel": "service 内组装 Action 并更新 Character", + "tag": "CURRENTLY SEPARATE", + "pos": [430, 760], + "size": [245, 95], + "details": [ + "Quick Start 审核后在 service 内从完整动画结果组装 Action,并直接更新 Character。", + "这条发布投影与 Editor 的共享 Publisher 尚未完全合流;图中保留分叉,避免误称端到端统一。" + ], + "sources": [ + { + "path": "frontend/src/pages/quick-start/service.ts", + "line": 1293, + "label": "Quick Start 审核发布" + }, + { + "path": "frontend/src/pages/quick-start/service.ts", + "line": 1320, + "label": "组装 Action" + }, + { + "path": "frontend/src/pages/quick-start/service.ts", + "line": 1352, + "label": "写入 Character" + } + ] + }, + { + "id": "editor-publisher", + "kind": "core", + "shape": "publisher", + "label": "Asset Publisher", + "sublabel": "CharacterAssetPublisher · Editor 幂等发布与对账回滚", + "tag": "SHARED PUBLISHER", + "pos": [430, 880], + "size": [245, 95], + "details": [ + "Editor 会话把审核节点、完整动画任务和 Character 交给 Publisher。", + "Action ID 使用首帧节点 ID,发布成功但 WorkflowRun 保存失败时可安全重试。" + ], + "sources": [ + { + "path": "frontend/src/pages/workflow-editor/runtime.ts", + "line": 217, + "label": "Editor 发布命令" + }, + { "path": "frontend/src/features/export/index.ts", "line": 29, "label": "幂等发布约定" } + ] + }, + { + "id": "asset-tree", + "kind": "core", + "shape": "asset-tree", + "label": "Character 资产树", + "sublabel": "Character → Outfit → Action → Sequence → Frame", + "tag": "DELIVERY BOUNDARY", + "pos": [760, 785], + "size": [260, 165], + "details": [ + "造型、动作、方向序列与逐帧时长属于同一份 character_data。", + "Outfit、Action、Frame 没有独立端点;更新时随 Character 整棵提交。", + "WorkflowRun 保留制作过程,Character 资产树提供产品消费与导出。" + ], + "sources": [ + { + "path": "frontend/src/entities/character/index.ts", + "line": 45, + "label": "Frame 与方向序列" + }, + { + "path": "frontend/src/entities/character/index.ts", + "line": 96, + "label": "Outfit / Character 树" + }, + { + "path": "frontend/src/entities/character/index.ts", + "line": 146, + "label": "整树 API 边界" + } + ] + }, + { + "id": "asset-library", + "kind": "surface", + "shape": "surface", + "label": "资产库", + "sublabel": "项目下已发布角色索引", + "pos": [1110, 780], + "size": [200, 95], + "details": ["按项目分页读取已发布 Character 摘要。", "草稿不会伪装成可交付资产出现在库中。"], + "sources": [ + { "path": "frontend/src/pages/asset-library/index.tsx", "line": 14, "label": "资产库页面" }, + { + "path": "frontend/src/pages/asset-library/index.tsx", + "line": 32, + "label": "已发布资产查询" + } + ] + }, + { + "id": "character-detail", + "kind": "surface", + "shape": "surface-small", + "label": "角色详情", + "sublabel": "母版 · 造型 · 动作 · 像素精修", + "pos": [1110, 900], + "size": [200, 75], + "details": [ + "读取完整 Character 树,组织造型、动作和精修入口。", + "可从当前造型构造导出模型,也可进入核验台。" + ], + "sources": [ + { "path": "frontend/src/app/app.tsx", "line": 85, "label": "项目资产路由" }, + { + "path": "frontend/src/pages/character-detail/index.tsx", + "line": 236, + "label": "角色导出入口" + } + ] + }, + { + "id": "playtest", + "kind": "surface", + "shape": "surface", + "label": "Playtest", + "sublabel": "只读核验已发布动作", + "pos": [1360, 780], + "size": [180, 95], + "details": [ + "只依赖 Character 公开类型与读取接口,不推进 Workflow、不修改资产。", + "按 Frame.index 与 durationMs 还原真实动作,并提供方向与动作键位核验。" + ], + "sources": [ + { "path": "frontend/src/pages/playtest/README.md", "line": 13, "label": "只读页面边界" }, + { "path": "frontend/src/pages/playtest/README.md", "line": 19, "label": "运行时模型" } + ] + }, + { + "id": "export-package", + "kind": "surface", + "shape": "surface", + "label": "Export Package", + "sublabel": "PNG · Atlas · GIF · Meta · ZIP · Cocos", + "pos": [1580, 780], + "size": [170, 95], + "details": [ + "同一渐进装配器被 Quick Start、Editor、角色详情和 Playtest 组合点复用。", + "浏览器验证帧序、尺寸、透明通道与质量状态,再生成 Sprite Sheet、元数据和 ZIP。" + ], + "sources": [ + { + "path": "frontend/src/features/export-package/README.md", + "line": 14, + "label": "导出数据流" + }, + { + "path": "frontend/src/features/export-package/README.md", + "line": 23, + "label": "导出结构" + } + ] + }, + { + "id": "agent-api", + "kind": "contract", + "shape": "endpoint", + "label": "/ai/chat", + "sublabel": "Agent 对话", + "pos": [250, 1065], + "size": [220, 65], + "details": ["AI SDK 协议经前端适配后进入同源 /ai/chat;模型密钥留在后端。"], + "sources": [ + { + "path": "frontend/src/features/quick-start-agent/production.ts", + "line": 33, + "label": "路径改写" + } + ] + }, + { + "id": "workflow-api", + "kind": "contract", + "shape": "endpoint", + "label": "/workflow-runs", + "sublabel": "CRUD · nodes JSON 持久化", + "pos": [600, 1065], + "size": [250, 65], + "details": ["后端保存 WorkflowRun.nodes;节点结构与推进规则仍由前端拥有。"], + "sources": [ + { + "path": "frontend/src/entities/workflow-run/api.ts", + "line": 29, + "label": "WorkflowRun DTO" + } + ] + }, + { + "id": "generation-api", + "kind": "contract", + "shape": "endpoint", + "label": "/generation/* · /tasks/*/events", + "sublabel": "任务创建 · 快照 · SSE 终态", + "pos": [930, 1065], + "size": [350, 65], + "details": ["任务在服务端运行;浏览器通过快照与 SSE 收回终态并完成流程闭环。"], + "sources": [ + { "path": "frontend/src/entities/generation/api.ts", "line": 1093, "label": "任务快照" }, + { "path": "frontend/src/entities/generation/api.ts", "line": 1120, "label": "任务订阅" } + ] + }, + { + "id": "assets-api", + "kind": "contract", + "shape": "endpoint", + "label": "/projects · /characters", + "sublabel": "项目约束 · 完整资产树", + "pos": [1390, 1065], + "size": [280, 65], + "details": ["项目提供生成约束;Character 整树承载可消费、可核验、可导出的资产。"], + "sources": [ + { + "path": "frontend/src/entities/character/index.ts", + "line": 146, + "label": "Character API" + } + ] + } + ], + "workflow_stages": [ + { "id": "setup", "label": "角色设定", "short": "01" }, + { "id": "template", "label": "角色母版", "short": "02" }, + { "id": "first-frame", "label": "动作首帧", "short": "03" }, + { "id": "method", "label": "生成路线", "short": "04" }, + { "id": "animation", "label": "完整动画", "short": "05" }, + { "id": "review", "label": "审核", "short": "06" } + ], + "asset_tree": [ + { "id": "character", "label": "Character", "depth": 0 }, + { "id": "outfit", "label": "Outfit", "depth": 1 }, + { "id": "action", "label": "Action", "depth": 2 }, + { "id": "sequence", "label": "Sequence", "depth": 3 }, + { "id": "frame", "label": "Frame", "depth": 4 } + ], + "connections": [ + { + "id": "quick-agent", + "from": "quick-start", + "to": "agent-runtime", + "label": "提案 / 授权", + "variant": "entry", + "route": [ + [370, 338], + [430, 338] + ], + "label_pos": [400, 326] + }, + { + "id": "editor-session-link", + "from": "workflow-editor", + "to": "editor-session", + "label": "用户命令", + "variant": "entry", + "route": [ + [370, 508], + [430, 508] + ], + "label_pos": [400, 496] + }, + { + "id": "agent-controller", + "from": "agent-runtime", + "to": "workflow-controller", + "label": "自动连续调用", + "variant": "entry", + "route": [ + [675, 338], + [720, 338], + [720, 392], + [760, 392] + ], + "label_pos": [719, 325] + }, + { + "id": "session-controller", + "from": "editor-session", + "to": "workflow-controller", + "label": "逐步调用", + "variant": "entry", + "route": [ + [675, 508], + [720, 508], + [720, 474], + [760, 474] + ], + "label_pos": [718, 530] + }, + { + "id": "controller-run", + "from": "workflow-controller", + "to": "workflow-run", + "label": "推进 / 保存", + "variant": "core", + "route": [ + [1020, 432], + [1052, 432], + [1052, 402], + [1090, 402] + ], + "label_pos": [1052, 420] + }, + { + "id": "controller-generation", + "from": "workflow-controller", + "to": "generation-apis", + "label": "创建 / 查询", + "variant": "core", + "route": [ + [890, 515], + [890, 610] + ], + "label_pos": [933, 566] + }, + { + "id": "generation-sse", + "from": "generation-apis", + "to": "sse-recovery", + "label": "任务身份", + "variant": "core", + "route": [ + [990, 660], + [1045, 660] + ], + "label_pos": [1018, 648] + }, + { + "id": "sse-controller", + "from": "sse-recovery", + "to": "workflow-controller", + "label": "终态收口", + "variant": "feedback", + "route": [ + [1160, 610], + [1160, 565], + [980, 565], + [980, 515] + ], + "label_pos": [1068, 553] + }, + { + "id": "sse-bake", + "from": "sse-recovery", + "to": "client-bake", + "label": "可选出帧", + "variant": "optional", + "route": [ + [1275, 660], + [1330, 660] + ], + "label_pos": [1303, 648] + }, + { + "id": "agent-quick-publish", + "from": "agent-runtime", + "to": "quickstart-publisher", + "label": "审核交付", + "variant": "caution", + "route": [ + [552, 390], + [552, 760] + ], + "label_pos": [594, 732] + }, + { + "id": "session-editor-publish", + "from": "editor-session", + "to": "editor-publisher", + "label": "审核交付", + "variant": "core", + "route": [ + [552, 560], + [552, 880] + ], + "label_pos": [594, 865] + }, + { + "id": "quick-assets", + "from": "quickstart-publisher", + "to": "asset-tree", + "label": "update", + "variant": "caution", + "route": [ + [675, 808], + [720, 808], + [720, 826], + [760, 826] + ], + "label_pos": [716, 796] + }, + { + "id": "editor-assets", + "from": "editor-publisher", + "to": "asset-tree", + "label": "publish", + "variant": "core", + "route": [ + [675, 928], + [720, 928], + [720, 906], + [760, 906] + ], + "label_pos": [716, 948] + }, + { + "id": "assets-library", + "from": "asset-tree", + "to": "asset-library", + "label": "已发布摘要", + "variant": "core", + "route": [ + [1020, 826], + [1110, 826] + ], + "label_pos": [1065, 814] + }, + { + "id": "library-detail", + "from": "asset-library", + "to": "character-detail", + "label": "完整资产", + "variant": "core", + "route": [ + [1210, 875], + [1210, 900] + ], + "label_pos": [1250, 892] + }, + { + "id": "assets-playtest", + "from": "asset-tree", + "to": "playtest", + "label": "只读核验", + "variant": "core", + "route": [ + [1020, 850], + [1320, 850], + [1320, 828], + [1360, 828] + ], + "label_pos": [1320, 842] + }, + { + "id": "assets-export", + "from": "asset-tree", + "to": "export-package", + "label": "渐进导出", + "variant": "core", + "route": [ + [1020, 906], + [1535, 906], + [1535, 828], + [1580, 828] + ], + "label_pos": [1535, 893] + }, + { + "id": "agent-contract", + "from": "agent-runtime", + "to": "agent-api", + "label": "Bearer", + "variant": "contract", + "route": [ + [552, 390], + [552, 1030], + [360, 1030], + [360, 1065] + ], + "label_pos": [390, 1018] + }, + { + "id": "run-contract", + "from": "workflow-run", + "to": "workflow-api", + "label": "JSON", + "variant": "contract", + "route": [ + [1390, 580], + [1390, 1018], + [725, 1018], + [725, 1065] + ], + "label_pos": [760, 1006] + }, + { + "id": "generation-contract", + "from": "generation-apis", + "to": "generation-api", + "label": "HTTP / SSE", + "variant": "contract", + "route": [ + [875, 710], + [875, 1006], + [1105, 1006], + [1105, 1065] + ], + "label_pos": [1088, 994] + }, + { + "id": "assets-contract", + "from": "asset-tree", + "to": "assets-api", + "label": "整树读写", + "variant": "contract", + "route": [ + [890, 950], + [890, 1038], + [1530, 1038], + [1530, 1065] + ], + "label_pos": [1495, 1026] + } + ], + "truths": [ + { + "number": "01", + "title": "双入口共享编排,不等于端到端完全统一", + "body": "Quick Start 与 Workflow Editor 共用 Controller 和 WorkflowRun;动作发布仍保留两条适配路径。" + }, + { + "number": "02", + "title": "后端任务完成,不等于浏览器流程已经闭合", + "body": "SSE / 快照终态仍要由 Controller 结算并把节点变化持久化回 WorkflowRun。" + }, + { + "number": "03", + "title": "交付的是资产,而不是一组图片", + "body": "最终被核验和导出的,是带造型、动作方向、帧序与时长的 Character 资产树。" + } + ] +} diff --git a/frontend/docs/architecture/windup-frontend.html b/frontend/docs/architecture/windup-frontend.html new file mode 100644 index 00000000..c1ef0b17 --- /dev/null +++ b/frontend/docs/architecture/windup-frontend.html @@ -0,0 +1,4436 @@ + + + + + + + + + + Windup 前端架构 · 双入口,一张节点图,一棵可交付资产树 + + + + +
+
WindupFrontend Map
+ + + main@992dadf +
+ + + + + + +
+
+
+
+ + Windup 前端架构 + + 双入口,一张节点图,一棵可交付资产树。Quick Start 与 Workflow Editor 汇入同一个 + WorkflowController 和 WorkflowRun,再把审核后的 Character 资产交给资产库、Playtest + 与导出包。 + + + + + + + + + + + + + + + + + + + + + FRONTEND ARCHITECTURE · 2026.08 + + Windup 前端架构 + + 双入口,一张节点图,一棵可交付资产树 + + + + + + + + + + + + + + 提案 / 授权 + + + + + + 用户命令 + + + + + + 自动连续调用 + + + + + + 逐步调用 + + + + + + 推进 / 保存 + + + + + + 创建 / 查询 + + + + + + 任务身份 + + + + + + 终态收口 + + + + + + 可选出帧 + + + + + + 审核交付 + + + + + + 审核交付 + + + + + + update + + + + + + publish + + + + + + 已发布摘要 + + + + + + 完整资产 + + + + + + 只读核验 + + + + + + 渐进导出 + + + + + + Bearer + + + + + + JSON + + + + + + HTTP / SSE + + + + + + 整树读写 + + + + + + + AppRoutes · ProtectedRoute · AuthSession:公开页 / 登录产品 / + 项目工作区的外壳边界;shared/api 惰性读取 token 并处理 401 恢复 + + + + + + APPROUTES / PROTECTEDROUTE / AUTHSESSION + + + 公开页 / 登录产品 / 项目工作区的外壳边界;shared/api 惰性读取 token 并处理 401 恢复 + + + + Quick Start:对话式入口 · 自动选择与连续推进 + + + + + AI-GUIDED + + + Quick Start + + + 对话式入口 · 自动选择与连续推进 + + + + Agent Runtime:Planner · 授权闸 · 自动推进 + + + + + BROWSER SANDBOX + + + Agent Runtime + + + Planner · 授权闸 · 自动推进 + + + + Workflow Editor:节点画布 · 用户逐步生成、确认与审核 + + + + + USER-DRIVEN + + + Workflow Editor + + + 节点画布 · 用户逐步生成、确认与审核 + + + + Editor Session:Project / Character 上下文 · Controller 装配 + + + + + SESSION ADAPTER + + + Editor Session + + + Project / Character 上下文 · + Controller 装配 + + + + WorkflowController:一实例 · 一条 WorkflowRun + + + + + + ORCHESTRATION CORE + + WorkflowController + + + 一实例 · 一条 WorkflowRun + + + COMMANDS · PERSIST · SUBSCRIBE · RESUME + + + + WorkflowRun.nodes:前端感知并推进 · 后端原样持久化 + + + + SINGLE SOURCE OF FLOW STATE + + + WorkflowRun.nodes + + + 前端感知并推进 · 后端原样持久化 + + + + + + + + + + + + 每个 Action 重复 03 → 06;多个动作共享角色母版后可并行 + + + dependsOnNodeIds · nodeId + taskId · phase / status + + + + Generation APIs:图片 · 首帧 · 动画 · 任务快照 + + + + + AUTHENTICATED ADAPTER + + + Generation APIs + + + 图片 · 首帧 · 动画 · 任务快照 + + + + SSE + Recovery:先订阅再查询 · 断线对账 · 轮询降级 + + + + + FINALIZATION LOOP + + + SSE + Recovery + + + 先订阅再查询 · 断线对账 · 轮询降级 + + + + Client Bake:可选三渲二 · 浏览器 WebGL 出帧 + + + + + GPU ON DEVICE + + + Client Bake + + + 可选三渲二 · 浏览器 WebGL 出帧 + + + + Quick Start 发布适配:service 内组装 Action 并更新 Character + + + + + CURRENTLY SEPARATE + + + Quick Start 发布适配 + + + service 内组装 Action 并更新 + Character + + + + Asset Publisher:CharacterAssetPublisher · Editor 幂等发布与对账回滚 + + + + + SHARED PUBLISHER + + + Asset Publisher + + + CharacterAssetPublisher · Editor + 幂等发布与对账回滚 + + + + Character 资产树:Character → Outfit → Action → Sequence → Frame + + + DELIVERY BOUNDARY + + Character 资产树 + + + + + + + + + character_data + + + + 资产库:项目下已发布角色索引 + + + + + + 资产库 + + + 项目下已发布角色索引 + + + + 角色详情:母版 · 造型 · 动作 · 像素精修 + + + + + + 角色详情 + + + 母版 · 造型 · 动作 · + + + + Playtest:只读核验已发布动作 + + + + + + Playtest + + + 只读核验已发布动作 + + + + Export Package:PNG · Atlas · GIF · Meta · ZIP · Cocos + + + + + + Export Package + + + PNG · Atlas · GIF · + Meta · ZIP · Cocos + + + + /ai/chat:Agent 对话 + + + + + /ai/chat + + + Agent 对话 + + + + /workflow-runs:CRUD · nodes JSON 持久化 + + + + + /workflow-runs + + + CRUD · nodes JSON 持久化 + + + + /generation/* · /tasks/*/events:任务创建 · 快照 · SSE 终态 + + + + + /generation/* · /tasks/*/events + + + 任务创建 · 快照 · SSE 终态 + + + + /projects · /characters:项目约束 · 完整资产树 + + + + + /projects · /characters + + + 项目约束 · 完整资产树 + + + + +
+
+

+ + + + + +