Agent 图片节点写入:从画布可撤销到生成结果落地可靠性
结论先行:#5143 没有重新设计同步或历史系统,而是把既有的“服务端提交 forward delta、通过 Agent WebSocket 广播 inverse delta、客户端只记录 history”的 seam 补齐到 insert_image_node / update_image_node 。因此 A…
source_automation: “voyager-merged-pr” run_date: “2026-07-24” anchor_pr_number: 5143 pr_number: 5143 pr_title: “Add undo support to image node insert/update tools” pr_url: “https://github.com/adastralab-ai/voyager/pull/5143 ↗” author: “Jason Tan / banchichen” merged_at: “2026-07-24T04:35:50Z” merge_commit: “231fca9dca350ab271817db690d1ee3e888cc111” main_sha_at_read: “383e57fe2d4b42e5736c605a37e1f07c931fdff0” modules:
- “backend/workers/agent/src/tools”
- “backend/workers/agent/src/agent.ts”
- “backend/workers/agent/src/designAction.ts”
- “backend/workers/agent/src/imageFetchRetry.ts”
- “backend/workers/agent/src/skills/generate-image”
- “packages/common/src/agentChat”
- “packages/site/src/app/(main)/_agent”
- “packages/site/src/app/(main)/files/[id]”
- “packages/design/src/history” files_changed: 5 learning_tags:
- “agent-runtime”
- “image-node-tools”
- “page-delta”
- “undo-redo”
- “inverse-delta”
- “websocket-protocol”
- “design-editor”
- “image-placement”
- “provider-middleware”
- “reliability”
- “test-strategy” business_line: “Agent 图片生成/编辑结果的可逆画布落地与可靠性” related_prs: [4180, 4307, 5147] line_stage: “专用 image-node 写入与显式 bounds (#4307) -> 通用 Agent 画布逆向 delta seam (#4180) -> 图片节点 Undo/Redo 补齐 (#5143) -> 预览资源冷转换重试 (#5147)” open_questions:
- “page.tsx 只在 getEditorStates()[pageId] 存在时记录 agent-page-edit;页面未加载、重载或聊天与编辑器生命周期错开时,持久化写入仍成功但本地 Undo 记录会丢失,这是否应保持为当前打开编辑器的范围边界。”
- “agent-page-edit 的 undoDelta 在协议中仍是 unknown[],没有共享 Delta schema 或客户端语义校验;跨版本部署时如何拒绝不兼容的历史项。”
- “一个 turn 内多次 image-node tool 调用会产生多个独立 history item,且逆向 delta 基于工具读取的 prevValue;需要明确按 tool、turn 还是事务合并,并验证并发协作者更新下的逆向 delta。”
- “#5147 通过 HTTP 400 的错误字符串识别 preview fetch timeout;provider 文案变化会使重试失效,是否能补结构化错误码。”
- “显式 bounds 把布局责任交给 skill/model,工具只校验正数尺寸;是否需要服务端增加页面边界、重叠或超大 bounds 的产品约束。” feishu_doc_url: null github_path: “outputs/voyager-daily-pr-study/2026-07-24-pr-5143-agent-image-node-undo-reliability.md”
结论先行:#5143 没有重新设计同步或历史系统,而是把既有的“服务端提交 forward delta、通过 Agent WebSocket 广播 inverse delta、客户端只记录 history”的 seam 补齐到
insert_image_node/update_image_node。因此 Agent 生成或替换 DESIGN 页图片后,正常的 Undo/Redo 可以覆盖图片节点;Go 仍是唯一写入者,客户端不会把同一变更再提交一次。与此同时,#5147 在更早的模型输入边界为 preview URL 冷转换增加有界重试,解决“图片已生成并计费,但模型在真正落到画布前因预览抓取 400 失败”的另一窗口。两者共同提高了 Agent 图片结果的可见落地率,但不是同一个故障的两种修复。
业务线概览#
这条线解决的是用户让 Agent 处理图片时的两个连续承诺:
- 用户说“生成一张图并放到当前设计页”或“替换这个图片”时,Agent 应该改动一个明确的 image node,而不是把整页 HTML 重写成不可控的布局;
- 这次自动写入应该像用户手工编辑一样可撤销、可重做,同时不能因预览 URL 的一次性冷转换失败而让已经生成的结果消失在聊天与画布之间。
本次范围集中在已有 DESIGN 页的图片节点插入/替换/移动,以及生成结果进入模型和画布的可靠性边界。不扩展到 DOC 图片、CODE 页面、复杂海报布局、删除操作或账单系统。update_image_node 只接受已知 node id,insert_image_node 只接受已有 asset 和显式 bounds;复杂页面仍走其他 creation/edit 工具。
围绕锚点,正式计入的主线 PR 是:
- #4307 ↗:建立专用 image-node 工具和“图片生成结果落到当前 DESIGN 页”的产品边界;
- #4180 ↗:建立 Agent 画布编辑的 inverse-delta history 通道,但当时明确没有覆盖新 image-node 工具;
- #5143 ↗:把这两条基础在图片节点上接通;
- #5147 ↗:同一天稍后的 preview URL timeout 重试,补上生成结果进入 model turn 前的资源可靠性。
今日锚点#
- PR:#5143 Add undo support to image node insert/update tools ↗
- **作者:**Jason Tan / banchichen
- **Merge:**2026-07-24T04:35:50Z,即 Australia/Melbourne 2026-07-24 14:35:50 AEST
- Merge commit:
231fca9dca350ab271817db690d1ee3e888cc111 - **改动规模:**5 个文件,57 additions / 13 deletions;2 个实现文件、1 个工具装配文件、2 个单元测试文件。
- **当前主线:**锚点 merge commit 已可达
origin/main;本次最终读取的origin/main为383e57fe2d4b42e5736c605a37e1f07c931fdff0。
选择 #5143 是因为它是 7 月 24 日本地日期内已经合入、且尚未作为锚点学习过的直接行为修复。Issue #5139 ↗ 已把根因定位到:图片工具只走远程同步,HistoryManager 会忽略 SOURCE_REMOTE,而没有像 update_elements 一样广播逆向 delta。#5143 的 diff 正好把这个已验证根因映射为最小修复,并为 insert/update 的成功与失败路径补断言。
演进时间线#
| 阶段 | PR | 改变的层 | 代码证据与新增能力 |
|---|---|---|---|
| 专用图片写入入口 | #4307 ↗,2026-07-04T03:36:40Z | Agent prompt/skill、tool registry、Design delta | 将“生成资产”和“放到当前页”拆开,新增 insert_image_node / update_image_node;仅对 DESIGN 页做 node-level delta,并把这类 tool 计入 designAction = edit。最终合入的分支代码要求调用方提供显式 bounds,避免工具层默认 center 导致多图重叠。 |
| 通用可撤销 seam | #4180 ↗,2026-07-05T12:35:35Z | Agent worker、WS broadcast、file/page history | update_elements / update_design 在 Go 提交成功后广播 inverse delta;客户端按 pageId 记录 file history。Go 仍是唯一 writer,远程同步不会被客户端再次提交。该 PR 的范围说明明确把 image-node 工具留到后续。 |
| 当前锚点:图片 Undo 补齐 | #5143 ↗,2026-07-24T04:35:50Z | image-node tool builder、Agent broadcast、unit tests | insert_image_node 广播 {removeNode};update_image_node 通过 getDelta(nextValue, prevValue) 反向恢复 asset、bounds、crop;只有 submitPageDelta 成功后才广播。 |
| 并行/后续可靠性 | #5147 ↗,2026-07-24T05:54:24Z | AI SDK model middleware、provider error boundary | 对 provider 在首个输出 token 前因 preview URL timeout 返回的特定 400 做最多 2 次重试;不重跑已完成的 image tool,不重复计费,并记录 agent.image_fetch_retry。它修的是“还没进入 page delta 前”的窗口,不替代 #5143 的 history 修复。 |
非主线分支说明:#4307 的 review follow-up #4312 ↗ 将 insert_image_node 的 placement fallback 收紧为必填 bounds,但其 base 是 zh-agent-image-node-tools,merge commit 不在 origin/main。因此本报告不把 #4312 计入 related_prs;它的最终代码状态随 #4307 的 feature branch 一并进入主线,只作为实现证据引用。
**当前阶段判断:**这条线已经完成了从“Agent 可以生成图片”到“Agent 可以把图片以小范围 delta 写入设计页并被用户撤销”的闭环;资源冷转换失败也有了保守恢复。尚未完成的是跨 WebSocket、file history、editor history 的真实回归,以及并发、生命周期和协议版本边界。
当前架构与数据流#
flowchart LR
U["编辑器内 Agent 聊天"] --> C["AgentChatContext WebSocket"]
C --> W["Agent Durable Object / agent.ts"]
W --> T["insert_image_node 或 update_image_node"]
T --> G["Go API submitPageDelta"]
G --> R["持久化 forward delta + 远程同步"]
R --> S["Design store SOURCE_REMOTE"]
W --> I["agent-page-edit: pageId + inverse delta"]
I --> P["parseAgentBroadcast"]
P --> H["fileHistory.record(pageChange)"]
H --> X["HistoryManager.apply / SOURCE_HISTORY"]
A["image_generation assetId + preview"] --> T
A --> M["provider model input"]
M --> Q["#5147 wrapStream: preview timeout retry"]plaintext- **用户入口与上下文:**编辑器把当前
pageId、选择和 viewport 放进 chat context。EditorChatSidebar将onAgentPageEdit传入AgentChatProvider;Agent worker 在构造 tools 时同时保留当前 view 和broadcastPageEdit回调。 - 资产与写入:
image_generation先得到 durableassetId。#4307 建立的专用工具通过 Go 读取 asset/page,验证 asset 是 image、page 是 DESIGN,然后构造addNode或patchNodedelta。显式 bounds、natural size 和 crop 计算留在 tool 层,避免调用方退回整页 HTML 重写。 - **唯一写入者:**工具调用
submitPageDelta把 forward delta 交给 Go。客户端从正常 remote sync 收到变化;这条路径不直接触碰客户端 history,也不让客户端再次提交 forward delta。 - **本地 Undo 通道:**提交成功后 worker 在同一 Agent WS 广播
agent-page-edit。客户端过滤chatId,把pageId和 inverse delta 交给 file page;file page 只有在对应 editor state 已加载时才写入fileHistory。Undo 时HistoryManager应用 inverse delta,并以SOURCE_HISTORY标记,避免重新记录。 - 预览资源可靠性:#5147 将 AI SDK 的 model 包在
wrapLanguageModel中。provider 若在 0 output token 的输入阶段因download+timed out/timeout返回 400,middleware 重新doStream(),最多两次;其他 400、abort 或已产生输出的错误不应被这个分支吞掉。
关键代码#
1. #4307:用专用 image-node delta 限定写入范围#
来源:#4307 ↗ 的最终合入 diff,backend/workers/agent/src/designAction.ts:3-14、backend/workers/agent/src/tools/insertImageNodeTool.ts。
if (
tools.has("update_design") ||
tools.has("update_elements") ||
tools.has("update_image_node") ||
tools.has("insert_image_node")
) {
return "edit";
}plaintext这段不负责写入,但把新工具纳入 Agent 设计行为分类,避免图片节点编辑在观测上退化成普通 chat/design。真正的写入路径先读取完整 page value,再将单个节点转成 addNode 或节点 patch;update_image_node 不接受未知 node id,也不允许借此删除、调整复杂布局或重写整页。
2. #4307 的显式 bounds:把布局责任从工具 fallback 推回调用方#
来源:#4307 ↗ merge diff 中的 insertImageNodeTool.ts / toolDefs.ts;该最终状态由其非主线 review follow-up #4312 形成,但 #4312 不计入主线 PR 清单。
const delta: Delta = [{ action: "addNode", node }];
await submitPageDelta(go, { pageId, signal: abortSignal }, delta);plaintextbounds schema 要求正数 width/height,工具只做三位小数归一化和 aspect-ratio crop。这样多图 row/grid 的位置由 skill/model 明确决定,工具不再静默用同一组 center bounds 覆盖前一张图;代价是模型可能提交越界或互相重叠的合法 bounds,当前没有服务端几何约束。
3. #4180:先持久化 forward,再广播 inverse#
来源:#4180 ↗ diff;当前复用 seam 位于 backend/workers/agent/src/tools/updateElementsTool.ts:82-100。
const nextValue = { nodes: nextNodes, attributes: prevValue.attributes };
const delta = getDelta(prevValue, nextValue) ?? [];
await submitPageDelta(go, { pageId, signal: abortSignal }, delta);
broadcastPageEdit(pageId, getDelta(nextValue, prevValue) ?? []);plaintext这里的关键不是“让客户端知道 forward delta”,而是让服务端完成唯一写入后,把可直接交给 history 的 reverse delta 送回发起 chat。#4180 的设计理由是:history 订阅层看不到通用 sync delta id,按 id 认领会把修改扩散到 sync 引擎;广播逆向 delta 只增加 Agent chat 协议,不改变 Go writer 和 remote sync。
4. #5143:insert 和 update 使用不同但可回放的逆向表达#
来源:#5143 ↗ diff,backend/workers/agent/src/tools/insertImageNodeTool.ts:102-108、updateImageNodeTool.ts:127-133。
// insert_image_node
await submitPageDelta(go, { pageId, signal: abortSignal }, delta);
const undoDelta: Delta = [{ action: "removeNode", id: nodeId }];
broadcastPageEdit(pageId, undoDelta);
// update_image_node
await submitPageDelta(go, { pageId, signal: abortSignal }, delta);
broadcastPageEdit(pageId, getDelta(nextValue, prevValue) ?? []);plaintext插入只需要移除刚生成的 node id;替换或移动则复用 Design value 的 diff 计算,恢复旧 asset、bounds 和 crop。两个实现都把广播放在 awaited Go 写入之后,失败、非 DESIGN 页、非 image asset、未知 node 或空 delta 都不会产生伪造的 history item。
5. client 协议:远程同步和本地 history 是两条有意分离的通道#
来源:packages/common/src/agentChat/protocol.ts:75-99、packages/site/src/app/(main)/_agent/chat/_components/AgentChatContext.tsx:849-861、packages/site/src/app/(main)/files/[id]/page.tsx:673-684。
// common protocol
z.object({
type: z.literal("agent-page-edit"),
chatId: z.string(),
pageId: z.string(),
undoDelta: z.array(z.unknown()),
});
// file page
fileHistory.record({
type: "pageChange",
pageId,
value: { delta: undoDelta, selection: null },
});plaintextHistoryManager 当前明确跳过 SOURCE_REMOTE,而 apply 使用 SOURCE_HISTORY;因此 remote echo 负责把 forward state 带到编辑器,Agent broadcast 负责补一条只属于发起端的可撤销记录。handleAgentPageEdit 还用 editor state 是否存在作为生命周期边界,这正是“当前打开编辑器可撤销”与“跨 reload 仍有持久 Undo”之间的差异。
6. #5147:只在无副作用的 model 输入窗口重试#
来源:#5147 ↗ diff,backend/workers/agent/src/imageFetchRetry.ts:7-39、agent.ts:1290-1303。
if (attempt > maxRetries || !isImageFetchTimeoutError(error)) throw error;
options?.onRetry?.({ attempt, error });
// next loop: return await doStream()plaintextPR 的代码注释和 body 都把安全前提写清楚:这个 provider 400 发生在输出 token 之前,image generation tool 尚未重跑,所以重试不会重复扣费。实现没有把所有 400 都变成 retryable,而是以 APICallError.statusCode === 400 加错误文本判断,并将每次重试打到 agent.image_fetch_retry,便于之后替换成结构化错误码。
工程取舍#
边界与复用#
- 复用已有 delta/history seam:#5143 没有新建 image-specific undo API,而是让两个 image-node builder 接受与
update_elements相同语义的broadcastPageEdit。这保持 Go 单写、正常 remote sync 和 file history 的职责边界。 - 专用工具优于整页 HTML:#4307 用已知 page/node/asset id 和显式 bounds 把图片写入收敛到一个节点,减少模型重写整页导致的其他节点删除、布局漂移和不可逆历史项。
- **逆向 delta 优于客户端重提交:**客户端只记录逆向操作,Undo/Redo 仍由现有
HistoryManager计算互反项;不会因一次 Agent 写入在客户端和 Go 各提交一遍而产生重复变更。
兼容性与可靠性#
- #5143 的 forward delta 仍沿用已有
submitPageDelta,对其他协作者和普通 remote sync 的行为没有新增协议要求;新增的agent-page-editenvelope 对旧客户端的主要风险是无法消费 inverse,而不是 forward 数据无法持久化。 update_image_node只恢复它读取到的旧 asset/bounds/crop;这比保存整页快照更小,但也意味着并发修改、多个连续工具调用和失序 broadcast 需要依赖当前 page-delta/history 语义验证。- #5147 选择最多 2 次、只匹配具体错误,是“有界恢复”而不是通用重试。冷转换跨 colo 缓存导致预热不可依赖,因此重试发生在 provider 实际抓取所在的 colo;根治的静态 R2 物化被明确留在范围外。
测试策略#
- #4307 增加
insertImageNodeTool/updateImageNodeTool单元测试、designAction测试、prompt/skill 测试,并覆盖 DESIGN/non-DESIGN、asset 类型、bounds/crop 和工具轨迹。 - #5143 在两个工具测试中验证成功后 broadcast 的 reverse delta,以及失败/跳过时不 broadcast;这能保护“只有持久化成功才进 Undo”的局部契约。
- #4180 的验收重点是编辑器内手工 Cmd/Ctrl+Z、Redo、跨 tab 和全局
/ai/新建页边界;#5147 的验收重点是线上 retry 日志和多图生成落地。 - 目前没有看到一条真实的
provider/model -> Agent WS -> parseAgentBroadcast -> fileHistory -> HistoryManager.apply -> Go sync集成回归。现有测试可以证明 callback 参数和 delta 形状,却不能证明浏览器里的 Undo/Redo 用户路径完整闭环。
和最近学习记录的关系#
- 上一篇 #5107 Agent eval 与生产 turn 的提示一致性和成本可观测性 关注“评估是否测到了生产边界”;本篇关注生产 turn 真正写入画布后是否可恢复。共同方法是先找 source of truth 和跨层契约,再讨论功能表面。
- #4720 ↗ 与 #4895 ↗ 的学习记录分别处理 durable assetId/replay-time URL 和 replay burst;#5147 可看作同一可靠性思路在 provider 首次抓取冷资源时的延伸,但它不改变 transcript 或工具幂等性。
- #4598 CODE undo/redo 也使用 source/delta/history 语言,但 CODE 的
localDelta与 iframe/source rewrite 边界不同;不能因为都叫 Undo 就直接复用实现或测试结论。
我会怎么吸收#
- **给每个新的 Agent mutation tool 画完整四段链路:**forward persistence、remote echo、local history、redo reverse;没有明确四段,就不要把“写入成功”当成用户可恢复。
- **把“谁写 forward、谁只记录 inverse”写进接口契约。**让 Go/API 保持唯一 writer,客户端只消费 inverse history,能显著降低多 tab 和协作者场景的重复提交风险。
- **把模型可推理的范围压进工具 schema。**已知 id、显式 bounds、正数尺寸和明确的非目标操作,比在 tool 内做隐式 center/fit fallback 更容易测试和审计。
- **只在副作用边界之前重试。**先证明“0 output、0 tool、0 charge”,再对 provider 的暂时性输入失败做有限次数重试;同时记录 retry event,避免把所有 400 变成无界重放。
- **为跨 runtime seam 增加一条真实回归。**单测可以锁 inverse delta,但仍需要至少一个浏览器/组件级测试验证 WS envelope 到 page history、Undo、Redo 和 remote sync 的顺序。
边界、风险与未解问题#
已由代码验证的边界#
insert_image_node/update_image_node只写 DESIGN 页;update 只接受 rect image node,工具描述明确排除删除、复杂布局和整页改写。insert_image_node的逆向操作是删除新 node;update 的逆向操作来自getDelta(nextValue, prevValue),包含 asset、bounds 和 crop 的恢复。- 广播发生在
submitPageDeltaawait 成功之后;非 image asset、非 DESIGN、未知 node、空更新和异常路径不会主动生成 inverse history。 HistoryManager跳过SOURCE_REMOTE,客户端通过fileHistory.record注入的 page change 才能进入可撤销链路。
仍待确认的风险#
- 编辑器生命周期:
handleAgentPageEdit找不到getEditorStates()[pageId]就直接 return。跨页、重载、编辑器卸载或广播晚于页面切换时,forward 数据仍在服务端,但当前客户端没有 Undo item。 - 协议类型:
undoDelta是unknown[],没有把 DesignDelta的 action、node id 和 patch 结构纳入 common schema。部署前后版本错配时,错误可能延后到 apply 阶段才暴露。 - **事务与并发:**一次 turn 的多个工具调用会分别广播;逆向 delta 使用每次读取的快照。需要真实并发与多 tool trajectory 验证 history 顺序、冲突和 redo 语义。
- 重试识别:#5147 的字符串匹配是明确承认的临时契约;provider 改错误文案会降低恢复率,监控只能发现命中率变化,不能从类型系统阻止漂移。
- **布局约束:**显式 bounds 避免了工具 fallback 的重叠,但不阻止越界、极大尺寸和模型误算的多图布局。当前是 prompt/skill 约束,不是后端不变量。
候选说明#
- 本次先查 Australia/Melbourne 2026-07-24 的已合入记录;当日已有 #5143、#5147、#5152、#5157、#5128、#4804、#5135 等可达
origin/main的候选,因此没有扩大到 7 月 23 日。 - #5143 未出现在既有
study-log.jsonl的 anchor PR 中;#4180、#4307 和 #5147 作为背景/相关 PR 使用,没有把旧的 Agent runtime 学习记录重复当作今日锚点。 - #5157 / #5128 属于 export job / asynchronous export 线,且最近已有 #4938 PNG export scale 报告;#5141 是品牌描述文案修复,#5150 是 UI token 微调,#5152 是移动聊天 handoff。它们没有比 #5143 更自然地形成本次图片节点 Undo 业务线。
- #4312 的 base 不是
main,merge object 也不在origin/main,所以只说明为 #4307 的 feature-branch review evidence,不计入正式相关 PR 数量。
GitHub 文档#
- 报告文件:
outputs/voyager-daily-pr-study/2026-07-24-pr-5143-agent-image-node-undo-reliability.md - 锚点 PR:#5143 ↗
- 相关 PR:#4180 ↗、#4307 ↗、#5147 ↗
- **Voyager 代码证据 commit:**锚点
231fca9dca350ab271817db690d1ee3e888cc111;本次读取的origin/main为383e57fe2d4b42e5736c605a37e1f07c931fdff0。 - **持久化位置:**本报告只写入
joyehuang/ai-agent-field-notes;feishu_doc_url固定为null,没有创建或更新外部学习文档。