跳到主要内容

02 · 组件模型与目录

本章讲什么: 为什么 A2UI 把 UI 表示成「一张扁平 ID 列表」而不是嵌套树?为什么这恰好对流式和 LLM 友好?以及 A2UI 安全模型的核心——目录(catalog)白名单。

1. 邻接表模型:UI 是一张扁平列表,不是嵌套树

它要解决的小问题

如果 UI 用嵌套 JSON(孩子套在父亲里),LLM 必须一次性生成结构完整、括号配平的整棵树才能渲染——既难增量,也难乱序。A2UI 反其道:所有组件平铺成一个列表,父子关系靠 id 引用(a2ui_protocol.md:642-650)。这叫邻接表模型(adjacency list:用「谁指向谁」的引用列表表示图/树,而非物理嵌套)。

长什么样

// 示意,非源码:容器用 children/child 引用孩子的 id
[
{"id": "root", "component": "Column", "children": ["title", "btn"]},
{"id": "title", "component": "Text", "text": "Welcome"},
{"id": "btn", "component": "Button", "child": "btn_label"}
]

客户端把它们全塞进一个 Map<id, Component>,渲染时再顺着 root 的引用把树「拼」出来。

为什么这设计妙(三个收益)

  1. 乱序无所谓。 服务端可以任意顺序发组件;title 先到还是 root 先到都行,反正最后靠 ID 拼。
  2. root 是渲染开关。 树里必须恰好有一个 id: "root" 的组件做根。root 没到之前,其他更新「无可见效果、被缓冲」;root 一到就开画,缺失的引用先跳过/占位(a2ui_protocol.md:650)。
  3. 增量改 = 重发同 ID。 想改某组件,重发它的 id 即可原地更新(见 01 章 §3.2)。

实现侧的落点

组件在内核里就是 ComponentModel——一个拿着 idtypeproperties 的小对象,属性更新时发 onUpdated 事件(component-model.ts:22-56)。它们由 SurfaceComponentsModel 这张 Map<string, ComponentModel> 统一管(surface-components-model.ts:24),addComponent 撞 ID 会抛错。这正是规范说的「客户端把组件存进 map」。

注意:Map 不保证「root 在场才渲染」这条语义本身——那条由上层渲染器(如 a2ui-surface)按 root 引用遍历时自然实现;SurfaceComponentsModel 只负责存取。

2. 组件对象的结构

每个组件对象(a2ui_protocol.md:427-431):

  • id(必填):实例唯一标识,用于父子引用。
  • component(必填):类型名,如 "Text"
  • 其余属性:该类型特有的(texturlchildren…)直接平铺在对象上。

实现里 processUpdateComponentsMessage 正是这么拆的:const {id, component, ...properties} = comp;(message-processor.ts:298)——把 id/component 拎出来,剩下一股脑当 properties 存。

3. 目录(Catalog):A2UI 的安全心脏

思路

光把 UI 变成数据还不够安全——还得限定「数据里能出现哪些组件」。目录就是这份白名单:它声明本应用支持哪些组件、哪些函数,以及每个的 JSON Schema(a2ui_protocol.md:436-447)。Agent 只能生成目录里有的东西;客户端只渲染目录里有的东西。两头都被钉死在同一份契约上。

实现:Catalog

// 真实源码 catalog/types.ts:123-191(精简)
export class Catalog<T extends ComponentApi> {
readonly id: string;
readonly components: ReadonlyMap<string, T>; // 组件名 → API(含 Zod schema)
readonly functions: ReadonlyMap<string, FunctionImplementation>;
readonly invoker: FunctionInvoker; // 按名调用函数,自带参数校验
// ...
}

两点精华:

  1. 组件/函数都是 Map 按名查,O(1) 命中(v1.0 规范也把函数定义重构成对象 map 求 O(1) 查找,a2ui_protocol.md:40)。
  2. invoker 自带运行时安全: 调函数前先 fn.schema.parse(rawArgs) 用 Zod 强制校验并剥离非法参数,失败抛 A2uiExpressionError(catalog/types.ts:170-190)。即「目录定义的函数」也是被 schema 守住的。

