Canvas Agent 的核心不是模拟鼠标,也不是让模型返回一整份 Scene JSON,而是:读取受限上下文,生成类型化计划,经宿主策略校验后,通过同一个 CommandBus 修改 Document。

学习目标

  • 明确框架、宿主 Agent、模型供应商和业务 Policy 的边界;
  • 为大画布构建分层且有界的上下文;
  • 区分 Planner、AgentPlan、ToolDef、Command 与 Transaction;
  • 把模型输出当作不可信输入进行确定性校验;
  • 设计结构验证、视觉验证和 revision 冲突重试。

1. 开源包不内置 Agent

开源 npm 包只提供方法和契约,不包含:

模型 SDK、供应商客户端、系统提示词、API Key
业务节点类型、业务权限、业务审核规则

完整所有权:

Host Prompt / Host Agent / Host Provider
  → CanvasAgentContext
  → PlannerPort
  → untrusted provider output
  → parseRemoteAgentPlan + Host Policy
  → AgentPlan / CanvasCommand[]
  → CommandBus Transaction
  → Document
  → Working Set / Renderer

PlannerPort 可以是远程模型、本地模型、规则引擎或人工审核流程。框架只接收数据,不绑定 OpenAI、Anthropic 或任意模型域名。

2. 五个概念不要混淆

概念 负责什么
Planner 把任务和上下文翻译成计划
AgentPlan 本次准备做什么的结构化数据
ToolDef 允许的动作 schema 与确定性展开逻辑
CanvasCommand Document 的最小 create/update/delete 变化
Transaction 一次完整任务的原子提交与 History 单位

用户说“把三张卡片水平排列”:

Prompt
  → AgentPlan: arrange_nodes(ids)
  → ToolDef 校验 ids
  → 本地算法计算坐标
  → 3 条 update Command
  → 1 个 Transaction

模型负责意图和选择,本地确定性代码负责重复坐标计算。这样更便宜、更稳定,也便于设置上限。

3. 为什么不能发送完整大文档

如果 Document 有 50,000 个节点,全量发送会同时造成 Token、隐私和注意力问题。更好的分层:

Focused:选中、任务明确提及、正在编辑,字段较完整
Nearby:Camera/Working Set 附近,只有 ID、类型、Bounds、摘要
Recent:最近修改的少量节点
Peripheral:远区只给聚类数量和包围盒

上下文必须声明边界:

interface CanvasAgentContext {
  revision: number
  totalNodeCount: number
  nodes: CompactNode[]
  selectedIds: string[]
  viewport: Bounds
  truncated: boolean
}

truncated=true 告诉 Planner:未出现不代表不存在。需要远区数据时,应先调用 query 工具,而不是猜 ID。

4. 上下文脱敏由宿主决定

业务 payload 不能默认全部发给上游:

const contextNode = compactNodeForRemoteAgent(productNode, {
  compactPayload: (node) => ({
    publicId: node.payload.publicId,
    label: node.payload.label,
  }),
})

宿主应明确 allowlist。内部成本、用户信息、私有文件路径、签名 URL、令牌和不可公开字段不能因为“模型需要上下文”就被透传。

5. ToolDef 比任意 JavaScript 更可靠

任意代码难以回答:

  • 能访问哪些节点和字段;
  • 一次最多创建多少对象;
  • 如何撤销、审计和重试;
  • 如何处理不存在的 ID;
  • 是否绕过 Document 直接调用 Renderer。

ToolDef 提供白名单:

interface ToolDef<Args> {
  name: string
  parse(input: unknown): Args
  authorize(args: Args, context: HostPolicyContext): void
  toCommands(args: Args, document: DocumentSnapshot): CanvasCommand[]
}

常见工具:

query_nodes       读取更多有界上下文
create_nodes      创建少量节点
update_node       修改允许字段
delete_nodes      删除已授权节点
arrange_nodes     本地展开布局
set_viewport      改 Agent/用户视角,不改作品
verify            结构或视觉验收

6. 模型输出必须经过多层校验

