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 的引用把树「拼」出来。
为什么这设计妙(三个收益)
- 乱序无所谓。 服务端可以任意顺序发组件;
title先到还是root先到都行,反正最后靠 ID 拼。 root是渲染开关。 树里必须恰好有一个id: "root"的组件做根。root没到之前,其他更新「无可见效果、被缓冲」;root一到就开画,缺失的引用先跳过/占位(a2ui_protocol.md:650)。- 增量改 = 重发同 ID。 想改某组件,重发它的
id即可原地更新(见01章 §3.2)。
实现侧的落点
组件在内核里就是 ComponentModel——一个拿着 id、type、properties 的小对象,属性更新时发 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"。- 其余属性:该类型特有的(
text、url、children…)直接平铺在对象上。
实现里 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; // 按名调用函数,自带参数校验
// ...
}
两点精华:
- 组件/函数都是
Map按名查,O(1) 命中(v1.0 规范也把函数定义重构成对象 map 求 O(1) 查找,a2ui_protocol.md:40)。 invoker自带运行时安全: 调函数前先fn.schema.parse(rawArgs)用 Zod 强制校验并剥离非法参数,失败抛A2uiExpressionError(catalog/types.ts:170-190)。即「目录定义的函数」也是被 schema 守住的。
安全检查的第一道闸
建面时 MessageProcessor 按 catalogId 找目录,找不到直接抛错(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 | 同上,让校验器知道这是结构而非静态文本 |
| 函数元数据 | 函数要带 returnType 和 callableFrom | callableFrom 是 §双向 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.ts | ComponentModel |
| 组件集合(Map) | renderers/web_core/src/v0_9/state/surface-components-model.ts | SurfaceComponentsModel、addComponent |
| 目录类 | renderers/web_core/src/v0_9/catalog/types.ts | Catalog、invoker、ComponentApi |
| 组件 schema 范例 | renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts | TextApi、ImageApi、CommonProps |
| 组件拆解 | renderers/web_core/src/v0_9/processing/message-processor.ts | processUpdateComponentsMessage |
| 目录规范 | specification/v1_0/docs/a2ui_protocol.md | 「The component catalog」「Catalog Schema Rules」节 |