跳到主要内容

创建日期:2026-10-20 | 最近更新:2026-10-20 基准:A2UI v0.9.1,common_types.json 的 Action / CheckRule / FunctionCall,client_to_server.json,以及 catalogs/basic/catalog.json 的 14 个函数。完整消息示例已过 schema 校验;文中明确标为「错误写法」的片段除外。

动作、校验与函数:把用户的点击送回去

前面几篇讲的是「Agent 怎么把 UI 发下来」。这一篇讲反方向:用户操作之后,什么被发回服务端、什么留在本地、什么会被拦截。

A2UI 把交互拆成三种,边界非常清楚:

机制执行位置是否通知 Agent典型用途
action.event服务端是提交表单、确认订单
action.functionCall客户端否打开链接、本地计算
checks客户端否按钮置灰、输入校验提示

1. Action:事件还是本地函数​

交互组件(典型是 Button)的 action 是一个二选一的结构:

{
"action": {
"event": {
"name": "submit_booking",
"context": { "itemId": "123" }
}
}
}
{
"action": {
"functionCall": {
"call": "openUrl",
"args": { "url": "https://a2ui.org/help" },
"returnType": "void"
}
}
}

Action 的 schema 是 oneOf:

  • 有 event:发一个服务端事件;
  • 有 functionCall:执行一个客户端函数;
  • 两个都有、或都没有:校验失败。

1.1 服务端事件​

{
"id": "submit_btn",
"component": "Button",
"child": "submit_label",
"variant": "primary",
"action": {
"event": {
"name": "confirm_booking",
"context": {
"name": { "path": "/booking/name" },
"guests": { "path": "/booking/guests" }
}
}
}
}

event.name 是 Agent 用来 switch 的稳定标识。context 是「从数据模型里挑出来的一个视图」:只带这次事件真正需要的字段,而不是整个模型。

规范对 context 有一条很实用的建议:

Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.

也就是说:

  • 需要用户当前输入 → 用 { "path": "..." };
  • 固定的表单 ID、组件类型之类的静态值 → 直接写字面量。

1.2 本地函数​

{
"id": "help_btn",
"component": "Button",
"child": "help_label",
"action": {
"functionCall": {
"call": "openUrl",
"args": { "url": "https://a2ui.org/help" },
"returnType": "void"
}
}
}

本地函数不会通知 Agent。用户点开帮助链接,服务端不会收到任何消息。这是刻意的:导航、滚动、本地计算这类行为不需要一次网络往返。

openUrl 的实现有强制安全要求:只允许 http: / https:,拒绝 javascript:、data: 等 scheme;新窗口要带 noopener,noreferrer。这是 Renderer 的职责,但协议把它写进了实现指南,因为它是 XSS 的入口。

2. 客户端上行:action 消息的五个必备字段​

当用户触发 event 时,客户端构造一条 action 消息:

{
"version": "v0.9.1",
"action": {
"name": "confirm_booking",
"surfaceId": "booking",
"sourceComponentId": "submit_btn",
"timestamp": "2026-10-20T10:00:00Z",
"context": {
"name": "Alice",
"terms": true
}
}
}
字段必填说明
name是来自组件的 action.event.name
surfaceId是事件发生在哪块 surface
sourceComponentId是触发事件的组件 ID
timestamp是ISO 8601 时间戳
context是组件 action.event.context 解析后的对象;可以是空对象 {}

context 是必填的,即使没有内容也要写 {}。这一点 schema 里是 required,不是可选。

解析时机​

规范要求客户端的本地数据模型写入是同步的。所以点击发生时的顺序是:

  1. 用户最后一次输入已经写入本地模型;
  2. 点击按钮;
  3. 客户端解析 context 里的所有 path;
  4. 打包 action 消息;
  5. 发送。

不存在「输入还没落库就提交」的竞态。这也是为什么客户端不需要对输入做网络防抖。

3. checks:在发出去之前先拦一道​

Button、TextField、CheckBox、ChoicePicker、Slider、DateTimeInput 都支持 checks。它的形状是:

{
"checks": [
{
"condition": {
"call": "required",
"args": { "value": { "path": "/booking/terms" } },
"returnType": "boolean"
},
"message": "Please accept the terms"
}
]
}

注意两层结构:

  • 外层是 CheckRule:{ condition, message };
  • 内层 condition 是一个返回 boolean 的 FunctionCall。

condition 可以是简单函数,也可以用 and / or / not 组合:

