English | 简体中文

这个包不内置 Agent、模型 SDK、Provider、Prompt、API Key 或业务节点 Schema。它提供的是 “如何让任意 Agent 安全地读有限上下文,并通过同一 CommandBus 修改画布”的方法。

1. 两种接入形态

本地 PlannerPort

如果宿主已经有 Agent Runtime,可以实现 PlannerPort

interface PlannerPort<Node extends CanvasNode> {
  plan(input: {
    prompt: string
    context: CanvasAgentContext<Node>
    tools: readonly ToolDefinition[]
    signal?: AbortSignal
  }): Promise<AgentPlan<Node>>
}

Planner 可以是规则引擎、本地模型、现有 Agent 工具系统或服务端代理。包不关心来源, 最终只接收 renderer-independent 的 create/update/delete 计划。

远程模型输出

如果模型返回 JSON 文本,先用 parseRemoteAgentPlan 把不可信字符串解析为受限计划,再交给 CommandBus。不要直接 JSON.parse 后修改 Konva node 或 Document Map。

flowchart LR
  U["User prompt"] --> H["Host Agent / backend"]
  D["Document snapshot"] --> C["Bounded context"]
  C --> H
  H --> R["Untrusted JSON response"]
  R --> P["Host policy + parseRemoteAgentPlan"]
  P --> B["CommandBus"]
  B --> X["Revision-checked Document commit"]
  X --> V["Runtime / Renderer"]

信任边界在 parseRemoteAgentPlan 之前。即使请求来自自家服务端,也要把模型输出视为不可信。

2. 构建有界上下文

把整个无限画布发送给模型会同时制造成本、隐私和时延问题。buildAgentContext 按优先级选择:

  1. selected ids;
  2. visible ids;
  3. recent ids;
  4. 文档剩余节点;
  5. 去重后达到 maximumNodes 即停止。

默认最多 100 个节点:

const context = buildAgentContext({
  revision: kit.document.revision,
  nodes: kit.document.values(),
  selectedIds,
  visibleIds: frame.workingSet.active,
  recentIds,
  maximumNodes: 60,
})

返回值同时包含 totalNodeCounttruncated,便于 Agent 知道它看到的不是全量文档。 这只是节点数量门禁;发送远程服务前还应裁剪 payload。

const remoteNodes = context.nodes.map((node) =>
  compactNodeForRemoteAgent(node, {
    payloadKeys: ['label', 'fill'],
    compactPayload: (node) => ({
      // 只发送任务需要且允许离开设备的字段。
      semanticRole: readSemanticRole(node),
    }),
  }),
)

默认 compact payload 只尝试保留 filllabellabelColorstrokestrokeWidth。 附件内容、用户身份、内部 URL、业务字段和未公开 metadata 不应因为存在于节点就自动发送。

3. 定义远程输出协议

模型输出形状:

{
  "label": "Arrange selected cards",
  "commands": [
    {
      "type": "update",
      "id": "card-1",
      "patch": {
        "bounds": { "minX": 0, "minY": 0, "maxX": 240, "maxY": 120 }
      }
    }
  ]
}

actionIdexpectedRevision 由宿主在请求时绑定,不允许模型自己指定:

const requestRevision = kit.document.revision
const actionId = crypto.randomUUID()
const content = await callMyAgentBackend({ prompt, nodes: remoteNodes })

const plan = parseRemoteAgentPlan<MyNode>({
  actionId,
  expectedRevision: requestRevision,
  nodes: kit.document.values(),
  content,
  maximumCommands: 20,
  policy: {
    allowedKinds: ['card', 'note', 'arrow'],
    allowedPatchKeys: ['bounds', 'rotation', 'zIndex', 'payload'],
    maximumWorldCoordinate: 100_000,
    maximumNodeSize: 4_000,
    maximumPayloadBytes: 4_096,
    maximumPoints: 128,
    validateNode: assertBusinessRules,
  },
})

const commit = kit.commands.execute(plan)

4. 默认解析门禁

如果宿主不覆盖,当前默认值如下:

门禁 默认值
Agent response size 最大 32 KiB
Label 非空,最长 120 字符
Commands 1 至 32 条
Command types create, update, delete
Patch keys bounds, kind, payload, rotation, zIndex
World coordinate absolute value 最大 10,000,000
Node width / height 最大 1,000,000
JSON payload bytes 最大 8,192
payload.points 最大 512 项
Target id update/delete 必须指向请求快照中的已有节点