JSON 语法/Schema
  → 工具名白名单
  → 数量、文本、坐标、尺寸上限
  → 引用 ID 必须存在或由本计划创建
  → allowedKinds / allowedPatchKeys
  → 宿主 validateNode / authorize
  → expectedRevision
  → CommandBus 最终不变量

示例:

const plan = parseRemoteAgentPlan({
  actionId,
  expectedRevision: snapshot.revision,
  nodes: snapshot.nodes,
  content: providerOutput,
  policy: {
    allowedKinds: ['card', 'connector'],
    allowedPatchKeys: ['bounds', 'payload', 'zIndex'],
    maxCommands: 100,
    validateNode(node) {
      if (typeof node.payload.label !== 'string') {
        throw new Error('label is required')
      }
    },
  },
})

校验失败不能“尽量执行剩下部分”。一次 AgentPlan 应保持原子性,或显式拆成可审计的多个事务。

7. Revision 冲突与重试

Agent 读取 revision 41
用户修改画布 → revision 42
Agent 提交 expectedRevision 41
  → 拒绝 canvas_revision_conflict
  → 重新 getContext/query
  → 重新规划或 rebase

网络重试使用同一 actionId,防止已提交事务重复执行;内容发生变化则必须使用新 ID。

8. API Key 和模型域名的边界

在线 Demo 允许用户自行填写 OpenAI-compatible endpoint、API Key 和模型。安全要求:

  • Key 只保存在当前页面内存,刷新即清空;
  • 浏览器不把 Key写入 localStorage/IndexedDB/日志;
  • 请求通过受限网关,校验 origin、协议、端口和响应大小;
  • 网关设置请求体、并发、速率和超时上限;
  • 生产宿主更推荐服务端持有凭据。

框架只提供计划解析和 Command 提交方法,不代理任意 URL,也不替宿主决定供应商。

9. “提交成功”不等于“任务完成”

验证至少三层:

结构验证:节点数、ID、Bounds、关系、字段不变量
行为验证:Transaction 成功、History 可 Undo、revision 正确
视觉验证:截图/导出、重叠、遮挡、文字溢出、人工复核

“把卡片排整齐”即使三条 Command 都成功,也可能视觉上重叠。没有视觉 oracle 时,应返回 needs-review,而不是宣称完成。

10. 操作 Demo

  1. 在在线画布创建几个节点并选择其中两个;
  2. 查看发给模型的是有界摘要而非 Renderer 对象;
  3. 输入 endpoint、Key、model,执行“水平排列选中节点”;
  4. 观察 AgentPlan 经 Policy 变成一个 Transaction;
  5. Undo 一次,确认整体撤销;
  6. 模拟非法 kind、越界坐标和不存在 ID,确认零部分写入;
  7. 在规划期间人工修改画布,确认 revision conflict;
  8. 刷新页面,确认 API Key 不被恢复。

11. 引入旧项目的最小路径

已有业务 Store
  → 映射成 CanvasNode 摘要
  → 为现有操作封装 ToolDef
  → ToolDef 输出 CanvasCommand
  → CommandBus 回调现有 Store
  → 保留现有 Renderer

不要求先换 Canvas 框架。第一阶段甚至可以只接一个只读 getContext 和一个低风险 update_node,验证审计与 Undo 后再扩展。

12. 验收标准

包内没有模型 SDK、Prompt 或密钥
上下文有数量/字节上限并携带 truncated
敏感 payload 默认不外发
模型输出经过 Schema + Host Policy + CommandBus
旧 revision 与重复 actionId 行为确定
Agent 与人类操作共用 Transaction/History
结构成功与视觉成功明确区分

常见错误

  • 把完整大 Document 发送给模型;
  • 让模型直接输出/执行 JavaScript;
  • 把 Konva Node 或 ImageBitmap 当上下文;
  • 工具粒度过低,让模型生成上千个坐标;
  • 信任模型返回的 ID、URL、颜色和数量;
  • 把 API Key 持久化到浏览器;
  • 只看 HTTP 200 就宣称视觉任务完成。

思考题

当 Agent 需要修改视口外的一组节点时,应该直接扩大全量上下文,还是先 query 并返回聚类/候选?设计一套不超过固定 Token 预算的两阶段工具链。