{
"condition": {
"call": "and",
"args": {
"values": [
{
"call": "required",
"args": { "value": { "path": "/form/terms" } },
"returnType": "boolean"
},
{
"call": "or",
"args": {
"values": [
{
"call": "required",
"args": { "value": { "path": "/form/email" } },
"returnType": "boolean"
},
{
"call": "required",
"args": { "value": { "path": "/form/phone" } },
"returnType": "boolean"
}
]
},
"returnType": "boolean"
}
]
},
"returnType": "boolean"
},
"message": "Accept terms and provide email or phone"
}

行为语义:

  • Button:任意一个 check 失败 → 按钮自动置灰,不会发出 action。
  • 输入组件:失败的 check 会产生对应的 message,在组件渲染时展示。

校验的边界​

规范明确说:checks 是 UX 层的,不是数据完整性层:

Validation checks are designed to manage UI State (User Experience) by preventing invalid interactions before they happen. They are not a replacement for Data Integrity checks, which must still be performed on the agent.

所以服务端收到 action 后必须重新校验。客户端的置灰只是减少无效请求,不是安全边界。

一个必须记住的坑:checks 不是 FunctionCall 数组​

规范正文里的 Contact Form 示例写成了:

{
"checks": [
{
"call": "required",
"args": { "value": { "path": "/booking/terms" } },
"message": "Please accept the terms"
}
]
}

这不符合 common_types.json 的 CheckRule。正确写法必须把函数调用包进 condition:

{
"checks": [
{
"condition": {
"call": "required",
"args": { "value": { "path": "/booking/terms" } }
},
"message": "Please accept the terms"
}
]
}

仓库自己的测试样例(test/cases/checkable_components.json、button_checks.json)用的都是 condition 结构。再次说明:以 schema 和测试样例为准。

4. 函数:Catalog 里的可调用白名单​

FunctionCall 的结构:

{
"call": "formatDate",
"args": {
"value": "2026-10-20T00:00:00Z",
"format": "yyyy-MM-dd"
},
"returnType": "string"
}
字段必填说明
call是函数名,必须存在于当前 Catalog 的 functions 里
args否命名参数对象;参数值可以是字面量、DataBinding 或嵌套 FunctionCall
returnType否string / number / boolean / array / object / any / void;schema 标注默认 boolean

FunctionCall 的 schema 里有一个 oneOf:

{
"oneOf": [{ "$ref": "catalog.json#/$defs/anyFunction" }]
}

这意味着未知函数直接校验失败。不是「运行时报个警告」,而是生成侧就应该被拦住。这也是 A2UI 「不执行任意代码」的一部分:Agent 只能调用白名单里的名字。

4.1 基础 Catalog 的 14 个函数​

函数参数返回作用
requiredvalueboolean非 null、非 undefined、非空串、非空数组
regexvalue、patternboolean字符串匹配正则
lengthvalue、min 或 max(至少一个)boolean字符串长度范围
numericvalue、min 或 max(至少一个)boolean数值范围
emailvalueboolean邮箱格式
formatStringvaluestring字符串插值,见下一节
formatNumbervalue、decimals?、grouping?string数字格式化
formatCurrencyvalue、currency、decimals?、grouping?string货币格式化
formatDatevalue、formatstring按 Unicode TR35 模式格式化日期
pluralizevalue、other(必需),以及 zero / one / two / few / manystring按 CLDR 复数类别选词
openUrlurlvoid打开链接(仅 http/https)
andvalues(至少 2 个)boolean逻辑与
orvalues(至少 2 个)boolean逻辑或
notvalueboolean逻辑非

这些函数既可以出现在 checks.condition 里,也可以出现在任何 Dynamic* 属性里(只要返回类型匹配)。

4.2 returnType 的一个 schema 宽松处​

规范说 returnType 默认是 boolean。但 JSON Schema 的 default 只是注解,校验器不会替你填。更微妙的是:DynamicString / DynamicNumber / DynamicBoolean 在检查函数返回值时,只写了:

{ "properties": { "returnType": { "const": "string" } } }

没有 required: ["returnType"]。所以一个不写 returnType 的 FunctionCall 在类型槽里会「空过」校验。

我用 Draft 2020-12 实测过:

  • formatString 放进 checks.condition,returnType: "string" → INVALID(正确,校验器发现它不是 boolean);
  • 同一个调用删掉 returnType → VALID(校验器没东西可比较)。

结论:生产代码应当显式写 returnType。不写不会让 schema 报错,但会让类型检查失去意义,错误会推迟到运行时。

