青雲的博客

Article

不止是渲染:我给画布引擎做了一套让 agent 能"改图"的接口

· 13 分钟阅读

上一篇讲了怎么用 WebGPU 实例化把十万个矩形压到一次 draw call——那是把地基做实。但一个”面向 agent 的画布”光能渲染还不够:agent 得能读懂画布现在长什么样,还得能改它。这篇记录我在 sky-canvas 上,如何把一个只读渲染器变成一个 agent 能持续编辑的画布。灵感来自一个”面向 agent 的浏览器”。

一、声明式渲染只做完了一半

在补编辑能力之前,sky-canvas 已经有一层声明式接口:agent 吐一份 JSON 描述画布,SDK 全量渲染。

{
  "viewport": { "x": 0, "y": 0, "zoom": 1 },
  "nodes": [
    { "type": "rect",   "x": 100, "y": 100, "width": 200, "height": 80, "color": "#4a9eff" },
    { "type": "circle", "cx": 400, "cy": 300, "radius": 40, "color": "#ff6b6b" },
    { "type": "text",   "x": 120, "y": 140, "size": 24, "text": "Hello", "color": "#fff" }
  ]
}

这对”从零画一张图”很好使。但它是单向的、无状态的:每次都是一份完整 JSON 进去、一帧画面出来。真实的 agent 编辑场景不是这样——用户说”把那个登录按钮改成绿色、往下挪 20px”,agent 需要:

  1. 知道画布现在有什么(哪来的登录按钮?它现在什么颜色、在哪?)
  2. 能指名道姓地引用它(第几个 rect?叫什么?)
  3. 只改这一处,而不是重发整份 JSON

声明式 JSON 一条都满足不了。它没有”读”,没有”稳定引用”,没有”增量改”。agent 只能凭自己上一次发的 JSON 去记忆画布状态——一旦画布被别处改过,它就瞎了。

二、灵感:一个”面向 agent 的浏览器”是怎么做的

我研究了 ego-lite——一个专门给 AI agent 用的浏览器。它抽掉浏览器外壳后,留下三条设计,几乎可以原样搬到画布上:

1. Snapshot:给 agent 语义视图,不是原始数据。 ego 不把 DOM/HTML 甩给 agent,而是从无障碍树压缩出一份几百 token 的结构化文本——每个可交互元素一行,配一个临时编号 @N。让 agent 能决定”点哪个、填哪个”,不需要啃原始 HTML。

2. Ref 双轨寻址:临时 @N + 稳定 loc= @N 只对最近一次 snapshot 有效(页面一变就失效);要跨步骤稳定引用,用 loc= 选择器。

3. Code-base 而非 CLI-base:一趟批量执行。 agent 不是”调一个工具→看结果→再调”地循环,而是写一段脚本、一次性调用多个预注入函数,一趟跑完再回报。ego 宣称这让复杂任务快 2.5×、token 显著更少——本质是把多步压成一次输出,砍掉往返。

对照之下,我们的声明式 JSON 只相当于 ego 的”一次性重开一个页面”。缺的正是 观察 → 引用 → 增量改 的循环。

三、核心设计:人和 agent 共用一份画布文档

ego 的一句话点醒了我:它让”人和 agent 共用同一个浏览器”。那就让人和 agent 共用同一份画布文档

        人类编辑器 UI  ──┐
                          ├──►  SceneDocument(带稳定 id 的节点集)──►  WebGPU 渲染
        agent ops    ──┘            ▲

   agent 循环:snapshot() ─► 发一批 ops(按 id 寻址)─► apply → 重渲染 ─► 再 snapshot

声明式全量 JSON 不再是唯一入口,它降级成”其中一种 bulk-load 操作”。文档模型才是单一真相,人类的拖拽改色和 agent 的 ops 走同一套原语。

3.1 SceneDocument:给每个对象一个稳定 id

阶段一的 Scene 是无状态的 nodes 数组。现在引入有状态的文档,每个节点带一个稳定 id——这是 agent 的长期寻址句柄(对应 ego 的 loc=)。id 用自增计数器生成,不依赖 Math.random(worker/测试环境里它不可用):

class SceneDocument {
  private nodes = new Map<string, SceneNode>()
  private seq = { n: 0, c: 0, g: 0 }

  add(node: SceneNode, id?: string): string | undefined {
    if (id !== undefined) {
      if (this.nodes.has(id)) return undefined   // 显式 id 已占用:拒绝覆盖
      this.nodes.set(id, node)
      this.reserveId(id, 'n')                     // 抬高计数器,防后续自增撞车
      return id
    }
    const nid = `n${++this.seq.n}`
    this.nodes.set(nid, node)
    return nid
  }
}

这里有个坑值得单独说:最初 add 直接 nodes.set(id, node) 无脑覆盖。问题是——LLM 读了 snapshot 看到 #n1 存在,下一步很可能复用 #n1 这个 id,于是静默覆盖了原对象,画布数据被无声污染。修法是:显式 id 已占用就拒绝(返回 undefined),并且接纳显式 id 时同步抬高计数器,免得后续自增 id 又撞上。面向 agent 的接口,“脏输入不静默出错”是硬要求。

3.2 snapshot():画布的语义视图

对标 ego 的 Snapshot——把文档压成一份紧凑、每行一个对象的文本。@N 是当轮短号,#id 是稳定长号,双轨并存:

