本文档描述在 Crush 终端界面中实现 Google A2UI(Agent to UI) 协议的完整技术路线:以 声明式 JSON 为输入,由 受信任的 TUI 组件子集(catalog) 渲染,并支持 用户操作回传 至 agent 循环。
- 协议参考:A2UI 官网、google/A2UI。
- Crush 架构参考:
internal/ui/AGENTS.md、internal/message/content.go、internal/ui/chat/。
1. 目标与非目标
1.1 目标
1. 协议对齐:能解析并应用 A2UI 消息流中的核心概念(Surface、组件列表、数据模型、根节点、版本字段),与 v0.8(稳定)为主,预留 v0.9(草案)适配层。 2. 安全渲染:仅渲染 catalog 中显式允许 的组件类型;拒绝未知组件、超限深度、非法路径绑定。 3. 终端可用:在 Bubble Tea 体系内,以 字符串渲染 + 可选交互 的方式呈现表单类界面(非 Web 级像素布局)。 4. 可演进:解析、布局、交互、与 agent 的耦合分层清晰,便于后续扩展组件或对接外部渲染器。
1.2 非目标(首版可不实现)
- 完整实现 A2UI 全量标准 catalog(含复杂图表、富媒体、精确主题 token)。
- 与 Web/React/Flutter 渲染器 像素级一致。
- 在终端内嵌 WebView 或执行任意脚本。
- 替代现有 Markdown 主流程;A2UI 作为 补充内容类型 存在。
2. A2UI 与 Crush 的映射关系
| A2UI 概念 | Crush 侧落点 |
|---|---|
| Message(JSON 对象流 / JSONL) | 助手流式输出中的 独立通道 或 结构化 ContentPart(见 §5)。 |
| Surface | 会话内 surfaceId → 运行时状态(组件表、数据模型、root、版本)。 |
| Catalog | 仓库内 TUI Catalog 清单(Go 常量 + JSON Schema 子集或手写校验)。 |
| Component 树 | 内存中的 有向图(id → component),布局阶段展开为 终端行块。 |
| Data model / path 绑定 | 子集 JSON Pointer 或 v0.9 的 path 语义,映射到 map[string]any 或 typed 视图。 |
| User action | 转为 结构化用户消息 或 专用 tool result,由 QueryEngine 下一轮消费。 |
3. TUI Catalog 子集(建议首版)
下列组件与 A2UI 常见命名对齐;实现时以 官方 schema/文档 为准做字段级对照,下列为 能力规划。
3.1 只读 / 结构
| 组件 | TUI 表现 | 备注 |
|---|---|---|
Text | 纯文本行,支持 usageHint 映射为标题/加粗/次要样式 | 宽度受 cappedMessageWidth 约束 |
Divider | 单行分隔符(Unicode 或 styled line) | |
Row / Column(若协议或扩展中有容器语义) | 垂直/水平拼接 lipgloss 块 | 若无原生名,可用 Card 内嵌或自定义 Stack 扩展(仅 TUI catalog) |
3.2 表单与交互(核心)
| 组件 | TUI 表现 | 输入验证 |
|---|---|---|
TextField | 单行输入;焦点内接收按键 | 最大长度、可选 pattern |
MultilineTextField | 简化为「多行模板 + 外部编辑器打开」或仅单行 MVP | 首版可降级为 TextField |
Checkbox | Space 切换 | 绑定 bool |
RadioGroup / Select | 数字键或 j/k 选择 + Enter 确认 | 枚举值 |
Button | Enter 触发 action | 与 focus 环集成 |
DateTimeInput | 降级:ISO8601 文本输入 + 格式校验提示 | 无图形日历为预期限制 |
3.3 显式不支持(首版拒绝并记录)
- 任意 HTML/Web 专用组件。
- 需精确触控手势的控件。
- 未在 TUI catalog 注册的
component类型。
---
4. 总体架构
┌─────────────────────────────────────────────────────────────────┐
│ LLM / Provider 流式输出 │
└───────────────────────────────┬─────────────────────────────────┘
│
┌──────────▼──────────┐
│ A2UI 分帧器 │ JSONL / 增量 chunk 解析
│ (internal/a2ui) │
└──────────┬──────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌────────▼────────┐ ┌──────▼──────┐ ┌───────▼────────┐
│ Surface 运行时 │ │ 数据模型 │ │ 布局 + 样式 │
│ surface store │ │ path 绑定 │ │ lipgloss 块 │
└────────┬────────┘ └──────┬──────┘ └───────┬────────┘
│ │ │
└─────────────────┼─────────────────┘
│
┌──────────▼──────────┐
│ A2UIMessageItem │ list.Item / MessageItem
│ + KeyEventHandler │ 与 Chat 列表集成
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ UI 主模型 │ 焦点路由、回传 cmd
│ model/ui.go │
└───────────────────┘
建议新增 Go 包(名称可调整):
internal/a2ui/:协议无关核心 — 消息解析、校验、Surface 状态机、path 读写。internal/ui/a2ui/:视图层 — TUI 布局、组件渲染、与styles.Styles集成。- (可选)
internal/a2ui/catalog/tui_v1.json:机器可读 catalog 描述,供测试与文档生成。
- 消息:
internal/message中新增ContentPart实现或并行存储策略(§5)。 - 聊天项:
internal/ui/chat/messages.go的ExtractMessageItems扩展分支。 - 按键:
MessageItem的KeyEventHandler(见messages.go)与UI.Update焦点路由。
5. 消息载体设计(关键决策)
两种主流方案,推荐 A + 后期 B。
5.1 方案 A:专用 ContentPart(推荐)
新增例如 A2UIContent struct { Raw string; Version string; SurfaceID string },或按帧存储 []A2UIFrame。
- 优点:与
TextContent分离,历史记录清晰;持久化/sqlc 扩展路径明确。 - 缺点:需在 fantasy/各 provider 的流式处理中 识别并剥离 A2UI 块(若模型混排文本与 JSONL)。
5.2 方案 B:文本围栏(过渡)
助手 TextContent 内使用约定围栏,例如:
a2ui
{ "surfaceUpdate": ... }
- 优点:无需改 provider 协议层即可试验。
- 缺点:模型易破坏围栏;与流式切片拼接复杂;不利于二进制安全边界。
5.3 方案 C:专用 Tool(可选)
定义内置 tool a2ui_emit,参数为 JSONL 字符串,结果由 UI 拦截不展示原始 JSON。
- 优点:与 tool 循环天然一致,权限模型可复用。
- 缺点:占用 tool 轮次;对「纯对话输出 UI」的模型不友好。
---
6. 解析与流式语义
6.1 输入形态
- 支持 JSONL:每行一个完整 JSON 对象(与官方示例一致)。
- 支持 不完整行缓冲:流式 chunk 拼接至
\n再json.Unmarshal。
6.2 状态机(按 v0.8 概念命名,实现时对照官方)
1. 收到 surfaceUpdate(或 v0.9 的 updateComponents)→ 合并组件表(按 id 覆盖)。
2. 收到 dataModelUpdate / updateDataModel → 合并数据到对应 surface。
3. 收到 beginRendering / createSurface → 标记可渲染,记录 root。
4. surfaceId 缺失或重复创建 → 校验规则在 §8。
6.3 与助手「正在输入」的 UI
- 未完成
beginRendering前:可显示 「正在构建界面…」 占位(类似 tool pending)。 - 允许 增量 更新:每帧重算布局缓存失效策略(§11)。
7. 布局引擎(TUI)
7.1 原则
- 块级布局:每个组件渲染为
lipgloss字符串,父级做JoinVertical/JoinHorizontal。 - 宽度:统一使用现有
cappedMessageWidth(width)(见chat/messages.go),子组件再内缩进。 - 焦点顺序:DFS 或按 YAML 声明的
tabIndex(若协议无则按组件数组顺序)。
7.2 缓存
A2UIMessageItem嵌入cachedMessageItem模式(同user.go/assistant.go)。- 失效条件:任一帧导致 surface 版本递增、数据模型变更、终端宽度变化。
8. 安全与校验
1. 组件白名单:仅 catalog 内类型;json.RawMessage 延迟解析具体字段。
2. 深度与数量上限:最大组件数、最大嵌套(通过 child id 解析)、最大字符串长度。
3. path 约束:禁止 .. 或越界写入;仅允许绑定到 surface 私有数据树根键。
4. action 名:[a-zA-Z0-9_.-]+ 长度上限;payload 仅 JSON 对象且键白名单(若协议允许 payload)。
5. 日志:拒绝原因写入审计(若启用 permission v2 审计通道),开发日志大写首句(符合项目 log 规范)。
---
9. 用户操作回传(闭环)
9.1 事件模型
用户聚焦 Button 按 Enter → 生成事件:
{
"type": "a2ui_action",
"surface_id": "booking",
"action": "confirm_booking",
"context": { "path": "/booking", "snapshot": { ... } }
}
9.2 注入 QueryEngine 的方式(二选一或配置)
1. 合成 User 消息:文本为简短说明 + 上述 JSON 围栏,系统提示要求 coder 将 JSON 视为机器事件。
2. 内部通道:扩展 SendMsg 或等价结构,不经过用户可见文本,直接交给 QueryEngine 的下一轮(需改 agent 层,侵入性较高)。
建议:首版用 1(实现快);迭代 2(体验与多模态一致)。
9.3 与焦点的关系
遵循 internal/ui/AGENTS.md:不在子组件内开独立 Update。A2UIMessageItem.HandleKeyEvent 返回 tea.Cmd,由 UI.Update 聚合后触发「发送事件」命令。
---
10. 持久化与历史
ContentPart持久化:扩展 SQLite 序列化(与现有Parts存储方式一致,需跟internal/db迁移对齐)。- 会话重载:从 DB 还原 surface 状态;若仅存储原始帧,启动时重放 状态机重建运行时。
11. 测试策略
| 层级 | 内容 |
|---|---|
| 单元测试 | JSONL 分帧、状态机合并、path 读写、白名单拒绝 |
| 金样 / 快照 | 固定 surface 渲染字符串(注意 ANSI 稳定性,可 Strip 后比) |
| 集成测试 | Mock provider 返回 A2UI 流,验证 MessageItem 数量与按键回传 |
AGENTS.md 中 config.UseMockProviders)。---
12. Step-by-step 实施阶段(完整步骤)
以下每阶段结束应有 可运行二进制 + 可演示路径 + 测试。
Phase 0:规格冻结与对齐(1 周量级,视人力)
步骤:
1. 阅读 A2UI v0.8 官方消息类型列表,建立 字段对照表(Google Sheet 或 docs/a2ui-field-mapping.md)。
2. 冻结 TUI Catalog v1 列表(§3)与 明确不支持 集合。
3. 选定 消息载体(§5):确认采用 A 或 A+C。
验收: 评审通过的 markdown + 样例 JSONL(含错误样例)。
---
Phase 1:解析包与状态机(无 UI)
步骤:
1. 新建 internal/a2ui,实现 Decoder:字节流 → []Message 或强类型 sum type。
2. 实现 SurfaceStore:Apply(msg) error,覆盖 v0.8 核心消息。
3. 单元测试:官方文档中的最小 booking 示例重放后,root 与数据模型一致。
验收: go test ./internal/a2ui/... 全绿,不依赖 Bubble Tea。
---
Phase 2:校验与白名单
步骤:
1. 实现 Catalog 接口:ValidateComponent(type string, raw json.RawMessage) error。
2. 默认 TUI catalog 注册表;未知类型策略(跳过 + 诊断)。
3. 限制深度、组件数、字符串长度。
验收: Fuzz 或 table-driven 测试覆盖恶意/畸形输入。
---
Phase 3:只读渲染(无交互)
步骤:
1. 新建 internal/ui/a2ui/renderer.go:输入 Surface + width → string。
2. 仅支持 Text、Divider、简单 Column 式垂直栈。
3. 样式接入 styles.Styles(标题/正文/muted)。
验收: 在最小 demo 程序或临时测试中输出固定字符串快照。
---
Phase 4:Chat 集成(只读)
步骤:
1. 扩展 message 包:A2UIContent(或选定载体)。
2. ExtractMessageItems:当消息含 A2UI 部分时,追加 A2UIMessageItem(可与 AssistantMessageItem 同条消息共存或拆条,需产品决策)。
3. A2UIMessageItem 实现 MessageItem、RawRender、ID(建议 msg.ID + ":a2ui:" + surfaceId)。
验收: Mock 流式输出后,聊天列表中出现格式化 A2UI 块。
---
Phase 5:交互与焦点
步骤:
1. 实现 TextField、Checkbox、Button 的终端编辑状态(最小 viable)。
2. A2UIMessageItem 实现 KeyEventHandler、Focusable(与现有 list 焦点模型对齐)。
3. 在 UI.Update 中,当焦点在 main 且选中项为 A2UI 时转发按键。
验收: 用户可改字段并点击按钮,内存中 action 被捕获(日志或测试桩)。
---
Phase 6:回传 agent
步骤:
1. 定义 a2ui_action JSON schema(内部)。
2. tea.Cmd 发送:调用现有「发送消息」路径,注入合成用户内容或内部通道。
3. 更新 internal/agent 系统提示词模板:说明如何响应 A2UI 事件(文言文模板由项目规范决定)。
验收: 端到端:按钮 → 下一轮 assistant 可见事件并生成回复。
---
Phase 7:流式与性能
步骤:
1. 在 QueryEngine / 流式回调处挂载 A2UI 分帧器,边收边 Apply。
2. 渲染缓存与防抖:同一 tick 多帧合并一次 Invalidate。
3. 大 surface 懒渲染:仅计算可见区域(若 list 已虚拟化,单 item 高度估算需校准)。
验收: 大 JSONL 流下 UI 不卡顿(主观 + go test 基准可选)。
---
Phase 8:持久化
步骤:
1. DB migration:消息 parts 支持新类型。 2. 加载历史会话时重放 A2UI 帧或存储快照(二选一:快照简单,重放更省空间)。
验收: 重启 Crush 后同一 session 中 A2UI 状态一致。
---
Phase 9:v0.9 适配(可选)
步骤:
1. 在解码层根据 version 字段分发到 v0.8 / v0.9 规范化内部 IR(中间表示)。
2. 渲染与状态机仅依赖 IR,降低协议变动成本。
验收: v0.9 文档中的示例可渲染同等 TUI 效果(允许组件降级)。
---
Phase 10:文档与配置
步骤:
1. README 或 docs 中增加用户向说明:如何触发 A2UI、终端限制。
2. 配置项:a2ui.enabled、a2ui.max_components 等(走 internal/config 模式)。
验收: 新贡献者可按文档跑通 mock 场景。
---
13. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 模型输出非合法 JSONL | 强提示词 + 可选 tool 通道;解析失败降级为原始文本块 |
| 终端无真正「控件」导致 UX 差 | 文档明确降级策略;重要表单可走 dialog 模式 |
| 焦点与 textarea 冲突 | 严格沿用 uiFocusMain / uiFocusEditor 路由规则 |
| 协议快速迭代 | IR 层 + 版本字段分发(§Phase 9) |
14. 小结
本方案以 A2UI 协议为输入、Crush 自有 TUI catalog 为信任边界,分阶段落地:解析与状态机 → 只读渲染 → 聊天集成 → 交互与回传 → 流式与持久化。该路线与现有 Message / MessageItem / 中央 UI 路由模式兼容,且保留未来将同一 A2UI 流 旁路到桌面/Web 客户端 的可能性。