5. formatString:在字符串里嵌表达式​

formatString 是基础 Catalog 里最复杂、也最常用的函数。它支持在字符串里嵌入:

语法含义
${/user/name}绝对路径
${firstName}相对路径(集合作用域内)
${now()}无参函数调用
${formatDate(value:${/currentDate}, format:'yyyy-MM-dd')}具名参数的函数调用
${upper(${now()})}嵌套函数
\${字面量 ${

示例:

{
"id": "welcome",
"component": "Text",
"text": {
"call": "formatString",
"args": {
"value": "Hello, ${/user/firstName}! Welcome back to ${/appName}."
},
"returnType": "string"
}
}

非字符串值的转换规则和普通绑定一致:数字/布尔用标准表示,null / undefined 变空串,对象/数组 JSON 字符串化。

formatString 的实现需要手动解析并订阅嵌套路径,不能靠普通的「参数变了再重算」;这是 Renderer 的事,但作为 Agent 侧的作者,你要知道:字符串里的路径也是数据依赖,数据一变,整段文本要重算。

6. 校验闭环:prompt → generate → validate​

0.9 是 prompt-first,所以协议把「生成后校验」写成了正式流程:

  1. Prompt:把目标 UI、Catalog 的 JSON Schema、若干合法示例塞进提示词;
  2. Generate:让模型输出 A2UI JSON;
  3. Validate:用 schema 校验;
  4. 失败:把标准格式的错误喂回去,让模型自纠;
  5. 成功:发给客户端渲染。

标准错误格式​

客户端(或代客户端校验的系统)向上报告时,用 client_to_server.json 的 error 消息:

{
"version": "v0.9.1",
"error": {
"code": "VALIDATION_FAILED",
"surfaceId": "booking",
"path": "/components/0/text",
"message": "Expected string, got integer"
}
}

VALIDATION_FAILED 的四个字段都是必需的:code、surfaceId、path、message。path 是 JSON Pointer,指向出错字段,例如 /components/0/text、/components/2/checks/0。

其他错误用通用格式:

{
"version": "v0.9.1",
"error": {
"code": "RENDER_FAILED",
"surfaceId": "booking",
"message": "Component 'Chart' is not implemented"
}
}

通用格式要求 code、surfaceId、message,允许额外字段;code 不能是 VALIDATION_FAILED。

两阶段校验​

规范建议「防御在深度」:

  1. Agent 侧,发送前:拿 Catalog schema 校验生成的 JSON。失败就重试、修,或者降级成纯文本。
  2. 客户端侧,接收后:用本地 Catalog 再校验一遍。防的是版本错配、被篡改的 Agent 输出。失败就发 VALIDATION_FAILED 回去。

再加一层优雅降级:schema 过了,但某个组件渲染器没实现,就渲染 fallback 或跳过该节点,不要让整个 surface 崩掉。

7. Capabilities:谁支持哪些 Catalog​

这是交互闭环的前置条件。客户端必须先告诉 Agent「我能渲染什么」,Agent 才能决定「发什么」。

客户端在 metadata 里带:

{
"a2uiClientCapabilities": {
"v0.9": {
"supportedCatalogIds": [
"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
]
}
}
}

服务端能力(通常在 A2A AgentCard 的 extension params 里):

{
"v0.9": {
"supportedCatalogIds": [
"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
],
"acceptsInlineCatalogs": false
}
}

两点注意:

  • schema 里外层键固定是 "v0.9",即使消息版本是 v0.9.1;
  • inlineCatalogs 只有在服务端 acceptsInlineCatalogs: true 时才应发送。

这些对象不是 A2UI 的一等消息,而是传输层 metadata(A2A、AG-UI、MCP 初始化等各自决定放哪)。协议只规定形状,不规定信封。

8. 把闭环串起来:一次预订​

下面这条链覆盖了「下发 → 输入 → 点击 → 回传 → 更新」。五条消息都过了 schema 校验。

① 创建 surface,开启数据模型同步

{
"version": "v0.9.1",
"createSurface": {
"surfaceId": "booking",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json",
"sendDataModel": true
}
}

② 下发组件:输入框、勾选框、状态文本、提交按钮

{
"version": "v0.9.1",
"updateComponents": {
"surfaceId": "booking",
"components": [
{
"id": "root",
"component": "Column",
"children": ["name_field", "terms_check", "status_text", "submit_label", "submit_btn"]
},
{
"id": "name_field",
"component": "TextField",
"label": "Name",
"value": { "path": "/booking/name" },
"variant": "shortText",
"checks": [
{
"condition": {
"call": "length",
"args": {
"value": { "path": "/booking/name" },
"min": 2
},
"returnType": "boolean"
},
"message": "Name must be at least 2 characters"
}
]
},
{
"id": "terms_check",
"component": "CheckBox",
"label": "I accept the terms",
"value": { "path": "/booking/terms" }
},
{
"id": "status_text",
"component": "Text",
"text": { "path": "/booking/status" },
"variant": "caption"
},
{
"id": "submit_label",
"component": "Text",
"text": "Confirm"
},
{
"id": "submit_btn",
"component": "Button",
"child": "submit_label",
"variant": "primary",
"checks": [
{
"condition": {
"call": "required",
"args": { "value": { "path": "/booking/terms" } },
"returnType": "boolean"
},
"message": "Please accept the terms"
}
],
"action": {
"event": {
"name": "confirm_booking",
"context": {
"name": { "path": "/booking/name" },
"terms": { "path": "/booking/terms" }
}
}
}
}
]
}
}

③ 初始化数据

{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "booking",
"value": {
"booking": {
"name": "",
"terms": false,
"status": "Draft"
}
}
}
}

此时按钮是灰的,因为 terms 是 false,required 失败。

④ 用户输入并提交

用户输入 Alice,勾选条款。本地模型立刻变成:

{ "name": "Alice", "terms": true, "status": "Draft" }

按钮的 check 通过。点击后,客户端解析 context,发出:

{
"version": "v0.9.1",
"action": {
"name": "confirm_booking",
"surfaceId": "booking",
"sourceComponentId": "submit_btn",
"timestamp": "2026-10-20T10:00:00Z",
"context": {
"name": "Alice",
"terms": true
}
}
}

如果开启了 sendDataModel,这条消息的 transport metadata 里还会带完整模型:

{
"version": "v0.9.1",
"surfaces": {
"booking": {
"booking": { "name": "Alice", "terms": true, "status": "Draft" }
}
}
}

⑤ Agent 校验并回写

服务端重新校验业务规则(checks 只是 UX),然后更新状态:

{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "booking",
"path": "/booking/status",
"value": "Confirmed"
}
}

