Skip to content

ADR-007: 异步生成 + 服务端节点同步 + WebSocket 推送

Status

Accepted — 已实现 HTTP POST 触发生成 + WebSocket Command 同步节点/连线操作 + WS Push 接收生成结果。服务端为画布数据 source of truth。

Date

2026-03-27

Context

(决策前)AI 生成采用同步 POST-and-wait 模式:前端发请求 → 阻塞等待 → 拿到结果。画布数据仅存储在 localStorage。

这带来三个问题:

  1. 长时任务超时:图片/视频生成可能需要 30s~5min,HTTP 连接容易被 Nginx/CDN 超时断开
  2. 多端不同步:用户在设备 A 生成的内容,在设备 B 看不到
  3. 刷新丢失:用户在生成中刷新页面,无法恢复进行中的任务

需要决定生成流程的异步模型和数据持久化策略。

Options

A: 前端自治 + 轮询

  • 服务端不持有节点数据,只提供 POST /generate → taskIdGET /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

前端实现策略

  1. 现有 flowStore actions(addNoderemoveNodeaddEdge 等)内部增加 command 发送
  2. 本地先执行(乐观更新),同时发送 command 到服务端
  3. 服务端确认后通过 WebSocket 广播给其他客户端
  4. 撤销 = 发送反向 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 映射)