创建日期:2026-10-20 | 最近更新:2026-10-20 基准:A2UI v0.9.1,
common_types.json、catalogs/basic/catalog.json、server_to_client.json。本文的组件字段表直接抄自 schema,不抄概念文档。
组件模型与目录:扁平邻接表,加一份可替换的词汇表
这一篇回答四个问题:
- 一个组件对象到底长什么样?
- 为什么 A2UI 用扁平列表 + ID 引用,而不是嵌套树?
- 18 个基础组件各自有哪些字段?
- Catalog 是什么,为什么说它是 A2UI 安全性的根?
1. 组件对象:三个必备要素
updateComponents.components 里的每个元素都是一个组件对象:
{
"id": "submit_label",
"component": "Text",
"text": "Send",
"variant": "body"
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | ComponentId(string) | 是 | 当前 surface 内唯一,供父子引用 |
component | string | 是 | 组件类型名,例如 Text、Button |
| 其余属性 | 由 Catalog 决定 | 视类型 | 平铺在对象上,不再包一层类型对象 |
注意最后一行:v0.9 的组件属性是平铺的。v0.8 那种 {"component": {"Text": {...}}} 的包裹结构已经废弃。
Schema 对组件对象的约束非常严:每种组件都 unevaluatedProperties: false。这意味着:
- 写了 Catalog 没定义的属性 → 校验失败;
- 写了别的组件的属性 → 校验失败;
- 写了已废弃的属性(例如
Button.primary、Button.enabled)→ 校验失败。
仓库测试用例里专门有两组「应该失败」的样例:btn_dep 带 primary: true,btn2 带 enabled。它们被标记为 valid: false。所以不要凭记忆写字段,对着 Catalog 写。
2. 邻接表:为什么不是一棵嵌套的树
A2UI 的组件树在传输时是一张扁平表。父子关系靠 ID 字符串引用:
{
"id": "root",
"component": "Column",
"children": ["title", "profile_row"]
}
{
"id": "title",
"component": "Text",
"text": "Profile"
}
客户端收到后,做两件事:
- 把每个组件存进
Map<id, Component>; - 从
root开始,按children/child里的 ID 递归还原树。
用一张表表示就是:
| 存储(Map) | 内容 |
|---|---|
root | { component: "Column", children: ["title", "profile_row"] } |
title | { component: "Text", text: "Profile" } |
profile_row | { component: "Row", children: ["avatar", "name_col"] } |
| ... | ... |
为什么不用嵌套树?规范给了三个理由,我把它翻译成工程语言:
- LLM 更容易增量生成。每生成完一个组件就可以发出去,不必等整棵树闭合。
- 更新是局部的。改一个按钮,只发这个按钮的
id和新属性,不用重发父节点。 - 顺序无关。流式到达时,父组件可以先到,子组件后到;客户端缓冲即可。
代价是引用完整性要自己保证:children 里写了一个不存在的 ID,schema 不会报错(它只看类型),客户端只能跳过。这个检查通常由 Catalog validator 或 Agent SDK 做。
root 的唯一性
规范要求「恰好一个 id 为 root 的组件」。schema 本身不强制唯一,因为它是逐个组件校验的;唯一性是跨组件的语义约束。校验器需要额外遍历一遍。
root 出现之前,客户端不应把任何组件当成可见 UI;出现之后,从它开始建树。没有 root 的 surface 等于一块空白画布。
3. ChildList:静态孩子与模板孩子
容器组件的 children 不是简单的字符串数组,而是 ChildList,有两种形态。
形态一:静态数组
{
"id": "toolbar",
"component": "Row",
"children": ["btn1", "btn2", "btn3"]
}
数组里是 ComponentId,顺序就是渲染顺序。
形态二:模板对象
{
"id": "employee_list",
"component": "List",
"children": {
"path": "/employees",
"componentId": "employee_card"
}
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
path | string | 是 | 指向数据模型里的数组 |
componentId | ComponentId | 是 | 用作模板的组件 ID |
语义:客户端遍历 path 指向的数组,为每个元素实例化一次 componentId 指向的组件。模板内部使用的相对路径会以当前元素为作用域(下一篇详讲)。
ChildList 只有这两种形态,additionalProperties: false。所以下面这种写法是错的:
// 错:多了一个字段
{
"path": "/employees",
"componentId": "employee_card",
"itemKey": "id"
}
也不允许把子组件「内联」写在父组件里。所有子组件都必须是独立对象,靠 ID 被引用。
4. 所有组件共享的属性
除了各自的字段,组件还可以带这些通用属性:
| 属性 | 类型 | 说明 |
|---|---|---|
id | ComponentId | 必需,surface 内唯一 |
accessibility | object | { "label": DynamicString, "description": DynamicString },供读屏等辅助技术使用 |
weight | number | 类似 CSS flex-grow;只能用在 Row / Column 的直接子组件上 |
weight 的限制容易被忽略:schema 里它定义在 CatalogComponentCommon,所有组件都「允许」有这个字段;但描述明确说它只在 Row/Column 的直接子级有意义。写在别处不会报错,但语义未定义。
5. 基础 Catalog 的 18 个组件
catalogs/basic/catalog.json 定义了 18 个组件。下表按「布局 / 展示 / 容器 / 交互」分组,字段以 schema 为准。
布局
| 组件 | 必填字段 | 关键可选字段 |
|---|---|---|
Row | children | justify(start / center / end / spaceBetween / spaceAround / spaceEvenly / stretch)、align(start / center / end / stretch) |
Column | children | justify、align,取值同 Row |
List | children | direction(vertical / horizontal)、align |
三个容器都接受静态数组或模板对象形式的 children。
展示
| 组件 | 必填字段 | 关键可选字段 |
|---|---|---|
Text | text(DynamicString) | variant:h1–h5 / caption / body,默认 body |
Image | url(DynamicString) | description、fit(contain / cover / fill / none / scaleDown)、variant(icon / avatar / smallFeature / mediumFeature / largeFeature / header) |
Icon | name | name 可以是枚举字符串、{ "svgPath": "..." },或 DataBinding |
Video | url(DynamicString) | — |
AudioPlayer | url(DynamicString) | description |
Divider | — | axis:horizontal / vertical,默认 horizontal |
Icon 的枚举是固定的一组系统图标名(accountCircle、mail、search、settings……),不是任意字符串。具体清单在 catalog.json 的 components.Icon.properties.name.oneOf[0].enum。
容器
| 组件 | 必填字段 | 说明 |
|---|---|---|
Card | child | 只能有一个孩子;要放多个元素,先用 Column / Row 包起来 |
Tabs | tabs | 数组,每项 { "title": DynamicString, "child": ComponentId } |
Modal | trigger、content | trigger 是打开弹窗的组件 ID,content 是弹窗内容组件 ID |
交互
| 组件 | 必填字段 | 关键可选字段 |
|---|---|---|
Button | child、action | variant(default / primary / borderless)、checks |
TextField | label | value(DynamicString)、variant(longText / number / shortText / obscured)、validationRegexp、checks |
CheckBox | label、value(DynamicBoolean) | checks |
ChoicePicker | options、value(DynamicStringList) | label、variant(multipleSelection / mutuallyExclusive)、displayStyle(checkbox / chips)、filterable、checks |
Slider | value(DynamicNumber)、max | min(默认 0)、label、checks |
DateTimeInput | value(DynamicString) | enableDate、enableTime(都默认 false)、min、max、label、checks |
ChoicePicker.options 的每一项是 { "label": DynamicString, "value": string },其中 value 是选中后写进数据模型的稳定值,不是显示文本。
6. 警告:概念文档里的字段名有几处已经过期
如果你先读的是 Component Gallery,会看到下面这些字段。它们和当前 v0.9.1 的 schema 不一致:
| 文档里写的 | schema 里实际的 | 组件 |
|---|---|---|
tabItems | tabs | Tabs |
entryPointChild / contentChild | trigger / content | Modal |
minValue / maxValue | min / max | Slider |
textFieldType | variant | TextField |
selections | value | ChoicePicker |
我用 Draft 2020-12 对 schema 逐项验证过:按文档字段名写的组件会被 unevaluatedProperties: false 拒绝(多数情况下表现为整个 oneOf 不匹配)。
另外还有两处更隐蔽的不一致:
- Extension 规范里的示例组件写成
{"Text": {"id": "root", "text": "Hello!"}},这是 v0.8 的包裹结构。按 v0.9 schema 校验是INVALID,正确写法是{"id": "root", "component": "Text", "text": "Hello!"}。 client_capabilities.json把 Catalog 的functions描述成数组,但实际 catalog 文件和《Defining Custom Functions》都把它写成以函数名为键的对象。实现时以实际 catalog 文件为准。
结论:A2UI 0.9 的文档页、规范正文、schema、测试用例四者并非完全同步。判断字段是否合法,优先级应当是:
catalog.json / common_types.json(机器可读 schema)
> test/cases/*.json(实际能过校验的样例)
> a2ui_protocol.md(规范正文)
> docs/public/concepts/*.md(概念文档)
7. Catalog:Agent 的「词汇表」与安全边界
Catalog 是一份 JSON Schema 文档,形状是:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/catalogs/profile/v1/catalog.json",
"catalogId": "https://example.com/catalogs/profile/v1/catalog.json",
"components": {
"ProfileCard": { "...": "每个组件的 JSON Schema" }
},
"functions": {
"trim": { "...": "每个函数的 JSON Schema" }
},
"theme": {
"primaryColor": { "type": "string" }
}
}
三个组成部分:
- components:允许 Agent 使用的组件类型;
- functions:允许 Agent 引用的客户端函数;
- theme:
createSurface.theme可选的字段及其校验规则。
catalogId 与 $id 建议设成同一个 URI。它是标识符,不是下载地址:协议明确说 Catalog 必须在客户端和 Agent 编译/部署时就已知,运行时不会去 URI 拉取。
为什么它是安全边界
Agent 只能使用 Catalog 里声明过的组件和函数。客户端不需要(也不应该)动态执行 Agent 发来的任何东西:
- 组件名不在 Catalog → 校验失败;
- 函数名不在 Catalog →
FunctionCall的oneOf校验失败; - 属性不在组件 schema →
unevaluatedProperties: false拒绝。
这就是「safe like data」的机械保证。它不依赖 prompt 里的「请勿生成危险内容」,而是依赖机器可读的白名单。
自定义 Catalog 的两条硬规则
如果你要定义自己的 Catalog,规范要求:
- 单个子组件引用必须用
common_types.json#/$defs/ComponentId,不能写"type": "string"; - 子组件列表/模板必须用
common_types.json#/$defs/ChildList。
原因很实际:校验器靠这些 $ref 识别「哪些字段是结构链接」。如果你用裸 string,校验器会把它当成 URL、标签之类的普通文本,不会检查目标组件是否存在。这会让「引用完整性」检查静默失效。
Catalog 协商
客户端和 Agent 必须就 Catalog 达成一致。流程是:
- 客户端声明:在发给 Agent 的每条消息 metadata 里带
a2uiClientCapabilities,列出supportedCatalogIds。 - Agent 选择:创建 surface 时,从客户端支持的列表里挑一个,写进
createSurface.catalogId。 - 锁定:该 surface 生命周期内不再变;要换只能删了重建。
按 client_capabilities.json,schema 的正确形状是带 v0.9 外层键的:
{
"v0.9": {
"supportedCatalogIds": [
"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json",
"https://my-company.com/catalogs/profile/v1/catalog.json"
],
"inlineCatalogs": []
}
}
注意这个细节:client_capabilities.json 里外层键固定叫 "v0.9",即使你发的是 v0.9.1 消息。部分文档示例省略了这个外层键,或者写成 "v0.9.1",都与 schema 不符。按 schema 写。
inlineCatalogs 只在 Agent 声明 acceptsInlineCatalogs: true 时才应发送;生产环境不建议依赖它。
版本演进规则
Catalog 被当作 API 管理。规范给的兼容规则可以压缩成四句话:
- 只增不减:新增组件、函数、可选属性是安全的;
- 弃用不删除:标
deprecated: true和x-deprecated-reason,渲染器继续兼容旧数据; - 枚举当开放集:新增枚举值不应让老渲染器崩溃;
- 优雅降级:不认识的组件渲染 fallback,不要让整个 surface 失败。
破坏性变更才升 major,并把版本写进 catalogId(例如 .../v2/catalog.json)。迁移期客户端可以同时声明 v1 和 v2,Agent 择优。
校验时怎么接上 Catalog
server_to_client.json 本身是 catalog-agnostic 的:它用占位引用 catalog.json#/$defs/anyComponent 和 catalog.json#/$defs/theme。校验时要做的映射是:
- 用基础目录 → 把
catalog.json指向catalogs/basic/catalog.json; - 用自定义目录 → 把
catalog.json指向你自己的 catalog 文件。
仓库的测试脚本就是这么做的:把 basic catalog 的 $id 改写成 .../catalog.json,再作为 -r 引用传给 ajv。
8. 一个完整的组件树
下面这条消息创建一块 profile 卡片,包含 Card、Column、Row、Image、Text、Button。已通过 schema 校验。
{
"version": "v0.9.1",
"createSurface": {
"surfaceId": "profile_demo",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json",
"theme": {
"primaryColor": "#00BFFF",
"agentDisplayName": "Profile Bot"
}
}
}
{
"version": "v0.9.1",
"updateComponents": {
"surfaceId": "profile_demo",
"components": [
{
"id": "root",
"component": "Column",
"children": ["title", "profile_row", "submit_btn"],
"align": "stretch"
},
{
"id": "title",
"component": "Text",
"text": "# Profile",
"variant": "h2"
},
{
"id": "profile_row",
"component": "Row",
"children": ["avatar", "name_col"],
"align": "center"
},
{
"id": "avatar",
"component": "Image",
"url": "https://example.com/avatar.png",
"fit": "cover",
"variant": "avatar"
},
{
"id": "name_col",
"component": "Column",
"children": ["name", "role"]
},
{
"id": "name",
"component": "Text",
"text": { "path": "/user/name" },
"variant": "h1"
},
{
"id": "role",
"component": "Text",
"text": { "path": "/user/role" },
"variant": "caption"
},
{
"id": "submit_label",
"component": "Text",
"text": "Book"
},
{
"id": "submit_btn",
"component": "Button",
"child": "submit_label",
"variant": "primary",
"action": {
"event": {
"name": "submit_booking",
"context": {
"userId": { "path": "/user/id" }
}
}
}
}
]
}
}
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "profile_demo",
"path": "/user",
"value": {
"id": "u_42",
"name": "Alice",
"role": "Engineer"
}
}
}
树的形状:
Column(root)
├── Text(title)
├── Row(profile_row)
│ ├── Image(avatar)
│ └── Column(name_col)
│ ├── Text(name) ← /user/name
│ └── Text(role) ← /user/role
└── Button(submit_btn)
└── Text(submit_label)
注意 submit_btn 的 child 是 submit_label:按钮的可见内容来自被引用组件,而不是按钮自己的 text 字段。Card、Button 都是「单孩子」容器;Row、Column、List 才用 children。
9. 本篇自检清单
- 组件对象是扁平的:
id+component+ 平铺属性。 - 没有使用 v0.8 的
{"component": {"Text": {...}}}包裹。 - 没用文档里过期的
tabItems/entryPointChild/minValue/textFieldType/selections。 -
Card/Button用child(单数),Row/Column/List用children。 -
children要么是 ID 数组,要么是{ path, componentId },没有第三种。 - 组件树里恰好一个
root。 - 自定义 Catalog 的子组件字段用了
ComponentId/ChildList引用。 - 没指望运行时去
catalogId下载 Catalog。 - 自定义组件没有多写属性(
unevaluatedProperties: false)。
下一篇:数据绑定——JSON Pointer 怎么解析,模板里的相对路径到底相对于谁,输入组件的「双向绑定」边界在哪,以及 sendDataModel 为什么能把 Agent 做成无状态。