14 KiB
tags, create time
| tags | create time | ||||||
|---|---|---|---|---|---|---|---|
|
2026-04-29 16:00 |
第十章:A2UI 协议(流式 UI 组件)(最终章)
概述
本章作为 ChatWithEino Quickstart 的最终章,引入 A2UI 协议——把 Agent 的事件流以 JSONL/SSE 的形式推送到前端,渲染为可增量更新的 UI 组件树。你将掌握 Agent 到 Web 的端到端集成方案,理解为什么 AI 应用需要从纯文本走向结构化、可交互的 UI 呈现。
[!tip] 一句话理解 A2UI
A2UI = Agent 输出 × UI 组件映射。它定义了"Agent 做了什么"如何变成"用户看到了什么":文本 → Text 组件、工具调用 → Chip 卡片、进度更新 → 实时更新……一切通过声明式的组件树实现。
代码位置
- 入口代码:main.go
- Agent 构建:agent.go
- 服务端路由:server/server.go
- A2UI 子集实现:a2ui/types.go
- A2UI 事件流转换:a2ui/streamer.go
- 前端页面:static/index.html
前置条件
与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark)。
运行
在 examples/quickstart/chatwitheino 目录下执行:
go run .
输出示例:
starting server on http://localhost:8080
启动后浏览器访问 http://localhost:8080 即可看到完整的 A2UI 交互界面。
(可选)启用 Skills 能力
最终 Web 版使用的 Agent 构建逻辑与第九章对齐:当 EINO_EXT_SKILLS_DIR 指向一个合法 skills 目录时,会自动注册 skill 中间件,模型就能按需调用 skill 工具加载文档。
go run ./scripts/sync_eino_ext_skills.go -src /path/to/eino-ext -dest ./skills/eino-ext -clean
EINO_EXT_SKILLS_DIR="$(pwd)/skills/eino-ext" go run .
A2UI 的定位与边界
[!important] A2UI 不属于 Eino 框架本身
A2UI 是一个业务层的 UI 协议/渲染方案,不是 Eino 的核心 Component。本章把它集成进前面章节逐步构建出来的 Agent,是为了提供一个端到端、可落地的完整示例:从模型调用、工具调用、工作流编排,到最终把结果以更友好的 UI 方式呈现出来。
真实业务场景中,你完全可以根据产品形态选择不同的 UI 形式:
| 场景 | UI 形式 | 说明 |
|---|---|---|
| Web / App | 自定义组件、表格、卡片、图表 | 最典型的 B/S 架构应用 |
| IM / 办公套件 | 消息卡片、交互式表单 | 飞书、钉钉等平台的富消息 |
| 命令行 | 纯文本或 TUI | Console 版 Agent 的原生形式 |
Eino 关注「可组合的智能执行与编排能力」,而「如何呈现给用户」属于业务层可以自由扩展的一环。
从纯文本到结构化的 UI:为什么需要 A2UI
[!question] 思考一下
如果你要为一个 AI 聊天产品设计更丰富的交互体验,纯文本回复会遇到哪些瓶颈?
纯文本输出的局限:
- ❌ 无法展示结构化数据(表格、列表、卡片等)
- ❌ 无法实时更新(进度条、状态变化等)
- ❌ 无法嵌入交互元素(按钮、表单、链接等)
- ❌ 无法支持多媒体(图片、视频、音频等)
A2UI 的定位:
- ✅ 协议映射:Agent 输出 → UI 组件的声明式映射关系
- ✅ 流式渲染:组件实时更新,无需等待完整响应
- ✅ 增量更新:基于 dataKey 的数据绑定,文本流可逐 token 更新
简单类比:
- 纯文本输出 = "终端命令行"(只能显示文本)
- A2UI = "Web 应用"(可以显示任何 UI 组件)
A2UI v0.8 子集(本示例的边界)
本 quickstart 并没有实现一个"完整的 A2UI 标准库",而是实现了一个 A2UI v0.8 的子集:目标是把 Agent 的事件流,以稳定、可增量渲染的 UI 组件树方式推给浏览器。
当前实现的 A2UI 消息类型与组件类型,以 a2ui/types.go 为准。
A2UI 消息类型:信封结构
每一行 SSE(data: {...})承载一个 A2UI Message,Message 是一个"信封结构",每次只会出现一个字段:
[!note] 关键代码片段
注意:这是简化后的代码片段,不能直接运行,完整代码请参考 a2ui/types.go。
type Message struct {
BeginRendering *BeginRenderingMsg
SurfaceUpdate *SurfaceUpdateMsg
DataModelUpdate *DataModelUpdateMsg
DeleteSurface *DeleteSurfaceMsg
InterruptRequest *InterruptRequestMsg
}
| 消息类型 | 作用 | 触发时机 |
|---|---|---|
BeginRendering |
告诉前端"开始渲染一个 surface",指定根节点 ID | 新会话开始时 |
SurfaceUpdate |
新增/更新一批组件(组件是树,用 id 互相引用) | 创建/修改 UI 结构时 |
DataModelUpdate |
更新 data bindings(用于流式文本增量渲染) | assistant 生成文本时 |
InterruptRequest |
通知前端展示批准/拒绝入口 | Agent 需要人类审批时 |
DeleteSurface |
删除某个 surface | 清理/重置会话时 |
A2UI 组件类型
本示例 UI 组件只实现了 4 种(见 a2ui/types.go):
classDiagram
class ComponentTree {
<<abstract>>
+id string
+children []string
}
class TextComponent {
+text string
+dataKey string
+usageHint string
}
class ColumnLayout {
+spacing float64
+align ItemsAlign
}
class RowLayout {
+spacing float64
+align ItemsAlign
}
class CardContainer {
+title string
+border bool
}
ComponentTree <|-- TextComponent
ComponentTree <|-- ColumnLayout
ComponentTree <|-- RowLayout
ComponentTree <|-- CardContainer
note for TextComponent "支持 dataKey\n流式绑定"
note for ColumnLayout "垂直布局容器"
note for RowLayout "水平布局容器"
note for CardContainer "内容容器\n无布局功能"
各组件职责:
| 组件 | 用途 | 特性 |
|---|---|---|
Text |
文本渲染 | 支持 usageHint(caption/body/title),当存在 dataKey 时文本来自 DataModelUpdate |
Column |
垂直布局 | children 是组件 ID 列表 |
Row |
水平布局 | children 是组件 ID 列表 |
Card |
卡片容器 | children 是组件 ID 列表,仅做视觉分组 |
[!warning] Card ≠ 布局容器
Card不提供任何布局控制(不排布子元素的位置),它只是一个有视觉边界的容器。如需布局请用Column/Row。
A2UI 的实现链路
最终 Web 版的核心链路是三阶段管道:
- 后端运行 Agent:得到
*adk.AsyncIterator[*adk.AgentEvent] - 事件 → A2UI JSONL/SSE 流:转换为 A2UI 消息推送给浏览器(见 a2ui/streamer.go)
- 前端解析并渲染:读取 SSE 流并渲染组件树(见 static/index.html)
服务端路由(高层)
与 A2UI 相关的关键接口(见 server/server.go):
| 方法 | 路径 | 响应 | 说明 |
|---|---|---|---|
| GET | / |
HTML | 返回前端页面 |
| POST | /sessions/:id/chat |
SSE 流 | Agent 运行结果实时渲染 |
| GET | /sessions/:id/render |
JSONL | 回放历史消息 |
| POST | /sessions/:id/approve |
SSE 流 | interrupt 批准后继续执行 |
事件流转换
服务端把 Runner.Run(...) 的事件流交给 a2ui.StreamToWriter(...),后者负责:
flowchart TD
A["AgentEvent 输入"] --> B{事件类型}
B -->|"user"| C["渲染 User 气泡"]
B -->|"assistant"| D["创建 DataModelUpdate\n流式追加文本"]
B -->|"tool call"| E["渲染 ToolCall Chip 卡片"]
B -->|"tool result"| F["渲染 ToolResult Chip 卡片"]
B -->|"interrupt"| G["发送 InterruptRequest\n暂停等待人类审批"]
C --> H["SSE JSONL 输出"]
D --> H
E --> H
F --> H
G --> H
style D fill:#fff3e0
style G fill:#fce4ec
核心处理逻辑:
- User 输出:渲染为用户消息气泡
- Assistant 流式 token:创建
DataModelUpdate,通过dataKey绑定到一个Text组件上,实现"边生成边渲染" - Tool Call / Tool Result:渲染为独立的 chip 卡片
- Interrupt:发送
InterruptRequest,暂停等待人类批准后 resume
前端集成:Fetch + SSE(不是 WebSocket)
前端通过 fetch('/sessions/:id/chat') 发起请求,然后从 res.body 读取流式字节,按行切分并解析 data: {...} 的 JSON:
[!note] 前端代码片段
注意:这是简化后的代码片段,不能直接运行,完整代码请参考 static/index.html。
const res = await fetch(`/sessions/${id}/chat`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
const jsonStr = trimmed.slice(5).trimStart();
processA2UIMessage(JSON.parse(jsonStr));
}
}
}
[!tip] 为什么用 SSE 而不是 WebSocket?
- SSE(Server-Sent Events)是单向的、HTTP 兼容、天然支持断线重连语义
- 对于"服务器推送 UI 事件,客户端只需消费"的场景,SSE 比 WebSocket 更轻量
- 如果未来需要双向交互(如键盘快捷键、实时光标同步),才考虑升级到 WebSocket
A2UI 流式渲染流程
sequenceDiagram
participant U as 用户
participant FE as 前端浏览器
participant BE as 后端 Server
participant AG as Agent
participant LM as LLM
U->>FE: 输入消息
FE->>BE: POST /sessions/:id/chat
BE->>AG: Runner.Run()
AG->>LM: 发送请求
LM-->>AG: token 流式返回
loop 每个 AgentEvent
AG-->>BE: AgentEvent
alt assistant token
BE->>FE: DataModelUpdate (dataKey 绑定)
else tool call
BE->>FE: SurfaceUpdate (Chip 卡片)
end
FE-->>U: UI 增量更新
end
AG-->>AG: 可能需要 Interrupt
AG->>BE: InterruptRequest
BE->>FE: InterruptRequest
FE-->>U: 展示审批按钮
U->>FE: 点击批准
FE->>BE: POST /sessions/:id/approve
BE->>AG: Resume 继续执行
从 Quickstart 到生产落地
[!tip] 可扩展的设计思路
A2UI 的协议层和 Agent 层是解耦的。你可以只做其中的任意一部分。
| 方向 | 替换方案 | 适用场景 |
|---|---|---|
| 不同 UI 形态 | React/Vue 组件、移动端原生组件、TUI | 产品定位差异 |
| 传输协议升级 | gRPC Streaming / GraphQL Subscription | 需要更强的双向交互 |
| 渲染引擎切换 | 服务端 SSR → 客户端动态渲染 | CDN 加速需求 |
| 多模态扩展 | 嵌入图片、图表、代码高亮 | 数据类/分析型 Agent |
本章小结
| 核心概念 | 说明 | 关键点 |
|---|---|---|
| A2UI 协议 | Agent 到 UI 的映射协议 | 声明式组件树 + 数据绑定 |
| 消息信封 | BeginRendering / SurfaceUpdate / DataModelUpdate | 每种消息对应不同生命周期 |
| 组件系统 | Text / Column / Card / Row | 4 种基础组件覆盖常见 UI 场景 |
| SSE 流 | AgentEvent → A2UI JSONL → 前端渲染 | 单向推送、增量更新 |
| 中断协作 | InterruptRequest + approve 机制 | 人机协同的关键路径 |
[!success] 学习成果
完成本章后,你应该能够:
- 理解 A2UI 协议的设计思路和适用场景
- 掌握 Agent 事件流到前端 UI 的完整转换链路
- 使用 SSE 实现增量渲染的前端集成方案
- 知道如何将这个骨架扩展到不同的产品形态中
下一章预告
这是 Quickstart 系列的终章。后续如果你想深入:
- Eino/quick_start/chapter_09_skill_console — 回顾第九章的 Skill 知识注入能力
- Eino/README — 探索 Eino 框架的系统性学习路径
扩展思考
其他组件类型(可选实现方向)
| 组件 | 说明 | 实现难度 |
|---|---|---|
| 图表组件 | 折线图、柱状图、饼图 | 中高(需接入图表库) |
| 地图组件 | 地理信息可视化 | 中(依赖地图 SDK) |
| 时间线组件 | 事件顺序排列展示 | 低(已有 Column 即可) |
| 树形组件 | 层级数据结构展示 | 中(递归渲染逻辑) |
| 标签页组件 | 多面板 Tab 切换 | 低(State 管理即可) |
高级交互能力
| 能力 | 说明 | 技术要点 |
|---|---|---|
| 组件交互 | 点击、拖拽、输入反馈 | 前端事件 → API 调用 |
| 条件渲染 | 根据数据决定组件显隐 | 服务端根据状态发送不同 SurfaceUpdate |
| 组件动画 | 平滑过渡效果 | CSS transition / animation |
| 响应式布局 | 自适应屏幕尺寸 | 媒体查询 + 弹性布局 |
关联笔记
- Eino/quick_start/_index — Quickstart 系列统一入口
- Eino/quick_start/chapter_08_graph_tool — Graph Tool 复杂工作流编排
- Eino/quick_start/chapter_09_skill_console — Skill 知识与指令注入
- Eino/quick_start/chapter_07_interrupt_resume — Interrupt 与 Resume 机制