0
0

给前端 Agent 加撤销重做时,我会先把命令栈和副作用账本拆开

最近在做一个 Web 端 AI 工作台时,我给 Agent 加了一层撤销重做能力。最早的需求听起来很简单:用户让 AI 改配置、补表单、调整组件树,按一次撤销就能退回上一步。真正落到工程里后,我发现麻烦点在于 Agent 的一步操作经常跨过 UI 状态、草稿缓存、接口请求和本地队列。如果只把页面 state 往回放,很容易出现界面退回去了,服务端草稿、预览任务或待发送 outbox 还停在新版本的情况。

前端 Agent 可撤销操作的命令栈、反向补丁和副作用账本流程图

原创示意图:把用户意图、状态补丁、反向补丁、副作用命令和回放审计分层处理。 来源:Codex image generation

问题背景

普通编辑器里的 undo/redo 通常围绕 past、present、future 三段状态展开。Redux 文档里的 undo history 思路很适合做心智模型:每次提交把当前状态推入 past,撤销时再把 present 移入 future。前端 Agent 场景多了一层不确定性,用户看到的是一句自然语言指令,底层可能被拆成多次字段修改、一次组件树 patch、一次预览接口调用和一次索引更新。

我这次把源头定在“用户意图”上。每个 Agent 操作先生成一个 operationId,记录原始指令、目标区域、输入快照 hash 和预期输出类型。随后再把它拆成可纯函数回放的 state patch,以及需要额外确认的 side effect command。这样做的好处是排查时能回答一个很具体的问题:当前屏幕来自哪个意图,哪几个补丁生效过,哪些副作用已经提交。

踩坑和关键难点

第一个坑是深拷贝快照太随意。structuredClone 能处理很多结构化数据,但函数、DOM 节点、不可序列化引用都不应该进入可撤销状态。我的做法是在 Agent 输出落地前先过一层 plain data schema,只允许 JSON 友好的字段进入命令栈,组件实例、AbortController、请求句柄一律放在运行时上下文里。

第二个坑是把副作用混进状态补丁。Immer 的 patches 和 inverse patches 很适合描述对象状态变化,但它们不负责取消接口请求、回滚文件写入或撤销已发出的异步任务。这里我单独建了 sideEffectLedger,每条记录包含 operationId、commandType、idempotencyKey、status、rollbackHint 和外部引用。UI 撤销只会先应用 inverse patch,再检查账本里是否存在需要补偿的副作用。

第三个坑是重做链被新操作污染。用户撤销两步后,如果又让 Agent 做了一次新修改,旧的 future 必须失效。否则重做会把旧上下文里的补丁套到新状态上,轻则覆盖用户刚改的字段,重则触发错误的外部命令。我在每次新操作提交时都写入 baseHash,回放前先比对当前 state hash,不一致就进入人工确认面板。

解决思路

我最后采用四层结构:intent log 记录用户要做什么,patch stack 记录状态怎么变,inverse stack 负责撤销,side-effect ledger 记录外部世界发生了什么。Agent 生成结果后,先在内存沙箱里应用 patch,校验 schema 和 baseHash,再把 patch 与 inverse patch 一起提交。副作用命令只入队,不直接执行;执行器拿到命令后再根据 idempotencyKey 做去重和状态推进。

TanStack Query 的 optimistic updates 文档里有一个值得借鉴的点:乐观更新前保存上下文,失败后用上下文回滚。我的撤销链也按这个原则处理,每次本地应用 patch 前都保留最小回滚上下文,失败时先恢复 present,再把 operation 标记为 rejected,避免用户误以为操作已经进入可重做队列。

关键步骤

第一步是定义可撤销边界。只有纯状态修改进入 patch stack,例如表单字段、组件属性、筛选条件、草稿块顺序。网络请求、文件导出、通知发送、索引刷新都进入副作用账本。

第二步是给 Agent 输出加收口协议。每次输出必须包含 operationId、baseHash、patches、inversePatches、sideEffects 和 summary。前端只接受协议内字段,缺少 inversePatches 时直接降级为预览,不进入命令栈。

第三步是做回放面板。面板里按时间列出意图、补丁数量、副作用状态和当前可回滚性。开发时我会先用它查三类问题:补丁是否可重放,撤销后 future 是否清空,副作用是否存在补偿动作。

可复用经验

这套方式最有价值的地方是把“界面变化”和“外部变化”分开对账。AI 生成的修改越多,越需要让每一步都有 operationId、baseHash 和回滚上下文。以后无论接入 RAG 草稿、Electron 本地文件、接口编排还是自动化发布,撤销重做都可以沿着同一条线检查:意图是否明确,补丁是否纯净,副作用是否可追踪,回放是否能说明真实状态。

主要来源

MDN structuredClone

Immer patches

Redux Implementing Undo History

TanStack Query Optimistic Updates

评论