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 按优先级选择:
- selected ids;
- visible ids;
- recent ids;
- 文档剩余节点;
- 去重后达到
maximumNodes即停止。
默认最多 100 个节点:
const context = buildAgentContext({
revision: kit.document.revision,
nodes: kit.document.values(),
selectedIds,
visibleIds: frame.workingSet.active,
recentIds,
maximumNodes: 60,
})
返回值同时包含 totalNodeCount 和 truncated,便于 Agent 知道它看到的不是全量文档。
这只是节点数量门禁;发送远程服务前还应裁剪 payload。
const remoteNodes = context.nodes.map((node) =>
compactNodeForRemoteAgent(node, {
payloadKeys: ['label', 'fill'],
compactPayload: (node) => ({
// 只发送任务需要且允许离开设备的字段。
semanticRole: readSemanticRole(node),
}),
}),
)
默认 compact payload 只尝试保留 fill、label、labelColor、stroke、strokeWidth。
附件内容、用户身份、内部 URL、业务字段和未公开 metadata 不应因为存在于节点就自动发送。
3. 定义远程输出协议
模型输出形状:
{
"label": "Arrange selected cards",
"commands": [
{
"type": "update",
"id": "card-1",
"patch": {
"bounds": { "minX": 0, "minY": 0, "maxX": 240, "maxY": 120 }
}
}
]
}
actionId 和 expectedRevision 由宿主在请求时绑定,不允许模型自己指定:
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 是接入方能力,而不是包内置依赖。