01 · 协议消息与信封
本章讲什么: A2UI 的「线上格式」——服务端往客户端流的 4 类消息、客户端回传的 3 类事件、信封(envelope)的硬规则,以及 v1.0 新增的双向 RPC。读完你能看懂任何一条 A2UI JSONL。
1. 一条流由哪些消息组成
A2UI 的通信是一条单向 JSON 对象流:服务端(agent)不断发对象,客户端逐条解析、增量建/改 UI。规范定义服务端→客户端有四种消息(specification/v1_0/docs/a2ui_protocol.md:24-29):
| 消息 | 白话 | 作用 |
|---|---|---|
createSurface | 「开一个画面」 | 新建一个 surface 并开始渲染 |
updateComponents | 「填/改组件 」 | 给某 surface 加或更新一批组件定义 |
updateDataModel | 「填/改数据」 | 往某 surface 的数据模型写值 |
deleteSurface | 「关画面」 | 移除一个 surface 及其全部内容 |
surface(画面) = 一组组件 + 它们的数据,构成一块可独立渲染的 UI 区域。一个客户端可以同时挂多个 surface(比如聊天窗里好几张卡片)。
2. 信封的硬规则:恰好一个顶层键
每条消息必须是一个 JSON 对象,且恰好包含上述键之一(v1.0 还加了 callFunction、actionResponse)。键名本身就是消息类型(a2ui_protocol.md:169-171)。
实现里这条规则被严格执行——MessageProcessor 先数有几个更新类型键,多于一个就抛 A2uiValidationError,然后按键分发:
// 真实源码 message-processor.ts:229-262(精简)
private processMessage(message: A2uiMessage): void {
const updateTypes = ['createSurface','updateComponents','updateDataModel','deleteSurface']
.filter(k => k in message);
if (updateTypes.length > 1) {
throw new A2uiValidationError(`Message contains multiple update types: ...`);
}
if ('createSurface' in message) { this.processCreateSurfaceMessage(message); return; }
if ('deleteSurface' in message) { this.processDeleteSurfaceMessage(message); return; }
if ('updateComponents' in message){ this.processUpdateComponentsMessage(message); return; }
if ('updateDataModel' in message) { this.processUpdateDataModelMessage(message); return; }
}
这段是渲染内核的「总机」:message-processor.ts:229 的 processMessage 是每条消息进门后的第一站。注意它只认 v0.9 的四种——v1.0 才有的 callFunction/actionResponse 在这个 v0_9 内核里没有分支(差异点 1,后述)。
3. 四类消息逐个看
3.1 createSurface —— 开画面
要解决的小问题:渲染前得先有个「容器」,且这个容器要定好用哪份目录、要不要回传数据。
关键字段(a2ui_protocol.md:181-188):
surfaceId(必填):全局唯一,渲染器生命周期内不可重复。重复创建是错误,得先删再建。catalogId(必填):本 surface 用哪份组件目录(白名单)。surfaceProperties(选填,v1.0):如agentDisplayName、iconUrl,用于「这是哪个 agent 画的」归属展示。sendDataModel(选填):置true则客户端每次回传消息都带上本 surface 的完整数据模型。components/dataModel(选填,v1.0):允许在建面同一条消息里就内联整棵组件树和初始数据(v1.0 的「单消息实例化」)。
实现侧的校验(message-processor.ts:264-280):先按 catalogId 找目录,找不到抛 A2uiStateError("Catalog not found");surface 已存在也抛错;否则 new 一个 SurfaceModel 挂进 SurfaceGroupModel。
差异点 2(theme vs surfaceProperties): v1.0 规范用可扩展的
surfaceProperties取代了旧的硬编码主题。但 v0_9 实现的processCreateSurfaceMessage解构的仍是{surfaceId, catalogId, theme, sendDataModel}(message-processor.ts:266),SurfaceModel构造器收的也是theme(surface-model.ts:57-62)。即实现还停在theme阶段。
3.2 updateComponents —— 填组件
这条把一批组件作为扁平列表发来,父子关系靠 ID 引用(邻接表,详见 02 章)。它只能发给已存在的 surface,且组件可以引用尚不存在的孩子——客户端要优雅占位(渐进渲染)(a2ui_protocol.md:222-223)。
实现里的「增量更新」语义很关键(message-processor.ts:288-322):对每个组件,先看是否已存在——
- 已存在且
component类型变了 → 删旧建新(类型变更要重建); - 已存在且类型没变 → 只替换
properties; - 不存在 → 新建。
这就是「增量可更新」的落点:同一个 id 反复发,就是在原地改它。
3.3 updateDataModel —— 填数据
用来改「内容」而不动「结构」。语义是 upsert(a2ui_protocol.md:851-857):
path指向数据模型某处(JSON Pointer);省略或/表示替换整个模型。value存在 → 写入;value省略 → 删除该 key。
实现一行直达数据模型(message-processor.ts:333-335):const path = payload.path || '/'; surface.dataModel.set(path, value);。DataModel.set 的细节(自动建中间节点、通知祖先/后代信号)见 04 章。
3.4 deleteSurface —— 关画面
最简单:按 surfaceId 移除 surface 及其全部组件和数据(message-processor.ts:282-286 → model.deleteSurface)。
4. 一条完整流长什么样
规范给了渲染「联系表单」的完整 JSONL(a2ui_protocol.md:414-419)。骨架是:
{"version":"v1.0","createSurface":{"surfaceId":"contact_form_1","catalogId":"...basic/catalog.json"}}
{"version":"v1.0","updateComponents":{"surfaceId":"contact_form_1","components":[ ...几十个组件... ]}}
{"version":"v1.0","updateDataModel":{"surfaceId":"contact_form_1","path":"/contact","value":{...}}}
{"version":"v1.0","deleteSurface":{"surfaceId":"contact_form_1"}}
四步:建面 → 填一大批组件(表单的每个字段、校验规则、提交按钮的 action 都在这一步)→ 填初始数据 → 用完删面。注意顺序不强制完全如此,但建面必须在填之前(协议依赖有状态更新,见传输契约)。
5. 反方向:客户端→服务端的 3 类事件
用户交互不走 UI 流,而是另一条回传通道。客户端→服务端消息也是「恰好一个顶层键」,三选一(a2ui_protocol.md:1170-1172):
| 事件 | 何时发 | 关键字段 |
|---|---|---|
action | 用户触发了带服务端动作的组件(如点按钮) | name、surfaceId、sourceComponentId、timestamp、context、可选 wantResponse/actionId |
functionResponse | 客户端执行完服务端发来的 callFunction | functionCallId、call、value |
error | 客户端报运行时错误(如越界函数调用) | code、message、surfaceId 或 functionCallId |
实现里 action 的产生在 surface-model.ts:73-92 的 dispatchAction:它把 {name, surfaceId, sourceComponentId, timestamp, context} 组装好,用 A2uiClientActionSchema.safeParse 校验后再 emit——校验不过就只 console.error,不发出去,防止脏 action 上网。
6. v1.0 的双向 RPC(新增,且实现尚未跟上)
v1.0 相对 v0.9 最大的变化是引入双向 RPC(a2ui_protocol.md:35-43):
actionResponse:服务端同步回应客户端的action(当action带wantResponse: true时),把返回值送回,可选写入本地数据模型的responsePath。典型用途:输入框的「打字联想」。callFunction/functionResponse:服务端反向请求客户端执行一个目录里注册的函数(如「读屏幕分辨率」)。
安全边界(精华): callFunction 的执行边界由目录里函数的 callableFrom 元数据决定——clientOnly(仅本地可调)/ remoteOnly / clientOrRemote,省略则默认 clientOnly。客户端收到 callFunction 时必须在目录里查这个函数;若它是 clientOnly 或根本没注册,必须立刻拒绝并回 error{code:"INVALID_FUNCTION_CALL"}(a2ui_protocol.md:359-364)。这等于在协议层钉死「服务端不能远程触发本地敏感函数」。
差异点 1(双向 RPC 实现缺口): 上文 §2 的
processMessage只分发 4 种 v0.9 消息,没有callFunction/actionResponse分支;SurfaceModel.dispatchAction也只处理event动作(surface-model.ts:74)。所以双向 RPC 是 v1.0 规范层面的能力,在本仓库的 v0_9 渲染内核里尚未落地。这点很值得读者知道,免得照规范写客户端却在 v0_9 库里找不到对应处理。
7. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 消息分发总机 | renderers/web_core/src/v0_9/processing/message-processor.ts | processMessage |
| 建面/校验目录 | 同上 | processCreateSurfaceMessage |
| 组件增量更新 | 同上 | processUpdateComponentsMessage |
| 数据 upsert | 同上 | processUpdateDataModelMessage |
| action 组装+校验 | renderers/web_core/src/v0_9/state/surface-model.ts | dispatchAction、A2uiClientActionSchema |
| 协议规范 | specification/v1_0/docs/a2ui_protocol.md | 「Envelope message structure」「Client-to-server」节 |