安全检查的第一道闸

建面时 MessageProcessorcatalogId 找目录,找不到直接抛错(message-processor.ts:269-272)。没有目录的 surface 根本建不起来——这把「未知组件来源」挡在门外。

4. 组件如何声明自己:Zod schema

基础目录里每个组件是一个 {name, schema} 对象,schema 用 Zod 写。看 Text(basic_catalog/components/basic_components.ts:40-55):

// 真实源码(精简)
export const TextApi = {
name: 'Text',
schema: z.object({
...CommonProps, // 如 weight、accessibility
text: DynamicStringSchema.describe('要显示的文本,支持简单 Markdown'),
variant: z.enum(['h1','h2',...,'body']).default('body').optional(),
}).strict(),
} satisfies ComponentApi;

注意 text 的类型不是普通 string,而是 DynamicStringSchema——意味着它可以是字面量、也可以是 {path: ...} 数据绑定、也可以是函数调用。这个「Dynamic*」类型族是数据绑定的核心(下章 03),也是 GenericBinder 能「读 schema 自动决定绑法」的依据(04 章)。

.strict() / unevaluatedProperties: false 保证 agent 不能塞目录没声明的属性。

5. 规范对自定义目录的硬约束(为什么这么严)

大多数生产应用会自定义目录反映自家设计系统。但规范给自定义目录立了一堆「严格结构规则」(a2ui_protocol.md:471-533),目的是让目录能被可靠地翻成 LLM 友好的 DSL、映射到各语言 SDK、被校验器检查。挑几条关键的:

规则说的是什么为什么
组件判别符每个组件 schema 必须有 component: {const: "<键名>"}anyComponent 能按 component 字段路由分发
子引用必须用 ComponentId存别的组件 id 的字段要 $ref: ComponentId,不能用裸 string校验器靠这个识别「结构链接」,才能检查父引用的孩子是否存在(a2ui_protocol.md:158-167)
列表引用用 ChildList存孩子列表/模板的字段用 ChildList同上,让校验器知道这是结构而非静态文本
函数元数据函数要带 returnTypecallableFromcallableFrom 是 §双向 RPC 的安全边界来源
命名规则组件/函数/参数名必须符合 UAX #31(标识符规则)跨语言兼容,代码生成不踩雷

这条「子引用必须用 ComponentId 类型」很巧:校验器是靠类型来区分「这是指向另一个组件的链接」还是「这是一段普通字符串(URL/标签)」的。用错类型,校验器就不会去检查目标组件是否存在。

6. 基础目录有哪些组件/函数

基础目录(catalogs/basic/catalog.json)给了开箱即用的一套(a2ui_protocol.md:992-1033):

  • 展示类: Text、Image、Icon、Video、AudioPlayer。
  • 布局类: Row、Column、List、Card、Tabs、Divider、Modal。
  • 交互类: Button、CheckBox、TextField、DateTimeInput、ChoicePicker、Slider。
  • 函数类: 校验(required/regex/length/numeric/email)、格式化(formatString/formatNumber/formatCurrency/formatDate/pluralize)、逻辑(and/or/not)、动作(openUrl),以及系统函数 @index(列表迭代时返回当前下标)。

系统命名空间规则:@ 开头的函数名(如 @index)是跨目录的「系统上下文求值」,自定义目录禁止定义 @ 前缀函数(a2ui_protocol.md:1015)。

7. 代码地图

主题文件符号
单组件状态renderers/web_core/src/v0_9/state/component-model.tsComponentModel
组件集合(Map)renderers/web_core/src/v0_9/state/surface-components-model.tsSurfaceComponentsModeladdComponent
目录类renderers/web_core/src/v0_9/catalog/types.tsCataloginvokerComponentApi
组件 schema 范例renderers/web_core/src/v0_9/basic_catalog/components/basic_components.tsTextApiImageApiCommonProps
组件拆解renderers/web_core/src/v0_9/processing/message-processor.tsprocessUpdateComponentsMessage
目录规范specification/v1_0/docs/a2ui_protocol.md「The component catalog」「Catalog Schema Rules」节