Appearance
ADR-007: 异步生成 + 服务端节点同步 + WebSocket 推送
Status
Accepted — 已实现 HTTP POST 触发生成 + WebSocket Command 同步节点/连线操作 + WS Push 接收生成结果。服务端为画布数据 source of truth。
Date
2026-03-27
Context
(决策前)AI 生成采用同步 POST-and-wait 模式:前端发请求 → 阻塞等待 → 拿到结果。画布数据仅存储在 localStorage。
这带来三个问题:
- 长时任务超时:图片/视频生成可能需要 30s~5min,HTTP 连接容易被 Nginx/CDN 超时断开
- 多端不同步:用户在设备 A 生成的内容,在设备 B 看不到
- 刷新丢失:用户在生成中刷新页面,无法恢复进行中的任务
需要决定生成流程的异步模型和数据持久化策略。
Options
A: 前端自治 + 轮询
- 服务端不持有节点数据,只提供
POST /generate → taskId和GET /tasks/{taskId} - 前端 localStorage 存储 taskId↔nodeId 映射
- 前端轮询任务状态
- 优点:服务端简单,生成服务与画布解耦
- 缺点:无法实现多端同步(各端 localStorage 独立)
B: 服务端持有节点 + WebSocket 推送
- 服务端是画布/节点数据的 source of truth
- 前端通过 API 同步节点变更到服务端
- 生成完成后服务端直接写入节点
- WebSocket 推送变更到所有连接的客户端
- 优点:天然支持多端同步、刷新恢复、离线补偿
- 缺点:需要节点同步 API、服务端存储、WebSocket 基础设施
C: 前端自治 + WebSocket(不持有节点)
- 同 A,但用 WebSocket 替代轮询推送任务结果
- 前端负责 taskId→nodeId 映射
- 优点:服务端不需要理解节点
- 缺点:仍无法多端同步
Decision
选择 方案 B。多端同步是产品需求,服务端持有画布数据是前提条件。
生成流程
关键设计点
- generate API 不含节点信息:生成服务是纯粹的 AI 调用,不知道"节点"概念。taskId↔nodeId 的映射通过步骤 ② 的 PATCH 建立。
- PATCH 合并提交和同步:步骤 ② 一次完成"写入 taskId"和"同步到服务端",减少竞态窗口。
- 服务端是 source of truth:localStorage 降级为缓存/离线副本。
- WebSocket 推送节点变更:所有连接的客户端收到更新,天然实现多端同步。
同步模型:Command 而非 CRUD
用户的每次操作以 command 形式发送给服务端,而非同步状态快照。
背景:创建/删除节点天然是命令式的("创建了一个节点"而非"节点数量从 3 变成 4")。既然创建/删除已经是 command,移动/编辑也保持一致,避免混合两种模式。
CRUD vs Command 对比:
| CRUD(状态同步) | Command(操作同步) | |
|---|---|---|
| 撤销/重做 | 需要单独实现快照机制 | command stack 天然支持 |
| 协作编辑 | 需要全栈重构 | 扩展 command 广播范围即可 |
| 离线操作 | 冲突难解决 | command 队列暂存,上线后重放 |
| 服务端复杂度 | 低(纯 CRUD) | 中(每种 command 一个 handler) |
选择 Command 的理由:产品路径大概率从多端同步走向协作编辑。现在用 CRUD 以后迁移是全栈重构;现在用 Command 以后加协作是增量扩展。撤销/重做(P0 待修)也因此天然解决。
Command 类型(初步)
typescript
type CanvasCommand
= | { type: 'addNode', nodeType: NodeType, position: XY, data: NodeData }
| { type: 'removeNode', id: string }
| { type: 'moveNode', id: string, position: XY }
| { type: 'updateNodeData', id: string, data?: Record<string, unknown>, params?: Record<string, unknown> }
| { type: 'addEdge', source: string, target: string, sourceHandle?: string, targetHandle?: string }
| { type: 'removeEdge', id: string }
| { type: 'updateViewport', viewport: Viewport }生成相关:
typescript
type GenerationCommand
= | { type: 'startGeneration', nodeId: string, taskId: string }
// 生成完成由服务端触发,不是前端 command前端实现策略
- 现有 flowStore actions(
addNode、removeNode、addEdge等)内部增加 command 发送 - 本地先执行(乐观更新),同时发送 command 到服务端
- 服务端确认后通过 WebSocket 广播给其他客户端
- 撤销 = 发送反向 command(
removeNode的反向是addNode+ 原数据)
需要的接口
| 接口 | 用途 |
|---|---|
POST /ability/v1/{type}/action/generate | 提交生成任务 → { taskId } |
POST /canvas/{id}/commands | 发送 command(或通过 WebSocket 发送) |
GET /canvas/{id} | 拉取完整画布快照(页面加载/重连恢复) |
WebSocket | 双向:前端发 command + 服务端推变更 |
待定事项
以下细节待后续确认和设计:
- WebSocket 推送粒度(整个 node vs delta vs command 重放)
- 冲突策略(同一节点的并发 command 如何排序)
- Command 是走 HTTP POST 还是直接走 WebSocket
- 离线 command 队列大小和过期策略
- 画布快照频率(用于新客户端加入时的初始状态)
Consequences
正面:
- 多端同步:设备 A 的操作自动同步到设备 B
- 刷新恢复:服务端持有完整状态,刷新后从服务端恢复
- 无超时风险:生成请求立即返回 taskId,结果通过 WebSocket 异步推送
- 撤销/重做:command stack 天然支持,解决当前 P0 bug
- 协作就绪:未来加协作只需扩展 command 广播,不需要重构
负面:
- 需要从 localStorage 迁移到服务端存储(一次性迁移成本)
- 引入 WebSocket 基础设施(连接管理、心跳、重连)
- 前端需要 command 发送层和乐观更新机制
- 服务端需要实现每种 command 的处理函数
- 生成服务与画布存储产生间接耦合(通过 taskId↔nodeId 映射)