Files
CamTalk/docs/Eino/quick_start/chapter_10_a2ui_protocol.md

14 KiB
Raw Blame History

tags, create time
tags create time
eino
ai-development
go
quickstart
a2ui
sse
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 卡片、进度更新 → 实时更新……一切通过声明式的组件树实现。


代码位置

前置条件

与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 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 消息类型:信封结构

每一行 SSEdata: {...})承载一个 A2UI MessageMessage 是一个"信封结构",每次只会出现一个字段:

[!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 文本渲染 支持 usageHintcaption/body/title当存在 dataKey 时文本来自 DataModelUpdate
Column 垂直布局 children 是组件 ID 列表
Row 水平布局 children 是组件 ID 列表
Card 卡片容器 children 是组件 ID 列表,仅做视觉分组

[!warning] Card ≠ 布局容器

Card 不提供任何布局控制(不排布子元素的位置),它只是一个有视觉边界的容器。如需布局请用 Column / Row

A2UI 的实现链路

最终 Web 版的核心链路是三阶段管道:

  1. 后端运行 Agent:得到 *adk.AsyncIterator[*adk.AgentEvent]
  2. 事件 → A2UI JSONL/SSE 流:转换为 A2UI 消息推送给浏览器(见 a2ui/streamer.go
  3. 前端解析并渲染:读取 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

  • SSEServer-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 系列的终章。后续如果你想深入:

扩展思考

其他组件类型(可选实现方向)

组件 说明 实现难度
图表组件 折线图、柱状图、饼图 中高(需接入图表库)
地图组件 地理信息可视化 中(依赖地图 SDK
时间线组件 事件顺序排列展示 低(已有 Column 即可)
树形组件 层级数据结构展示 中(递归渲染逻辑)
标签页组件 多面板 Tab 切换 State 管理即可)

高级交互能力

能力 说明 技术要点
组件交互 点击、拖拽、输入反馈 前端事件 → API 调用
条件渲染 根据数据决定组件显隐 服务端根据状态发送不同 SurfaceUpdate
组件动画 平滑过渡效果 CSS transition / animation
响应式布局 自适应屏幕尺寸 媒体查询 + 弹性布局

关联笔记