默认 allowedKinds 未设置时不会限制非空 kind。这是框架中立的必要结果,不是生产环境建议。 真实项目应显式配置 allowed kinds,并使用 validateNode 执行业务字段、权限、资源引用、层级和 协作规则。

解析器会把全部命令先应用到克隆快照,再验证最终节点。因此多个命令组合后产生的越界状态也 会被拒绝,而不是只检查每条 patch 的表面形状。

5. 幂等、并发与撤销

Agent 调用期间用户仍可能编辑画布。请求开始时保存 revision,响应回来后仍用它作为 expectedRevision。若文档已经变化,CommandBus 抛 RevisionConflictError;宿主应重新构建 上下文并让 Agent 重算,不能把旧计划强行应用。

action id 是重试幂等键:

  • 同 id + 同计划:返回第一次 commit;
  • 同 id + 不同计划:ActionIdConflictError
  • 新请求:使用新 id。

Agent 与人工操作走同一个 CommandBus,所以一次计划是一个可撤销事务,而不是绕开历史系统 直接改 Renderer。

6. 威胁模型与控制

威胁 典型后果 必需控制
API Key 放浏览器/仓库 凭证被盗用 Key 只存在宿主服务端或用户本地存储,绝不提交 Git
任意 endpoint 代理 SSRF、访问内网 metadata HTTPS allowlist、DNS/IP 检查、禁止私网和重定向复核
Prompt injection 删除/泄漏/越权修改 最小上下文、工具白名单、输出 schema、业务 policy
超大响应/节点 内存与渲染 DoS response/commands/payload/points/坐标硬上限
旧响应覆盖新编辑 丢失用户工作 revision CAS,冲突后重算
重试重复创建 重复节点 action id 幂等
模型返回未知字段 原型污染或越权 JSON 数据、patch key allowlist、validateNode
用户 A 操作用户 B 节点 数据越权 服务端基于身份重新校验文档与节点权限
错误信息回显密钥 二次泄漏 日志脱敏,不记录 Authorization/request body

浏览器中的 policy 主要保护交互和数据完整性,不是授权边界。服务端必须再验证用户身份、文档 权限、rate limit、body limit、超时、并发和审计字段。

7. API Key 与 endpoint 应由谁填写

公开在线 demo 可以允许访客填写自己的 endpoint/model/key,但默认应只保存在当前浏览器会话, 明确提示风险,并由隔离 gateway 发起外部请求。npm 包本身不读取这些字段。

本仓库在线演示的兼容 gateway 会把用户填写的服务根地址规范化到 /v1/chat/completions。因此 OpenAI-compatible chat-completions 的默认请求后缀是:

/v1/chat/completions

这是演示网关的协议选择,不是核心包绑定。接入方可以使用 Responses API、内部 Agent 服务、 通用工具运行时或完全离线的 Planner,只要最终转换为 AgentPlan

8. 推荐的服务端职责

app.post('/api/canvas-agent/plan', authenticate, async (request, response) => {
  enforceBodyLimit(request)
  enforceRateLimit(request.user.id)
  const canonical = await loadAuthorizedDocument(request.user, request.body.documentId)

  // 不信任浏览器声称的节点权限和 revision。
  assertRevision(canonical, request.body.expectedRevision)
  const modelOutput = await provider.generate({
    prompt: buildServerPrompt(request.body.prompt),
    nodes: sanitizeContext(request.body.nodes, canonical),
    signal: AbortSignal.timeout(20_000),
  })

  // 服务端先执行同等级 schema/business 校验;浏览器仍会再次解析。
  const plan = parseAndAuthorize(modelOutput, canonical, request.user)
  response.json({ content: JSON.stringify(plan) })
})

网关不应该成为任意 URL 转发器。若允许自定义 Provider,应采用显式域名策略、解析后 IP 检查、 连接目标复核、禁止携带服务端内部 header,并限制响应大小。

9. 错误分类

建议 UI 区分:

  • AbortError:用户取消或新请求替代旧请求;
  • 网络/Provider error:未产生计划,可安全重试;
  • JSON/schema/policy error:模型输出不可信或不满足业务规则;
  • RevisionConflictError:画布已经变化,需要重新规划;
  • ActionIdConflictError:宿主错误地复用了 id;
  • durable save error:Document 已在内存提交,但尚未同步。

任何失败都不应留下“部分命令已经执行”的状态。parse 在克隆快照上完成,CommandBus 与 Document commit 都是事务式的。

可运行示例见 host-agent。 它故意使用规则 Planner,证明 Agent 是接入方能力,而不是包内置依赖。