From db985f0f722aba5e40f22edf8c8eb88e810dd55c Mon Sep 17 00:00:00 2001 From: huyan Date: Sat, 29 Aug 2026 18:16:44 +0800 Subject: [PATCH 1/3] docs(frontend): add architecture map Frontend orchestration and delivery boundaries were only scattered across code and module notes. Add a validated JSON source, deterministic renderer, and standalone interactive viewer with focused views and source anchors. Preserve the shared workflow core, browser finalization loop, and separate publication adapters as explicit handoff facts. --- frontend/docs/architecture/render.mjs | 977 ++++ .../windup-frontend.architecture.json | 903 ++++ .../docs/architecture/windup-frontend.html | 4436 +++++++++++++++++ 3 files changed, 6316 insertions(+) create mode 100644 frontend/docs/architecture/render.mjs create mode 100644 frontend/docs/architecture/windup-frontend.architecture.json create mode 100644 frontend/docs/architecture/windup-frontend.html 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 + + + 项目约束 · 完整资产树 + + + + +
+
+

+ + + + + + From 67a0cc91ca8a2bb56cf14a8c54dfbf688301c70e Mon Sep 17 00:00:00 2001 From: huyan Date: Sat, 29 Aug 2026 18:17:31 +0800 Subject: [PATCH 2/3] docs(frontend): document architecture viewer The architecture artifact needs a stable reading guide and regeneration contract. Document each focused view, the truthful publication split, renderer commands, and validation behavior. Keep the map maintainable without turning it into a stale component inventory or backend topology. --- frontend/docs/architecture/README.md | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 frontend/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,避免后续行号漂移让旧图指向错误实现。 From 659c387b924a4591f8a68c9dea0bffb17570631c Mon Sep 17 00:00:00 2001 From: huyan Date: Sat, 29 Aug 2026 19:19:25 +0800 Subject: [PATCH 3/3] docs(frontend): link architecture map Frontend contributors need a discoverable path to the final architecture handoff. Link the interactive map and its maintenance guide from the frontend README. Make the as-built diagram reachable without changing product code or runtime behavior. --- frontend/README.md | 4 ++++ 1 file changed, 4 insertions(+) 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)。