status_text 立刻从 Draft 变成 Confirmed。整条链没有重发组件树。

9. 本篇自检清单​

  • action 只包含 event 或 functionCall 之一。
  • event.name 稳定,能被 Agent 直接 switch。
  • event.context 只带需要的字段;静态值写字面量,动态值写 { path }。
  • 上行 action 的五个字段(含 context)齐全。
  • checks 使用 { condition, message },不是裸 FunctionCall。
  • 每个 check 的 condition 返回 boolean,并显式写 returnType。
  • 服务端对 action 重新做数据完整性校验。
  • 本地函数不依赖网络;openUrl 只允许 http/https。
  • 未知函数名会被 schema 拦住,不要指望运行时兜底。
  • 校验失败时按 VALIDATION_FAILED 的四个字段回报,方便模型自纠。
  • a2uiClientCapabilities 使用 schema 要求的 "v0.9" 外层键。

10. 协议全景回顾​

到这里,0.9 的核心已经闭合了:

Catalog(词汇表 + 安全白名单)
↓
createSurface(建画布、锁 catalog、可选同步数据模型)
↓
updateComponents(扁平邻接表,root 出现即建树)
↓
updateDataModel(JSON Pointer,细粒度 upsert/delete)
↓
用户输入(本地双向绑定,同步写模型)
↓
action.event(解析 context,发回服务端)
↓
Agent 校验、更新、再下发
↓
deleteSurface(移除)

四句话总结 A2UI 0.9:

  1. UI 是数据:组件树是扁平 JSON,不是可执行代码。
  2. 状态是路径:组件通过 JSON Pointer 绑定到每个 surface 的数据模型。
  3. 交互分两条路:通知 Agent 用 event,纯本地行为用 functionCall,拦在发送前用 checks。
  4. 安全靠白名单:组件、函数、主题都必须先在 Catalog 里声明;运行时不去下载、不执行任意代码。

回到系列首页:A2UI 入门。如果你要接着做实现,建议的下一步不是写渲染器,而是先把 Catalog schema + 校验器 + 一个最小 Agent 生成循环跑通:只要能让模型稳定产出能过 schema 的 JSONL,A2UI 的最难部分就已经过去了。