跳到主要内容

创建日期:2026-10-20 | 最近更新:2026-10-20 基准:A2UI v0.9.1,common_types.json、catalogs/basic/catalog.json、server_to_client.json。本文的组件字段表直接抄自 schema,不抄概念文档。

组件模型与目录:扁平邻接表,加一份可替换的词汇表

这一篇回答四个问题:

  1. 一个组件对象到底长什么样?
  2. 为什么 A2UI 用扁平列表 + ID 引用,而不是嵌套树?
  3. 18 个基础组件各自有哪些字段?
  4. Catalog 是什么,为什么说它是 A2UI 安全性的根?

1. 组件对象:三个必备要素​

updateComponents.components 里的每个元素都是一个组件对象:

{
"id": "submit_label",
"component": "Text",
"text": "Send",
"variant": "body"
}
字段类型必填说明
idComponentId(string)是当前 surface 内唯一,供父子引用
componentstring是组件类型名,例如 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"
}

客户端收到后,做两件事:

  1. 把每个组件存进 Map<id, Component>;
  2. 从 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"] }
......

为什么不用嵌套树?规范给了三个理由,我把它翻译成工程语言:

  1. LLM 更容易增量生成。每生成完一个组件就可以发出去,不必等整棵树闭合。
  2. 更新是局部的。改一个按钮,只发这个按钮的 id 和新属性,不用重发父节点。
  3. 顺序无关。流式到达时,父组件可以先到,子组件后到;客户端缓冲即可。

代价是引用完整性要自己保证: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"
}
}
字段类型必填说明
pathstring是指向数据模型里的数组
componentIdComponentId是用作模板的组件 ID

语义:客户端遍历 path 指向的数组,为每个元素实例化一次 componentId 指向的组件。模板内部使用的相对路径会以当前元素为作用域(下一篇详讲)。

ChildList 只有这两种形态,additionalProperties: false。所以下面这种写法是错的:

// 错:多了一个字段
{
"path": "/employees",
"componentId": "employee_card",
"itemKey": "id"
}

也不允许把子组件「内联」写在父组件里。所有子组件都必须是独立对象,靠 ID 被引用。

4. 所有组件共享的属性​

除了各自的字段,组件还可以带这些通用属性:

属性类型说明
idComponentId必需,surface 内唯一
accessibilityobject{ "label": DynamicString, "description": DynamicString },供读屏等辅助技术使用
weightnumber类似 CSS flex-grow;只能用在 Row / Column 的直接子组件上

weight 的限制容易被忽略:schema 里它定义在 CatalogComponentCommon,所有组件都「允许」有这个字段;但描述明确说它只在 Row/Column 的直接子级有意义。写在别处不会报错,但语义未定义。

5. 基础 Catalog 的 18 个组件​

catalogs/basic/catalog.json 定义了 18 个组件。下表按「布局 / 展示 / 容器 / 交互」分组,字段以 schema 为准。

布局​

组件必填字段关键可选字段
Rowchildrenjustify(start / center / end / spaceBetween / spaceAround / spaceEvenly / stretch)、align(start / center / end / stretch)
Columnchildrenjustify、align,取值同 Row
Listchildrendirection(vertical / horizontal)、align

三个容器都接受静态数组或模板对象形式的 children。

展示​

组件必填字段关键可选字段
Texttext(DynamicString)variant:h1–h5 / caption / body,默认 body
Imageurl(DynamicString)description、fit(contain / cover / fill / none / scaleDown)、variant(icon / avatar / smallFeature / mediumFeature / largeFeature / header)
Iconnamename 可以是枚举字符串、{ "svgPath": "..." },或 DataBinding
Videourl(DynamicString)—
AudioPlayerurl(DynamicString)description
Divider—axis:horizontal / vertical,默认 horizontal

Icon 的枚举是固定的一组系统图标名(accountCircle、mail、search、settings……),不是任意字符串。具体清单在 catalog.json 的 components.Icon.properties.name.oneOf[0].enum。

容器​

组件必填字段说明
Cardchild只能有一个孩子;要放多个元素,先用 Column / Row 包起来
Tabstabs数组,每项 { "title": DynamicString, "child": ComponentId }
Modaltrigger、contenttrigger 是打开弹窗的组件 ID,content 是弹窗内容组件 ID

交互​

组件必填字段关键可选字段
Buttonchild、actionvariant(default / primary / borderless)、checks
TextFieldlabelvalue(DynamicString)、variant(longText / number / shortText / obscured)、validationRegexp、checks
CheckBoxlabel、value(DynamicBoolean)checks
ChoicePickeroptions、value(DynamicStringList)label、variant(multipleSelection / mutuallyExclusive)、displayStyle(checkbox / chips)、filterable、checks
Slidervalue(DynamicNumber)、maxmin(默认 0)、label、checks
DateTimeInputvalue(DynamicString)enableDate、enableTime(都默认 false)、min、max、label、checks

ChoicePicker.options 的每一项是 { "label": DynamicString, "value": string },其中 value 是选中后写进数据模型的稳定值,不是显示文本。

6. 警告:概念文档里的字段名有几处已经过期​

如果你先读的是 Component Gallery,会看到下面这些字段。它们和当前 v0.9.1 的 schema 不一致:

文档里写的schema 里实际的组件
tabItemstabsTabs
entryPointChild / contentChildtrigger / contentModal
minValue / maxValuemin / maxSlider
textFieldTypevariantTextField
selectionsvalueChoicePicker

我用 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,规范要求:

  1. 单个子组件引用必须用 common_types.json#/$defs/ComponentId,不能写 "type": "string";
  2. 子组件列表/模板必须用 common_types.json#/$defs/ChildList。

原因很实际:校验器靠这些 $ref 识别「哪些字段是结构链接」。如果你用裸 string,校验器会把它当成 URL、标签之类的普通文本,不会检查目标组件是否存在。这会让「引用完整性」检查静默失效。

Catalog 协商​

客户端和 Agent 必须就 Catalog 达成一致。流程是:

  1. 客户端声明:在发给 Agent 的每条消息 metadata 里带 a2uiClientCapabilities,列出 supportedCatalogIds。
  2. Agent 选择:创建 surface 时,从客户端支持的列表里挑一个,写进 createSurface.catalogId。
  3. 锁定:该 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 做成无状态。