agent-transactionCommand 描述作品发生什么变化,Transaction 定义用户心智中的“一步”,History 保存可逆的真实 Diff。鼠标、属性面板和 Agent 不应该各写一套修改逻辑。
学习目标
- 设计 create/update/delete 三种可校验 Command;
- 用 before/after 构造可逆 Transaction;
- 理解实时预览、Document 更新和 History 粒度的区别;
- 处理 revision 冲突、actionId 幂等和原子失败;
- 用步数与字节预算治理 History。
1. 统一写入链路
鼠标操作 ─┐
属性面板 ─┼→ CanvasCommand[] → Transaction → Document → Derived State → Renderer
AgentPlan ─┘
Derived State 包括 SpatialIndex、Working Set、持久化 Journal 和协作 Diff。它们消费同一个已提交事务,而不是监听各个 UI 入口猜测变化。
2. Command 只描述业务变化
type CanvasCommand =
| { type: 'create'; node: CanvasNode }
| { type: 'update'; id: string; patch: NodePatch }
| { type: 'delete'; id: string }
Command 里没有 new Konva.Rect()、context.fillRect() 或 HTMLElement。Renderer 如何显示,是提交后的另一层职责。
执行前需要验证:ID、节点类型、允许字段、坐标范围、数量限制、关系引用和宿主业务不变量。
3. before / after 让操作可逆
| 操作 | before | after |
|---|---|---|
| create | null |
完整新节点 |
| update | 完整旧节点 | 完整新节点 |
| delete | 完整旧节点 | null |
interface Change {
id: string
before: CanvasNode | null
after: CanvasNode | null
}
interface Transaction {
actionId: string
label: string
revisionBefore: number
revisionAfter: number
changes: Change[]
}
Undo 反向应用 before,Redo 正向应用 after。History 不必理解“排列”是什么,只需要恢复真实变化。
4. 为什么三条 Command 只是一条历史
“水平排列 A、B、C”会产生三条 update,但用户认为它是一次任务:
Transaction:水平排列三张卡片
├─ A before → after
├─ B before → after
└─ C before → after
点击一次 Undo,三张卡片整体回到原位。若每条 Command 独立入栈,用户要按三次 Undo,Agent 的一次任务也可能只撤回一半。
5. 一次拖动为什么只有一条 History
pointerdown
→ 保存 transaction before
pointermove × 120
→ 实时更新 Document
→ 更新 SpatialIndex 和当前 Handle
→ History 新增 0
pointerup
→ 读取 after
→ 提交 1 条 Transaction
实时更新 Document 能保证吸附、连线和命中读取当前坐标;不为每次 move 写历史,能保证一次手势只需一次 Undo。
错误方式是每次 pointermove 复制完整大 Document。若 6,000 节点快照约 1.1 MiB,120 次 move 会制造约 132 MiB 级别的重复数据和大量 GC。
6. 原子执行
Transaction 必须全成或全败:
预检全部 Command
→ 在草稿/事务 Store 中应用
→ 校验最终 Document 不变量
→ 一次 commit revision
→ 产生 changes
→ 通知 Index/Renderer/Persistence
若第 3 条 Command 失败,前两条不能留在 Document。不要边遍历边修改正式 Store,再试图手工回滚未知副作用。
7. 并发 revision
Planner 读取 revision 41 后,用户可能又做了一次修改,Document 已是 42。此时旧计划必须拒绝:
if (expectedRevision !== document.revision) {
throw new RevisionConflict(expectedRevision, document.revision)
}
调用方应重新读取上下文并 rebase,而不是覆盖新状态。Revision 不是锁住整个 UI,而是防止基于旧世界的计划静默写入。
8. actionId 幂等
网络超时可能发生在“服务端已提交、调用方未收到响应”。重试相同事务时:
- 相同
actionId+ 相同输入:返回第一次结果,不重复执行; - 相同
actionId+ 不同输入:拒绝,防止 ID 复用掩盖错误; - 新业务意图:使用新 actionId。
可以保存输入摘要和 commit result。幂等不能只按 label 判断。
9. Undo/Redo 如何更新全链路
Undo
→ 反向应用 changes.before
→ 更新 Document revision
→ 增量更新 SpatialIndex
→ 更新/销毁当前 Renderer handles
→ 原事务进入 Redo Stack
Redo
→ 应用 changes.after
→ 同步相同派生层
只移动 Konva Node 不是真正 Undo。Camera 通常不进入作品 History;若需要返回上个视角,应单独设计 View History。
10. History 也需要预算
maxTransactions:最多保留多少步
maxHistoryBytes:before/after 合计字节预算
mergePolicy:连续输入或拖动如何合并
删除图片节点时,History 只保存 node + assetId,不复制 Blob。但资产 GC 必须考虑 Undo/Redo 是否仍能恢复它。
History 是可裁剪的编辑能力,不是作品真相。快照损坏或 revision 不匹配时可以丢弃,Document 仍要保留。
11. 操作 Demo
- 点击“排列三张卡片”,观察三条 update 只形成一个 Transaction;
- 一次 Undo/Redo 验证三节点整体变化;
- 修改 B 颜色,Undo 后执行另一操作,观察旧 Redo 分支清空;
- 模拟相同 actionId 重试,确认 revision 只增加一次;
- 用旧 expectedRevision 提交,确认无部分写入;
- 刷新后从 Workspace 恢复 History,继续 Undo。
12. 验收标准
一次用户/Agent 意图 = 一个原子 Transaction
失败事务不产生部分 Document 变化
Undo/Redo 同步 Document、Index、Renderer
旧 revision 被明确拒绝
相同 actionId 不重复执行
History 受步数和字节预算约束
Camera/hover/播放器运行态不污染作品 History
常见错误
- UI、Agent、脚本分别直接改 Store;
- 每条批量 Command 独立入栈;
- 每个 pointermove 保存完整快照;
- 只在 Renderer 上 Undo;
- revision 冲突时强行覆盖;
- actionId 重试重复创建节点;
- History 里复制 Blob 或运行时对象;
- 删除节点立即永久清理仍被 History 引用的素材。
思考题
Agent 一次创建 100 个节点,第 73 个因坐标越界失败。你会选择先执行再回滚,还是在草稿事务中预检并一次提交?分别列出对 SpatialIndex、Asset 引用和 History 的影响。