diff --git a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md index da5cda88a..df59712c0 100644 --- a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md +++ b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md @@ -1,6 +1,7 @@ # 无限画布对话框 Agent 模式升级开发文档 > 日期:2026-06-22 +> 当前版本:V2 修订版 > 参考项目:Cowart(https://github.com/zhongerxin/cowart) > 目标:把当前“对话框 Agent 能理解画布上下文并生成结果”,升级为“Agent 能通过稳定协议读写画布、填充画布意图、保留版本链、低打扰执行”的可落地系统。 @@ -405,10 +406,94 @@ sequenceDiagram - Agent 只产出意图明确的 `CanvasOperationPlan`。 - Canvas Mutation API 负责真正修改画布,避免 Agent 任意写 JSON。 -- 视角控制通过 `CanvasViewOperationPlan` 在前端执行,不写入项目数据。 +- Agent 视角控制通过 `CanvasViewOperationPlan` 在前端执行,默认作为临时 UI 状态,不触发项目保存。 - 高风险操作必须经过确认门。 - 默认不破坏原图、原节点、原批注。 +### 4.4 V2 必修策略 + +为了让本文档从方向方案变成可拆 PR 的执行文档,V2 必须先补齐五个工程边界。 + +1. 新旧 `canvasMedia` 迁移策略 + +当前 Chat API 已经会 stream `data-canvasMedia`,前端 `CanvasChatPanel.appendStreamMediaToCanvas()` 会自动把消息媒体追加到画布。如果同时引入 `CanvasOperationPlan -> Mutation API`,会出现同一张结果图被插入两次的问题。 + +迁移策略: + +- 阶段 A:保留旧 `data-canvasMedia`,新增 `canvasOperationPlan` shadow log,但不执行 mutation。 +- 阶段 B:将 `canvasAgentWritebackMode` 切到 `operation_authoritative`,由 mutation API 成为权威写回路径。 +- 阶段 C:当某个结果已经通过 `data-canvasOperation` 写回成功,前端必须忽略同批次 `data-canvasMedia`。 +- 阶段 D:验证稳定后,Agent 模式下默认不再为同一结果触发旧 `appendStreamMediaToCanvas()`。 + +协议要求: + +- 每次 Agent 画布写回必须带 `operationId`。 +- 每个媒体结果必须带稳定 `mediaItemId`。 +- `data-canvasMedia` 与 `data-canvasOperation` 如果描述同一媒体,必须共享 `mediaItemId`。 +- 前端维护 `handledCanvasMediaItemIds`,防止重复插入。 +- 后端返回 `createdNodeIdsByMediaItemId`,用于前端建立“媒体结果 -> 画布节点”的映射。 + +2. autosave 冲突策略 + +当前画布保存是整体 `canvasData` PUT。Agent mutation 是服务端直接改 `canvasProject.canvasData`。如果前端仍持有旧快照并触发 autosave,可能把 mutation API 新插入的节点覆盖掉。 + +迁移策略: + +- mutation API 必须基于数据库最新 `canvasData` 应用 operation。 +- mutation 请求带 `baseCanvasRevision` 或 `baseCanvasUpdatedAt`。 +- mutation 响应返回新的 `canvasRevision`、`canvasUpdatedAt`、`createdNodeIds`、`updatedNodeIds`。 +- 前端收到 `data-canvasOperation` 后立即 revalidate 或 patch 本地 store。 +- 在 revalidate/patch 完成前,暂停当前画布 autosave。 +- autosave 请求也必须携带 `baseCanvasRevision`。 +- 如果 autosave 的 base revision 落后,服务端拒绝或要求前端重新拉取,不能用旧整体快照覆盖新画布。 + +落地约束: + +- 第一版 mutation 只做 append-like 操作:`fill_slot`、`insert_beside`、`append_revision`、`link_nodes`,减少 merge 难度。 +- `replace_node_media`、`delete_node`、`move_nodes` 后置,因为这些更容易和用户本地编辑冲突。 +- `syncCanvasNodesForProject()` 只能在合并后的最新 `canvasData` 上执行,不能拿前端旧快照执行。 + +3. slot-only context 策略 + +当前 `buildContextPayload()` 偏图片上下文,若没有有效图片节点容易返回空。Agent Slot 上线后,用户可能只选中一个空 slot,然后说“在这里生成一张图”。这时没有图片,但上下文仍然有效。 + +要求: + +- `CanvasAgentContext` 可以在没有 `canvasImageNodes` 的情况下发送。 +- 只要存在 selected slot、selected text/ui/video node、annotations、viewport intent 或明确视角指令,就不能因为“没有图片”丢弃上下文。 +- legacy `canvasImageNodes` 可以为空数组,但 `canvasAgentContext.selectedNodes`、`slots`、`viewport` 必须保留。 +- 后端 normalize 不能把“无媒体”误判为“无画布上下文”。 +- 澄清门必须把“选中空 slot + 生成意图”视为可执行,不要求用户再上传图片。 + +4. 视角持久化边界 + +协议层的 `CanvasViewOperationPlan` 是当前用户的临时 UI 指令。它不应该因为 Agent 自动聚焦而把项目默认视角永久改掉。 + +但现有 `CanvasState` 中包含 `viewport`,并且项目保存时可能把 viewport 一起写入 `canvasData`。因此实现时要明确区分: + +- 用户手动缩放/拖动画布:可以沿用现有保存行为。 +- Agent 自动聚焦/缩放/平移:默认 transient,不进入 autosave。 +- 若未来要保存“演示视角”或“项目默认视角”,需要独立字段与明确用户动作。 + +实现建议: + +- 增加 `setTransientViewport()` 或 `applyViewOperation({ persist: false })`。 +- autosave builder 跳过最近一次 Agent transient viewport,或记录 `viewportSource = "agent-transient"` 并过滤。 +- `data-canvasViewOperation` 不调用 mutation API。 +- 生成结果节点本身要保存,视角跳转不保存。 + +5. 批量媒体协议 + +现有草案的 `media` 只能表达单个结果,但实际需求包含“再出三版”“图层拆分”“批量改三张图”“多角度生成”等多结果场景。 + +要求: + +- `CanvasOperationPlan` 支持 `mediaItems`。 +- 单图 `media` 仅作为兼容字段,normalize 后统一转成 `mediaItems`。 +- 每个 `mediaItem` 必须有 `mediaItemId`、`type`、`url`、可选尺寸、标题、来源节点、元数据。 +- 多结果布局必须声明 `placementStrategy`,例如 `row`、`grid`、`overlay`、`beside_each_source`。 +- mutation 结果必须返回每个 `mediaItemId` 对应的 `createdNodeId`。 + --- ## 5. 数据协议设计 @@ -428,6 +513,8 @@ export interface CanvasAgentContext { version: 1; source: "canvas" | "realtime-canvas"; projectId?: string; + canvasRevision?: string; + canvasUpdatedAt?: string; viewport?: CanvasViewportContext; selectedNodeIds: string[]; selectedNodes: CanvasAgentNodeRef[]; @@ -438,6 +525,18 @@ export interface CanvasAgentContext { } ``` +`CanvasAgentContext` 不能依赖图片节点是否存在。slot-only、text-only、view-only 请求都必须能形成有效 context。 + +有效 context 判断: + +- 有 selected image/video/text/ui/workflow/model3d/world3d/agent-slot 节点。 +- 有 selected 或 visible slot。 +- 有 annotations。 +- 用户请求是视角操作,例如“放大选中的图”“缩小看全局”。 +- 有 legacy `canvasImageNodes`。 + +只有以上全部不存在时,才返回 `undefined`。 + ### 5.3 CanvasAgentNodeRef ```ts @@ -518,15 +617,33 @@ export type CanvasOperationKind = | "move_nodes" | "update_text"; +export interface CanvasMediaItem { + mediaItemId: string; + type: "image" | "video"; + url: string; + assetId?: string; + width?: number; + height?: number; + title?: string; + prompt?: string; + sourceNodeIds?: string[]; + metadata?: Record; +} + export interface CanvasOperationPlan { version: 1; + operationId: string; operation: CanvasOperationKind; projectId: string; + baseCanvasRevision?: string; + baseCanvasUpdatedAt?: string; sourceNodeIds?: string[]; targetSlotId?: string; anchorNodeId?: string; placement?: "right" | "left" | "below" | "above" | "viewport-center"; + placementStrategy?: "single" | "row" | "grid" | "overlay" | "beside_each_source"; preserveOriginal?: boolean; + // 兼容旧调用;后端 normalize 时统一转成 mediaItems。 media?: { type: "image" | "video"; url: string; @@ -534,13 +651,23 @@ export interface CanvasOperationPlan { width?: number; height?: number; }; + mediaItems?: CanvasMediaItem[]; metadata?: Record; } ``` +批量结果规则: + +- 单媒体:允许传 `media` 或长度为 1 的 `mediaItems`。 +- 多媒体:必须传 `mediaItems`。 +- `mediaItems[].mediaItemId` 必须稳定,供 `data-canvasMedia` 去重和 operation result 映射。 +- “再出三版”使用一个 operation + 三个 `mediaItems`,或三条 operation;首期推荐一个 operation,方便统一布局。 +- “图层拆分”使用 `placementStrategy = "overlay"`,并在每个 `mediaItem.metadata` 中记录 layer role/order。 +- “批量处理三张源图”使用 `placementStrategy = "beside_each_source"`,每个 `mediaItem.sourceNodeIds` 绑定自己的源图。 + ### 5.7 CanvasViewOperationPlan -视角控制不应该混进 `CanvasOperationPlan`。它不修改 `canvasProject.canvasData`,只改变当前用户客户端的 viewport。 +视角控制不应该混进 `CanvasOperationPlan`。Agent 发出的视角操作默认不修改 `canvasProject.canvasData`,只改变当前用户客户端的 viewport。 建议新增: @@ -558,6 +685,7 @@ export type CanvasViewOperationKind = export interface CanvasViewOperationPlan { version: 1; + viewOperationId: string; operation: CanvasViewOperationKind; projectId?: string; nodeIds?: string[]; @@ -565,6 +693,7 @@ export interface CanvasViewOperationPlan { zoom?: number; padding?: number; durationMs?: number; + persist?: false; center?: { x: number; y: number; @@ -583,6 +712,13 @@ export interface CanvasViewOperationPlan { - `zoom_in` / `zoom_out` / `zoom_to`:只调缩放。 - `pan_to`:平移到画布坐标或节点附近。 +持久化边界: + +- `persist` 首期固定为 `false`。 +- 前端执行 view operation 时需要标记 `viewportSource = "agent-transient"`。 +- autosave 不能因为 Agent transient viewport 变化而触发保存。 +- 用户随后手动缩放/拖动画布时,可以恢复现有 viewport 保存行为。 + 第一期推荐只实现: - `focus_node` @@ -730,6 +866,27 @@ return { }; ``` +slot-only / view-only 修正: + +- 不能继续沿用“没有 `canvasImageNodes` 就返回 `undefined`”的判断。 +- `canvasImageNodes` 只代表 legacy 图片输入,不代表完整画布上下文。 +- 选中空 slot 时,`legacyContextPayload.canvasImageNodes = []`,但 `canvasAgentContext.slots` 和 `selectedNodes` 必须存在。 +- 用户只说“放大选中的图 / 缩小看全局”时,即使不涉及媒体生成,也要发送 viewport 和 selection。 +- 后端 `normalizeCanvasAgentContext()` 必须优先读取 `canvasAgentContext`,再 fallback 到 legacy `canvasImageNodes`。 + +推荐判断: + +```ts +const hasAgentContext = + selectedNodes.length > 0 || + slots.length > 0 || + annotations.length > 0 || + Boolean(viewport) || + legacyCanvasImageNodes.length > 0; + +return hasAgentContext ? contextPayload : undefined; +``` + ### 7.2 schema 兼容 修改: @@ -819,7 +976,14 @@ export function resolveCanvasViewOperation(params: { 3. 调用 `setViewport()`。 4. 必要时同步 `selectNodes()`,让用户知道 Agent 聚焦的是谁。 -视角控制不需要保存到数据库,除非后续要做“共享演示视角”。 +Agent 视角控制默认不保存到数据库,除非后续要做“共享演示视角”。 + +当前项目里 `viewport` 属于 `CanvasState`,可能随 `buildPersistedCanvasData()` 被保存。因此本次改造不能直接复用普通 `setViewport()` 后让 autosave 自然触发。建议: + +- store 增加 `setTransientViewport(viewport)`,内部更新视图但标记 `viewportSource = "agent-transient"`。 +- autosave 判断如果最近变更只有 `agent-transient` viewport,则跳过保存。 +- 用户手动 wheel zoom、拖动画布、点击缩放按钮后,恢复 `viewportSource = "user"`。 +- `focusCanvasOnNode()` 可复用计算逻辑,但执行 Agent view operation 时必须走 transient 通道。 --- @@ -837,6 +1001,7 @@ lib/canvas/agent-context.ts - 从旧 `contextPayload` 和新 `canvasAgentContext` 中归一化上下文。 - 兼容没有新字段的前端。 +- 支持没有图片媒体的 slot-only / view-only context。 - 限制节点数量,防止请求过大。 - 提供 helper: - `getSelectedSlots()` @@ -844,6 +1009,13 @@ lib/canvas/agent-context.ts - `getBestAnchorNode()` - `getCanvasAgentFocusPolicy()` +normalize 优先级: + +1. 如果存在 `contextPayload.canvasAgentContext`,优先使用它。 +2. 如果没有新 context,再从 legacy `canvasImageNodes` 构造 `candidateNodes`。 +3. 如果新 context 存在但 `canvasImageNodes` 为空,不允许丢弃 context。 +4. `selectedSlotCount > 0` 时,媒体输入为空也可以继续规划生成任务。 + ### 8.2 新增 mutation 服务 建议新增: @@ -865,13 +1037,38 @@ export async function applyCanvasOperationPlan(params: { 1. 读取 `canvasProject`。 2. 校验 userId 权限。 -3. 校验 source/slot/anchor 节点存在。 -4. 计算插入位置。 -5. 创建或更新节点。 -6. 创建 edge。 -7. 写入 metadata。 -8. 调用 `syncCanvasNodesForProject()`。 -9. 保存 `canvasProject.canvasData`。 +3. 校验 `baseCanvasRevision` / `baseCanvasUpdatedAt`。 +4. 如 revision 落后,拒绝高风险操作;append-like 操作可在最新画布上重新计算位置后继续。 +5. 校验 source/slot/anchor 节点存在。 +6. normalize `media` / `mediaItems`。 +7. 计算插入位置。 +8. 创建或更新节点。 +9. 创建 edge。 +10. 写入 metadata。 +11. 调用 `syncCanvasNodesForProject()`。 +12. 保存 `canvasProject.canvasData` 并生成新 `canvasRevision`。 + +autosave 冲突要求: + +- mutation 服务永远基于数据库最新 `canvasData` 修改。 +- 不接受前端传入的整份旧 `canvasData` 作为 mutation 基础。 +- mutation 结果必须返回最新 revision,前端据此刷新本地 store。 +- 如果同一 `operationId` 重试,服务端必须幂等,不重复创建节点。 +- 服务端可存储或从 metadata 中查找 `operationId`,避免网络重试导致重复写入。 + +首期允许在 revision 不一致时自动合并的操作: + +- `insert_beside` +- `fill_slot`,仅限空 slot 或 append-version slot +- `append_revision` +- `link_nodes` + +首期 revision 不一致时必须拒绝或要求确认的操作: + +- `replace_node_media` +- `delete_node` +- `move_nodes` +- `replace-existing` slot fill ### 8.3 新增 API @@ -893,16 +1090,21 @@ POST /api/canvas/:id/agent-operations { "operationPlan": { "version": 1, + "operationId": "op_...", "operation": "append_revision", "projectId": "...", + "baseCanvasRevision": "...", "sourceNodeIds": ["..."], "anchorNodeId": "...", "placement": "right", "preserveOriginal": true, - "media": { - "type": "image", - "url": "https://..." - } + "mediaItems": [ + { + "mediaItemId": "media_...", + "type": "image", + "url": "https://..." + } + ] } } ``` @@ -914,9 +1116,13 @@ POST /api/canvas/:id/agent-operations "success": true, "data": { "createdNodeIds": ["..."], + "createdNodeIdsByMediaItemId": { + "media-1": "node-1" + }, "updatedNodeIds": [], "createdEdgeIds": ["..."], - "canvasData": {} + "canvasData": {}, + "canvasRevision": "..." } } ``` @@ -980,8 +1186,17 @@ lib/ai/runtime/direct-agent-image-edit-executor.ts ```ts canvasOperationPlan?: CanvasOperationPlan; +canvasOperationPlans?: CanvasOperationPlan[]; +canvasViewOperationPlan?: CanvasViewOperationPlan; ``` +约束: + +- 单结果可以返回 `canvasOperationPlan`。 +- 多批次或多目标可以返回 `canvasOperationPlans`。 +- 单个 operation 内的多媒体结果必须放入 `mediaItems`。 +- executor 生成的 assistant message media 必须带同一个 `mediaItemId`,供旧 `data-canvasMedia` 去重。 + 图片生成: - 有 selected slot:`fill_slot` @@ -1008,14 +1223,42 @@ canvasOperationPlan?: CanvasOperationPlan; app/(chat)/api/chat/route.ts ``` +这是新旧写回迁移的核心位置。当前代码已有旧链路: + +- Chat API stream `data-canvasMedia` +- 前端 `CanvasChatPanel` 收到后调用 `appendStreamMediaToCanvas()` + +V2 新链路是: + +- executor 返回 `CanvasOperationPlan` +- Chat API 调用 `/api/canvas/[id]/agent-operations` 或内部 mutation service +- mutation API 返回 created nodes +- Chat API stream `data-canvasOperation` +- 前端刷新/patch 画布,并用 `handledCanvasMediaItemIds` 忽略同批旧媒体 + 在生成/编辑成功后: 1. 如果请求来自 canvas。 2. 如果有 `projectId`。 3. 如果 executor 返回 `canvasOperationPlan`。 -4. 调用 `applyCanvasOperationPlan()`。 +4. 如果 `canvasAgentWritebackMode = "operation_authoritative"`,调用 `applyCanvasOperationPlan()`。 5. stream 一个 canvas operation chunk 给前端。 -6. 如果需要聚焦新结果或当前编辑对象,额外 stream 一个 canvas view operation chunk。 +6. 把 operation 中所有 `mediaItemId` 加入本次响应的 `handledCanvasMediaItemIds`。 +7. 如果后续仍 stream legacy `data-canvasMedia`,必须附带相同 `mediaItemId`,前端据此跳过自动插入。 +8. 如果需要聚焦新结果或当前编辑对象,额外 stream 一个 canvas view operation chunk。 + +迁移开关: + +```ts +type CanvasWritebackMode = + | "legacy_canvas_media" + | "operation_shadow" + | "operation_authoritative"; +``` + +- `legacy_canvas_media`:保持现状,只发 `data-canvasMedia`。 +- `operation_shadow`:生成 operation 并记录日志,但不写库,仍走旧前端插入。 +- `operation_authoritative`:mutation API 写库,前端忽略同批 `data-canvasMedia`。 建议新增 stream part: @@ -1028,18 +1271,27 @@ payload: ```json { "projectId": "...", + "operationId": "op_...", "operation": "append_revision", "createdNodeIds": ["..."], + "createdNodeIdsByMediaItemId": { + "media_...": "node_..." + }, + "canvasRevision": "...", "anchorNodeId": "...", + "handledCanvasMediaItemIds": ["media_..."], "message": "已放到原图右侧" } ``` 前端收到后: -- mutate 当前画布项目。 -- 或直接 patch 本地 store。 -- 执行 `CanvasViewOperationPlan`,把视角移动到目标节点或选区。 +- 暂停 autosave。 +- mutate 当前画布项目或直接 patch 本地 store。 +- 记录新的 `canvasRevision`。 +- 把 `handledCanvasMediaItemIds` 加入本地去重集合。 +- 恢复 autosave。 +- 执行 `CanvasViewOperationPlan`,把视角移动到目标节点或选区,且视角变化不触发保存。 第一期建议 mutate 重新拉取,稳定优先。 @@ -1053,11 +1305,13 @@ payload: ```json { + "viewOperationId": "view_...", "operation": "focus_latest_result", "projectId": "...", "nodeIds": ["..."], "zoom": 0.9, "padding": 120, + "persist": false, "reason": "聚焦刚生成的新版本" } ``` @@ -1225,9 +1479,13 @@ lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts - 新增 `lib/canvas/agent-context.ts` - 改造 `CanvasChatPanel.buildContextPayload()` - 保持旧字段兼容 +- 支持 slot-only / view-only context +- 新增 `operationId`、`mediaItemId`、`baseCanvasRevision` 类型 - 增加单元测试: - selected image 归一化 - selected slot 归一化 + - slot-only context 不被丢弃 + - view-only context 不被丢弃 - annotations 归一化 - 超量节点裁剪 @@ -1235,6 +1493,8 @@ lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts - 现有画布对话不受影响。 - Chat API 日志能看到 normalized canvas agent context。 +- 选中空 slot 且没有任何图片时,Chat API 仍能收到 selected slot。 +- 用户只发“缩小看全局”时,后端能收到 viewport context。 - 未升级前端仍可调用 Chat API。 ### 阶段 2:Agent Slot 前端能力 @@ -1264,11 +1524,14 @@ lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts - 新增 `lib/canvas/agent-mutations.ts` - 新增 `/api/canvas/[id]/agent-operations` +- 新增 canvas revision / base revision 校验 +- 新增 operation 幂等处理 - 实现: - `insert_beside` - `fill_slot` - `append_revision` - 写入 metadata 与 edges +- 返回 `createdNodeIdsByMediaItemId` - 调用 `syncCanvasNodesForProject()` 验收: @@ -1276,6 +1539,8 @@ lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts - API 能在指定 anchor 右侧插入图片节点。 - API 能填充 slot 并记录 `filledNodeIds`。 - API 能创建 revision edge。 +- 同一个 `operationId` 重试不重复创建节点。 +- stale autosave 不能覆盖 mutation 新增节点。 - 非当前用户无法写入。 ### 阶段 4:Agent 执行链路接入 @@ -1284,16 +1549,21 @@ lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts 任务: -- 扩展 direct executor 返回 `canvasOperationPlan` +- 扩展 direct executor 返回 `canvasOperationPlan` / `canvasOperationPlans` +- executor media 输出带 `mediaItemId` - Chat API 成功后调用 mutation +- 新增 `CanvasWritebackMode` +- 先支持 `operation_shadow`,再开启 `operation_authoritative` - stream `data-canvasOperation` - stream `data-canvasViewOperation` -- 前端收到后刷新画布 +- 前端收到后刷新画布,并记录 `handledCanvasMediaItemIds` 验收: - 选中 slot 后:“生成一张赛博风主图”能填充 slot。 - 选中图片后:“换成白底”能在右侧生成新版本。 +- “再出三版”能生成 3 个节点且不重复插入。 +- 旧 `data-canvasMedia` 和新 `data-canvasOperation` 同时存在时,不出现重复图片节点。 - 原图不被覆盖。 - 画布出现 source -> result 边。 - 生成后视角自动聚焦新结果。 @@ -1306,6 +1576,7 @@ lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts - 新增 `CanvasViewOperationPlan` - 新增 `lib/canvas/view-operation.ts` +- 新增 transient viewport 执行通道 - 前端消费 `data-canvasViewOperation` - 支持: - `focus_selection` @@ -1321,6 +1592,7 @@ lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts - 用户说“缩小看全局”,画布缩放到主要内容范围。 - 生成新图后,画布自动聚焦新结果。 - 用户说“对比原图和新图”,画布缩放到两者的共同包围盒。 +- Agent 自动聚焦不触发 autosave,不改项目保存视角。 ### 阶段 6:标注编辑最小闭环 @@ -1356,10 +1628,16 @@ tests/routes/canvas-chat-helpers.test.ts - canvas agent context normalize - selected slot 优先级 +- slot-only context 保留 +- view-only context 保留 - selected image 优先级 - annotation brief 提取 +- media normalize:`media` 转 `mediaItems` +- 批量 `mediaItems` layout strategy +- `operationId` 幂等 key 生成 - operation plan placement 计算 - view operation viewport 计算 +- transient viewport 不进入 persisted canvasData - focus selection / fit all / zoom in / zoom out - 多选澄清策略 @@ -1378,6 +1656,10 @@ tests/routes/canvas-agent-operations.test.ts - anchor 不存在 400 - fill slot 成功 - append revision 成功 +- 多 `mediaItems` 成功创建多个节点 +- 返回 `createdNodeIdsByMediaItemId` +- 同 `operationId` 重试幂等 +- stale `baseCanvasRevision` 对高风险操作返回冲突 - replace 需要显式 operation ### 14.3 E2E 手测路径 @@ -1394,6 +1676,10 @@ tests/routes/canvas-agent-operations.test.ts 10. 选中一张图,输入“放大选中的图”,验证画布聚焦该图。 11. 输入“缩小看全局”,验证画布缩放到主要内容范围。 12. 输入“对比原图和新图”,验证画布同时框住两个节点。 +13. 选中空 slot 且没有图片,输入“在这里生成一张主图”,验证不被澄清且结果填入 slot。 +14. 输入“再出三版”,验证只出现 3 个新图,不被旧 `canvasMedia` 重复插入成 6 个。 +15. Agent 生成期间触发一次普通 autosave,验证新生成节点不被旧快照覆盖。 +16. Agent 自动聚焦后刷新页面,验证项目默认视角没有被 Agent transient viewport 改掉。 --- @@ -1403,6 +1689,7 @@ tests/routes/canvas-agent-operations.test.ts ```ts canvasAgentOperationProtocolEnabled: boolean +canvasAgentWritebackMode: "legacy_canvas_media" | "operation_shadow" | "operation_authoritative" canvasAgentSlotEnabled: boolean canvasAgentViewOperationEnabled: boolean canvasAgentAnnotationEditEnabled: boolean @@ -1410,12 +1697,14 @@ canvasAgentAnnotationEditEnabled: boolean 灰度顺序: -1. 管理员账号开启。 -2. 内部测试账号开启。 +1. 开启 context protocol,只读不写。 +2. 开启 `operation_shadow`,记录 operation 但仍走旧 `canvasMedia`。 3. 默认开启 slot,但 mutation 写回只对 Agent 模式开启。 -4. 开启视角操作能力,只影响当前用户客户端。 -5. 全量开启非破坏式 `insert_beside` / `append_revision`。 -6. 最后开放 `replace_node_media`。 +4. 开启 mutation API,仅内部用户。 +5. 切到 `operation_authoritative`,前端按 `mediaItemId` 去重旧 `canvasMedia`。 +6. 开启视角操作能力,只影响当前用户客户端,且不持久化 Agent transient viewport。 +7. 全量开启非破坏式 `insert_beside` / `append_revision`。 +8. 最后开放 `replace_node_media`。 --- @@ -1462,6 +1751,22 @@ canvasAgentAnnotationEditEnabled: boolean - 用户正在拖拽、框选、编辑文本时不强制切换视角。 - 提供前端开关 `canvasAgentViewOperationEnabled` 独立灰度。 +### 16.6 旧 `canvasMedia` 与新 operation 重复插入 + +规避: + +- 引入 `canvasAgentWritebackMode` 分阶段迁移。 +- `operation_authoritative` 模式下,前端按 `mediaItemId` 忽略同批 legacy media。 +- E2E 必测“再出三版”不会插入 6 张图。 + +### 16.7 autosave 覆盖 Agent mutation + +规避: + +- mutation 与 autosave 都携带 base revision。 +- 前端收到 operation 后暂停 autosave 并 revalidate。 +- stale autosave 不允许覆盖数据库最新 `canvasData`。 + --- ## 17. 推荐首个 PR 切分 @@ -1471,6 +1776,8 @@ canvasAgentAnnotationEditEnabled: boolean - 新增协议类型 - 扩展 schema - 前端输出 `canvasAgentContext` +- 修复 slot-only / view-only context +- 增加 `mediaItems`、`operationId`、`mediaItemId`、`baseCanvasRevision` - 后端 normalize - 测试 normalize @@ -1485,23 +1792,36 @@ canvasAgentAnnotationEditEnabled: boolean - 新增 mutation service - 新增 API route +- revision 冲突检测 +- operation 幂等 +- 批量 `mediaItems` - 支持 `insert_beside` / `fill_slot` / `append_revision` - API 测试 -### PR 4:Agent 生成写回 +### PR 4:新旧写回迁移 + +- 新增 `canvasAgentWritebackMode` +- 支持 `operation_shadow` +- legacy `data-canvasMedia` 带 `mediaItemId` +- 前端 `handledCanvasMediaItemIds` 去重 +- 验证不开重复节点 + +### PR 5:Agent 生成写回 - direct executor 生成 operation plan - Chat API 调 mutation - 前端刷新画布 +- `operation_authoritative` 模式写回 -### PR 5:画布视角控制 +### PR 6:画布视角控制 - 新增 view operation 类型 - 新增 view operation resolver - Chat API stream `data-canvasViewOperation` - 前端执行聚焦、缩放、看全局 +- transient viewport 不触发 autosave -### PR 6:标注编辑 MVP +### PR 7:标注编辑 MVP - annotation metadata - 图片节点批注入口