viewport: (300,200) zoom=1 | 13 objects (showing 13)
@1 #n1 text   (90,50) "Weekly Sales" size=20 #e6edf3
@2 #n2 line   (90,320)->(520,320) #8b949e
@4 #n4 rect   (120,220) 50x100 #3fb950
@5 #n5 rect   (190,170) 50x150 #3fb950
...

只读、不改状态,支持视口裁剪(对标 ego 的 only_within_viewport)——只快照与当前视口相交的对象,showing N 会小于总数,让 agent 知道视口外还有多少。几万个对象的画布,不必把全量塞给 LLM。

3.3 ops:按 id 寻址的增量编辑

一组判别式联合的操作,覆盖 agent 编辑画布的高频动作:

type SceneOp =
  | { op: 'add';     node: SceneNode; id?: string }
  | { op: 'update';  id: string; patch: Partial<SceneNode> }
  | { op: 'move';    id: string; dx: number; dy: number }
  | { op: 'remove';  id: string }
  | { op: 'connect'; from: string; to: string; color?: string; width?: number }
  | { op: 'group';   members: string[]; id?: string }
  | { op: 'setViewport'; viewport: SceneViewport }

function applyOps(doc: SceneDocument, ops: SceneOp[]): OpResult[]

关键是批量一趟(对标 ego 的 code-base 执行):agent 一次发一整组 ops,applyOps 顺序应用、逐条回结果。未知 id、未知 op 记 {ok:false, error} 而非抛异常——一条失败不影响同批其余。这跟渲染层”未知类型静默跳过、颜色非法回退白色”是同一套”脏数据也不崩”的基调。

move 单独列出来而不都塞进 update,因为位移是最高频操作(类比 ego 把 click 单列而非都走 js());connect 按两端节点的中心自动算连线,group 之后可以对整组一次性 move——这些都是节点图/流程图的刚需。

3.4 skill:把”怎么用”变成可分发、可积累的知识

ego 还有一个我很认同的拆分:runtime(浏览器桥 + helper)和 skill(教 agent 怎么用 + 沉淀站点经验)解耦,经验能独立于代码生长。

所以我给 sky-canvas 配了一个 canvas-agent skill:SKILL.md 教 agent “先 snapshot、用 #id 引用、一趟发多个 ops、再 snapshot” 的循环范式和 ops 速查;learnings/ 目录沉淀领域经验(比如”画柱状图时柱子高度和 y 要联动”、“画节点图先 add 拿 id 再 connect”)。skill 不是文档,是 agent 使用这套 API 的操作知识。

四、验证:从”改一格”到”LLM 从零编排一张星图”

光有设计不算数。我做了个四面板的 demo:①全量装载 Scene ②看 snapshot ③ops 控制台 ④真接 LLM(浏览器直连 Anthropic,读 snapshot、吐 ops)。

第一关:编辑闭环通不通。 装载一张柱状图,snapshot 正确读出 13 个对象(@1 #n1 text ... @6 #n6 rect ...),ops 控制台按 #id[{"op":"move","id":"n7","dx":0,"dy":40}, {"op":"update","id":"n7","patch":{"color":"#3fb950"}}]——目标柱子精准移动 + 改色,其余不动。观察→引用→增量改,闭环成立。

Demo:柱状图 + ops 编辑闭环

第二关:LLM 能不能真的驱动它。 给一句自然语言指令、一块空画布nodes:[]),让 agent 读 snapshot 规则、按 skill 描述的循环范式自己组 ops。结果:45 个 op 全部应用成功,从零生成了一张 “Celestial Navigator” 星图——深空背景、三层轨道圆、中心恒星、六颗带标签的彩色行星、星座连线、图例、装饰框角,snapshot 里数出 39 个对象 + 9 条连线。

Skill 驱动:agent 从空画布生成 Celestial Navigator 星图

这一关证明的不是”LLM 会调 API”,而是:只要给对了语义视图(snapshot)和操作原语(ops),LLM 能自己把一个复杂画面拆解成一串合法的增量操作。 这是”面向 agent 的画布”要成立的前提。

五、边界在哪

诚实地说清楚现在的位置:

  • 应用后是全量重渲染。 ops 改的是文档模型,重画走的还是实例化渲染管线——它本来就扛得住几十万对象,所以 MVP 没做增量渲染 diff。真正的增量渲染(只重传变化的实例)是后面的事。
  • snapshot 是对象级、非像素级。 text 的包围盒是按字号估算的,做视口裁剪足够,不追求像素精确。
  • demo 的 LLM 是浏览器直连,图省事;生产环境该走后端代理,别把 key 暴露在前端。

但核心闭环——stable id + snapshot + ops + skill——是通的,而且是纯函数、可单测的(53 个单测覆盖文档/快照/ops,不依赖 GPU)。

结语

主流开源画布(tldraw、excalidraw、PixiJS)都是给用的:漂亮的手柄、顺滑的手势。但没有一个把”agent 能读能改”当作一等公民来设计。ego-lite 证明了”面向 agent”是浏览器值得重做一遍的理由;我赌它同样是画布值得重做一遍的理由。

WebGPU 底座解决”画得动”,这套 snapshot + ops 解决”agent 改得动”。两块拼起来,才是一个面向 agent 的画布编辑器该有的样子。


代码开源在 sky-canvas,文档模型 / snapshot / ops 均为可运行的真实实现并有单测覆盖;星图为 skill 驱动下的浏览器实测截图。

Keep Reading

相关文章

评论