Generative UI:渲染图表、文件和表单
背景
最近在给业务做 AI 助手,后端返回 SSE 流,前端把模型输出的 Markdown 渲染出来。后来需求多了:问"Q3 各区域销售怎么样",要直接出一张柱状图;问"线上 500 多不多",要给一条能直接执行的日志查询语句;再往后还有 PDF、Excel、可点击的卡片和能提交的表单。
这类由 agent 在运行时决定界面内容的做法,现在一般叫 Generative UI。下面是我们的方案,以及和几个现有标准的对比。
一条回复里,文字、图表、查询语句、文件、表单各是一个独立的块:
只有文本流的问题
常见的实现是 SSE 每个事件带一小段文本,前端拼起来交给 Markdown 渲染器。纯问答没问题,遇到富内容就不好办了:
- 图表只能让模型输出 Markdown 表格或者一张图片,没法交互。
- 想要按钮,只能让模型输出一个链接,点了之后会发生什么不可控。
- 表格里的数字是模型写出来的,它没看到真实数据时就会编。
- 图表要懒加载 ECharts、文件要预览、表单要校验,在一段文本里没法区别处理。
把消息拆成块
我们的做法是把一条消息从一个字符串改成一个块数组。SSE 的每个事件都带上类型和所属块的 ID,前端用 reducer 把事件合并成块数组,再按块的类型找对应的组件渲染。
柱状图就是其中一种块。之后加日志查询、文件、表单,都是多注册一个类型,主流程不用动。
两种实现方式
让 SSE 事件带上类型,有两种做法。
方式 A 是后端发结构化事件,文本和图表分开发:
event: text_delta
data: {"blockId":"b1","delta":"上季度各区域销售额如下:"}
event: block_start
data: {"blockId":"b2","type":"chart"}
event: block_end
data: {"blockId":"b2","payload":{"type":"bar","title":"Q3 区域销售额", ...}}
event: text_delta
data: {"blockId":"b3","delta":"华东领先明显……"}
event: done
data: {}方式 B 是后端只透传模型的文本,约定模型用带 chart 语言标记的代码块输出图表配置,前端在流里识别这个代码块,把里面的 JSON 当作图表块处理。
| A:结构化事件 | B:Markdown 约定 | |
|---|---|---|
| 后端改动 | 需要定义事件 | 基本不用改 |
| 可靠性 | 格式由代码保证 | 依赖模型遵守格式,JSON 写坏要兜底 |
| 数据来源 | 后端取真实数据 | 模型自己写数字 |
| 扩展到文件、表单 | 容易 | 交互基本做不了 |
B 改动小,适合先做原型;能改后端的话直接用 A。下文都按 A 来写。
Generative UI 的三种模式
社区里一般按"界面由谁定义"把 Generative UI 分成三种:
- 受控型:前端写好一组组件,agent 只决定用哪个组件、传什么参数。常见做法是把组件和工具调用绑在一起,模型调了某个工具,前端就渲染对应的组件。样式和交互都在前端手里,代价是每种界面都要写一个组件。
- 声明式:前端提供一个组件目录,agent 用目录里的组件拼出一棵组件树(JSON),前端负责映射成真实组件。agent 可以组合出新布局,但只能用目录里有的组件。Google 的 A2UI 和 OpenAI 的 Open-JSON-UI 属于这种。
- 开放式:agent 或第三方服务直接返回 HTML,前端放进沙箱 iframe 里运行。最灵活,但样式统一、无障碍和安全都不好保证。MCP Apps 是这种。
业务内部的助手,图表、文件、卡片、表单的种类有限,又要符合自己的设计规范,用受控型就够了。图表这类块的 schema 后面会往声明式靠一些。
数据由后端取
图表里的数据应该由后端调业务接口拿,模型只负责判断要不要画、画哪个。
把展示图表定义成工具
以 OpenAI 风格的 function calling 为例:
{
"type": "function",
"function": {
"name": "show_sales_chart",
"description": "当用户询问区域销售数据时调用,向用户展示区域销售额图表",
"parameters": {
"type": "object",
"properties": {
"quarter": { "type": "string", "description": "季度,例如 2026Q3" },
"chartType": { "type": "string", "enum": ["bar", "line", "pie"] }
},
"required": ["quarter"]
}
}
}模型只给出季度和图表类型这些参数,不涉及业务数字。
Chat Completions 里 tools 和 tool_calls 的 type 以前只有 function,后来 OpenAI 加了参数为自由文本的 custom 类型。国内大多数兼容 OpenAI 的模型和网关目前只支持 function,这个场景也只需要 function。
后端拦截工具调用
模型返回 tool_call 后,编排服务不把它转给前端,而是自己处理:
- 给前端发
block_start,前端先显示骨架屏; - 用当前用户的身份调业务接口取数;
- 把数据拼成图表 schema,发
block_end; - 把数据摘要作为工具结果交回模型,模型接着写分析。
第 4 步不能省。数据是后端取的,但"华东领先明显"这句话是模型写的,模型没看到数据就可能写错,图和文字对不上。
工具结果里最好写明"图表已经展示给用户",不然模型经常会再输出一个 Markdown 表格,把数字重复一遍。
编排服务的主循环
上面几步写成代码大致如下(Node.js + OpenAI SDK,省略了非 UI 工具和重试):
// 会产生界面的工具,执行后得到一个块
const uiTools = {
show_sales_chart: {
blockType: 'chart',
async run(args, ctx) {
// 用当前用户的身份查数,权限沿用业务系统自己的
const rows = await salesApi.byRegion(args.quarter, { user: ctx.user });
return {
payload: {
version: 1,
type: args.chartType ?? 'bar',
title: `${args.quarter} 区域销售额`,
unit: '万元',
x: { field: 'region' },
series: [{ field: 'amount', name: '销售额' }],
data: rows,
},
summary: rows.map((r) => `${r.region} ${r.amount}`).join(';'),
};
},
},
};
async function runTurn(messages, sse, ctx) {
while (true) {
const stream = await llm.chat.completions.create({
model: MODEL,
messages,
tools: TOOL_DEFINITIONS,
stream: true,
});
const textBlockId = newId();
let text = '';
const calls = [];
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (delta?.content) {
text += delta.content;
sse.send('text_delta', { blockId: textBlockId, delta: delta.content });
}
// 流式返回时 tool_calls 的参数是分片到达的,按 index 拼起来
for (const part of delta?.tool_calls ?? []) mergeToolCallDelta(calls, part);
}
messages.push({ role: 'assistant', content: text || null, tool_calls: calls.length ? calls : undefined });
if (calls.length === 0) break; // 没有工具调用,这一轮结束
for (const call of calls) {
const tool = uiTools[call.function.name];
const blockId = newId();
sse.send('block_start', { blockId, type: tool.blockType });
try {
const { payload, summary } = await tool.run(JSON.parse(call.function.arguments), ctx);
sse.send('block_end', { blockId, payload });
messages.push({
role: 'tool',
tool_call_id: call.id,
content: `图表已展示给用户,不要再用表格重复数字。数据:${summary}`,
});
} catch (err) {
sse.send('block_error', { blockId, code: 'DATA_FETCH_FAILED', message: '销售数据暂时无法获取' });
messages.push({ role: 'tool', tool_call_id: call.id, content: '取数失败,请告诉用户稍后重试' });
}
}
// 带着工具结果进入下一次循环,模型接着写分析
}
sse.send('done', {});
}tool_calls 不会发给前端,前端只处理编排服务定义的事件。以后换模型厂商,前端协议不用改。
事件协议
事件外壳
所有事件用同一个外壳,方便去重和排查:
type SSEEnvelope<T> = {
v: 1; // 协议版本
msgId: string; // 所属消息
blockId: string; // 所属块
seq: number; // 递增序号,用于去重和断线续传
data: T;
};事件类型有这几种:
| 事件 | 作用 | 关键字段 |
|---|---|---|
text_delta | 文本增量 | delta |
block_start | 非文本块开始,前端显示占位 | type,可选 meta(标题、预估高度等) |
block_delta | 块的增量更新(可选) | JSON Patch 数组 |
block_end | 块完成,带完整 payload | payload |
block_error | 块失败 | code、message、是否可重试 |
done | 消息结束 | 用量统计等 |
error | 整轮失败 | code、message |
另外约定两点:前端遇到不认识的事件类型直接忽略,这样后端加新类型不会影响旧版前端;遇到不认识的块类型,显示块里的 fallback 文本。
block_delta 用在比较大的块上,比如几百行的表格,可以先发表头再分批追加行。增量用 JSON Patch(RFC 6902)描述,和 AG-UI 的 STATE_DELTA 一样。一般用不上。
块晚到
后端取数可能要几百毫秒到几秒。这期间先发 block_start,前端显示骨架屏,文字照常往下流,数据回来后再发 block_end。
所以块的到达顺序和显示顺序可能不一样,前端要按 blockId 更新原来位置的块,而不是追加到末尾:
骨架屏的高度要和图表最终的高度一致,不然图表出来时页面会跳一下。可以在 block_start 的 meta 里带一个预估高度。
失败、重试和断线
单个块失败时发 block_error,前端在原位置显示错误和重试按钮。重试不用重新跑整轮对话,单独提供一个接口,比如 POST /chat/blocks/:blockId/retry,后端用当时的工具参数重新取数。
断线续传靠 seq。原生 EventSource 重连时会自动带上 Last-Event-ID,但它只支持 GET 请求。AI 对话一般用 POST,需要用 fetch 读流,重连也得自己实现:记下最后收到的 seq,重连时带上,后端从这之后补发。后端做不到补发的话,至少要保证 done 之后能从历史接口拿到完整的块数组。
块的类型
三类块
按处理方式,块可以分成三类:
| 类别 | 例子 | 数据方向 | 主要问题 |
|---|---|---|---|
| 展示类 | 图表、日志查询语句、代码、表格 | 服务端 → 前端 | 渲染和样式统一 |
| 资源类 | PDF、图片、Excel 等文件 | 对象存储 ↔ 前端 | 内容不走 SSE,只传引用 |
| 交互类 | 卡片、表单、确认框 | 双向 | 回传通道、状态、幂等 |
块的结构
type Block = {
id: string;
type: string; // 'text' | 'chart' | 'query' | 'file' | 'card' | 'form' ...
version: number; // 该类型 schema 的版本
status: 'loading' | 'done' | 'error';
payload?: unknown; // 各类型自己的 schema
fallback?: string; // 前端不认识该类型时显示的文本
actions?: Action[]; // 交互类才有
state?: unknown; // 服务端维护的交互状态,比如表单是否已提交
};fallback 在这些情况下会用到:老版本前端、能力不全的小程序端、把对话导出成纯文本、把对话交给另一个模型看。比如图表块的 fallback 可以写成"Q3 区域销售额:华东 320、华南 210……"。
图表
图表的 schema 要先定一件事:后端直接填 ECharts 的 option,还是填一份业务语义的 schema,由前端转成 option。
| 直接下发 ECharts option | 语义 schema + 前端转换 | |
|---|---|---|
| 前端工作量 | 几乎没有 | 每种图写一个转换函数 |
| 新增图形 | 后端改,不用发版 | 前端要支持 |
| 样式统一 | 主题色、字体、暗色模式散落在后端 | 前端统一处理 |
| 数值格式化 | JSON 里不能放函数,只能用字符串模板 | 前端用函数格式化 |
| 响应式 | 后端不知道容器宽度 | 前端按宽度调整 |
| 换图表库或多端 | 后端跟着改 | 只改转换函数 |
建议用语义 schema:
type ChartSpec = {
version: 1;
type: 'bar' | 'line' | 'pie';
title?: string;
unit?: string;
valueFormat?: 'number' | 'percent' | 'currency';
x: { field: string };
series: { field: string; name: string }[];
data: Record<string, string | number>[];
orientation?: 'vertical' | 'horizontal';
stack?: boolean;
};前端每种图写一个转换函数,再和基础主题合并:
import * as echarts from 'echarts/core';
import { BarChart, LineChart, PieChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, CanvasRenderer]);
function toBarOption(spec: ChartSpec) {
// 类别多时用横向柱状图
const horizontal = spec.orientation === 'horizontal' || spec.data.length > 8;
const format = formatterOf(spec.valueFormat, spec.unit);
const category = { type: 'category', data: spec.data.map((d) => d[spec.x.field]) };
const value = { type: 'value', axisLabel: { formatter: format } };
return {
tooltip: { trigger: 'axis', valueFormatter: format },
legend: spec.series.length > 1 ? {} : undefined,
xAxis: horizontal ? value : category,
yAxis: horizontal ? category : value,
series: spec.series.map((s) => ({
type: 'bar',
name: s.name,
stack: spec.stack ? 'total' : undefined,
data: spec.data.map((d) => d[s.field]),
})),
};
}
const mappers = { bar: toBarOption, line: toLineOption, pie: toPieOption };
export function toOption(spec: ChartSpec, theme: 'light' | 'dark') {
return merge(baseTheme[theme], mappers[spec.type](spec));
}另外保留一个 { "type": "echarts_raw", "option": {...} } 类型,给桑基图、地图这种少见的图用。前端拿到后同样先合并基础主题再 setOption,并且只接受后端代码生成的 option,不接受模型输出的。某种 raw 图用得多了,再给它加一个正式的类型。
日志查询语句
本质是带语言标记的代码块,但一般要配几个操作,所以单独做成一个类型:
{
"type": "query",
"payload": {
"lang": "sls",
"statement": "status:500 | stats count() by path",
"timeRange": { "from": "now-1h", "to": "now" }
},
"actions": [
{ "id": "copy", "label": "复制", "kind": "local" },
{ "id": "run", "label": "执行查询", "kind": "api" },
{ "id": "open", "label": "在日志平台打开", "kind": "link", "href": "https://logs.example.com/..." }
]
}点"执行查询"后,结果作为一个新的表格块或图表块追加到消息里。查询由后端执行,要限制只读、限制时间范围,权限用用户自己的日志权限。
文件
PDF、图片、Excel 的内容不要放进 SSE。后端把文件放到对象存储,事件里只带引用:
{
"type": "file",
"payload": {
"assetId": "a_123",
"name": "Q3报表.xlsx",
"mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"size": 482133,
"url": "https://oss.example.com/a_123?signature=...&expires=...",
"preview": {
"kind": "table",
"rows": [["区域", "销售额"], ["华东", 320], ["华南", 210]]
}
},
"fallback": "文件:Q3报表.xlsx(471 KB)"
}需要注意的几点:
- URL 用短期签名。过期后前端调
GET /assets/:id/url换新链接,翻历史消息时一定会碰到过期的情况。 - 预览和下载分开。图片显示缩略图;PDF 用 pdf.js 预览前几页;Excel 由后端顺便给前 N 行数据,前端不用下载整个文件解析,要看全部时再用 SheetJS 打开或者跳在线预览。
- 生成慢的文件,比如导出大报表,先发
block_start,中间用block_delta更新进度,完成后在block_end里带上链接。 - 预览数据也要控制大小,单条 SSE 事件在几十 KB 以内比较稳,再大就拆开或者改成按需拉取。
卡片和表单
SSE 是单向的,用户在卡片、表单上的操作要通过另一个接口回到后端:
POST /chat/actions
{
"msgId": "m_1",
"blockId": "b5",
"actionId": "submit",
"payload": { "range": ["2026-07-01", "2026-09-30"], "reason": "季度复盘" },
"idempotencyKey": "7f3c..."
}每个动作要在定义里写明去向:
type Action = {
id: string;
label: string;
kind: 'api' | 'prompt' | 'link' | 'local';
// api: 直接调业务接口,不经过模型(提交审批、执行查询)
// prompt: 转成一条用户消息发给模型("展开华东各城市数据")
// link: 跳转
// local: 纯前端行为(复制、展开收起)
prompt?: string; // kind 为 prompt 时发给模型的文本
href?: string; // kind 为 link 时的地址
confirm?: string; // 有副作用的动作,先弹二次确认
};prompt 类动作在对话里挺好用。比如柱状图的柱子可以点,点"华东"就自动发一句"展开华东各城市的数据",不用用户自己打字。
表单的字段和校验用 JSON Schema 描述,再加一份 uiSchema 描述控件,前端可以直接用 react-jsonschema-form 这类库渲染:
{
"type": "form",
"payload": {
"title": "申请导出全量明细",
"schema": {
"type": "object",
"required": ["range"],
"properties": {
"range": { "type": "array", "items": { "type": "string", "format": "date" }, "minItems": 2, "maxItems": 2 },
"reason": { "type": "string", "maxLength": 200 }
}
},
"uiSchema": { "range": { "ui:widget": "dateRange" }, "reason": { "ui:widget": "textarea" } },
"defaults": { "range": ["2026-07-01", "2026-09-30"] }
},
"actions": [{ "id": "submit", "label": "提交", "kind": "api", "confirm": "确认提交导出申请?" }]
}模型可以帮忙预填默认值,比如从对话里推断时间范围,但表单有哪些字段由后端定义。
交互类块还有几个要处理的地方:
- 状态要落库。表单提交后,服务端把块的
state改成已提交并记下结果,前端据此锁定表单,刷新页面或者换设备看历史时不能再提交一次。 - 幂等。每次动作带一个
idempotencyKey,后端按它去重,防止重复点击。 - 后端要重新校验。前端校验只是为了体验,后端用同一份 schema(比如用 ajv)再校验一遍,同时检查权限。
- 动作要有白名单。哪些表单、哪些 api 动作可以出现,由后端注册的列表决定,模型只能从里面选、填参数,不能自己造一个调用某个接口的按钮。
前端实现
整体流程:
用 fetch 读流
对话请求一般是 POST,要带历史消息和其他参数,原生 EventSource 只支持 GET,所以用 fetch 读:
export async function* readSSE(res: Response) {
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value.replace(/\r\n/g, '\n');
let end: number;
// SSE 用空行分隔事件
while ((end = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const evt = parseEvent(raw);
if (evt) yield evt;
}
}
}
function parseEvent(raw: string) {
let event = 'message';
let id: string | undefined;
const data: string[] = [];
for (const line of raw.split('\n')) {
if (!line || line.startsWith(':')) continue; // 冒号开头是注释,常用作心跳
const i = line.indexOf(':');
const field = i === -1 ? line : line.slice(0, i);
const value = i === -1 ? '' : line.slice(i + 1).replace(/^ /, '');
if (field === 'event') event = value;
else if (field === 'data') data.push(value);
else if (field === 'id') id = value;
}
if (data.length === 0) return null;
return { event, id, data: JSON.parse(data.join('\n')) };
}这是最简单的实现,生产环境可以用 eventsource-parser 或 @microsoft/fetch-event-source,边界情况处理得更全。
请求时可以带上当前端支持的块类型和版本:
fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Blocks': 'chart@1,query@1,file@1,form@1' },
body: JSON.stringify({ messages }),
});X-Blocks 是我们自己定的请求头。Web、小程序、App 支持的块不一样,后端遇到不支持的类型就降级成 fallback 文本或者链接。
reducer
type Message = { id: string; blocks: Block[] };
function reduce(msg: Message, evt: SSEEvent): Message {
switch (evt.event) {
case 'text_delta':
return upsert(msg, evt.data.blockId, (b) => ({
id: evt.data.blockId,
type: 'text',
version: 1,
status: 'loading',
payload: ((b?.payload as string) ?? '') + evt.data.delta,
}));
case 'block_start':
return upsert(msg, evt.data.blockId, () => ({
id: evt.data.blockId,
type: evt.data.type,
version: evt.data.version ?? 1,
status: 'loading',
}));
case 'block_end': {
const parsed = schemas[evt.data.type]?.safeParse(evt.data.payload);
return upsert(msg, evt.data.blockId, (b) => ({
...b!,
status: parsed?.success ? 'done' : 'error',
payload: parsed?.success ? parsed.data : undefined,
fallback: evt.data.fallback,
actions: evt.data.actions,
}));
}
case 'block_error':
return upsert(msg, evt.data.blockId, (b) => ({ ...b!, status: 'error' }));
default:
return msg; // 不认识的事件忽略
}
}
// 已有的块原地更新,新块追加到末尾,晚到的块因此会留在原来的位置
function upsert(msg: Message, id: string, fn: (b?: Block) => Block): Message {
const i = msg.blocks.findIndex((b) => b.id === id);
if (i === -1) return { ...msg, blocks: [...msg.blocks, fn()] };
const blocks = msg.blocks.slice();
blocks[i] = fn(blocks[i]);
return { ...msg, blocks };
}block_end 的 payload 用 zod 之类的库按类型校验,不通过就把块标成错误,显示 fallback,不要让整条消息渲染失败。
合并更新
文字流每秒可能有几十个事件,每个都 setState 会卡。先把事件放进队列,每帧用 requestAnimationFrame 合并处理一次:
function useMessageStream() {
const [message, setMessage] = useState<Message>(emptyMessage);
const queue = useRef<SSEEvent[]>([]);
const frame = useRef(0);
const push = useCallback((evt: SSEEvent) => {
queue.current.push(evt);
if (frame.current) return;
frame.current = requestAnimationFrame(() => {
const batch = queue.current;
queue.current = [];
frame.current = 0;
setMessage((m) => batch.reduce(reduce, m));
});
}, []);
return { message, push };
}组件注册表
const registry: Record<string, React.ComponentType<{ block: Block }>> = {
text: TextBlock,
chart: lazy(() => import('./blocks/ChartBlock')),
query: QueryBlock,
file: lazy(() => import('./blocks/FileBlock')),
form: lazy(() => import('./blocks/FormBlock')),
};
const BlockView = memo(
function BlockView({ block }: { block: Block }) {
const Component = registry[block.type] ?? UnknownBlock;
return (
<ErrorBoundary fallback={<FallbackText block={block} />}>
<Suspense fallback={<Skeleton type={block.type} />}>
<Component block={block} />
</Suspense>
</ErrorBoundary>
);
},
// 块本身没变就不重新渲染
(prev, next) => prev.block === next.block,
);
export function MessageView({ message }: { message: Message }) {
return message.blocks.map((b) => <BlockView key={b.id} block={b} />);
}几个细节:
- reducer 用不可变更新,没变的块引用不变,
memo才有效。否则文字每更新一次,图表都会重新setOption。 - ECharts、pdf.js、表单库都不小,用懒加载,第一次遇到对应的块才加载。ECharts 只引入用到的图表和组件。
- 每个块单独包一层 ErrorBoundary,一个块出错只影响它自己。
- 骨架屏高度固定,前面说过。
和现有标准的对比
上面是我们自己定的协议。几个现有标准的事件命名和消息结构可以参考,以后想接入现成的工具也方便。它们分别负责不同的部分:
- MCP:agent 和工具、数据之间;
- A2A:agent 和其他 agent 之间;
- AG-UI:agent 和前端之间的双向事件流;
- A2UI、Open-JSON-UI、MCP Apps:在事件流上传输的 UI 数据格式。
AG-UI
AG-UI(Agent–User Interaction Protocol)由 CopilotKit 发起,是一个开源的事件协议,通常用 SSE 传输。它本身不规定 UI 长什么样,只负责 agent 和应用之间的通信,上面几种 UI 格式都可以通过它传。
事件分为这几类:
| 类别 | 事件 |
|---|---|
| 生命周期 | RUN_STARTED、RUN_FINISHED、RUN_ERROR、STEP_STARTED、STEP_FINISHED |
| 文本消息 | TEXT_MESSAGE_START、TEXT_MESSAGE_CONTENT、TEXT_MESSAGE_END |
| 工具调用 | TOOL_CALL_START、TOOL_CALL_ARGS、TOOL_CALL_END、TOOL_CALL_RESULT |
| 状态 | STATE_SNAPSHOT、STATE_DELTA、MESSAGES_SNAPSHOT、ACTIVITY_SNAPSHOT、ACTIVITY_DELTA |
| 推理 | REASONING_START、REASONING_MESSAGE_CONTENT 等 |
| 子 agent | SUBAGENT_STARTED、SUBAGENT_FINISHED、SUBAGENT_ERROR |
| 扩展 | RAW、CUSTOM |
流式内容都是开始、内容、结束三个事件,共用一个 ID。STATE_DELTA 和 ACTIVITY_DELTA 用 JSON Patch 描述增量,CUSTOM 用 name 和 value 传自定义数据。
和我们的事件对应关系如下:
| 本文的事件 | AG-UI 中的对应 |
|---|---|
text_delta | TEXT_MESSAGE_CONTENT |
block_start / block_end | TOOL_CALL_START / TOOL_CALL_RESULT(前端按工具名渲染),或 ACTIVITY_SNAPSHOT,或 CUSTOM |
block_delta | ACTIVITY_DELTA / STATE_DELTA(JSON Patch) |
done | RUN_FINISHED |
error | RUN_ERROR |
新项目可以直接用 AG-UI 的事件名,自定义的块放进 CUSTOM 或 ACTIVITY_*。
A2UI
A2UI 是 Google 提出的声明式 UI 格式。agent 只传描述界面的数据,客户端用自己的组件渲染,不执行 agent 传来的代码。当前是 v0.9 系列,v1.0 还是候选版本。消息有四种:
| v0.9 消息 | 作用 | v0.8 中的名字 |
|---|---|---|
createSurface | 创建一块界面,指定组件目录(catalogId) | beginRendering |
updateComponents | 增量更新组件 | surfaceUpdate |
updateDataModel | 更新数据 | dataModelUpdate |
deleteSurface | 删除界面 | deleteSurface |
几个设计点:
- 组件用扁平列表表示,子组件通过 ID 引用,不是嵌套结构。模型可以一个个生成、增量修改,某个组件写错也只影响它自己。
- agent 只能用客户端组件目录(catalog)里的组件,属性也按目录里的 schema 校验。
- 界面结构和数据分开。组件用 JSON Pointer 绑定数据,比如
{"path": "/user/name"},数据更新后组件自动刷新,不用重发组件。
{"version":"v0.9","createSurface":{"surfaceId":"main","catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}
{"version":"v0.9","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Text","text":{"path":"/user/name"}}]}}
{"version":"v0.9","updateDataModel":{"surfaceId":"main","path":"/","value":{"user":{"name":"Alice"}}}}用户操作通过 action 回传。组件上的动作有两种:event 发回给 agent,functionCall 只在客户端执行,比如打开链接。event 的 context 可以引用数据里的路径,发送前由渲染器替换成实际的值:
{
"version": "v0.9",
"action": {
"name": "submit_reservation",
"surfaceId": "booking-surface",
"sourceComponentId": "submit-btn",
"timestamp": "2026-02-25T10:40:00Z",
"context": { "time": "7:00 PM", "size": 4 }
}
}和我们的方案比,event 对应 api 和 prompt 类动作,functionCall 对应 link 和 local 类动作。如果以后需要 agent 自己组合卡片、列表、按钮这类布局,而不是每种都让前端写一个组件,可以考虑 A2UI。
MCP Apps
MCP Apps 是 MCP 的一个扩展(SEP-1865),2025 年 11 月提出,现在是 Final 状态,合并了社区项目 MCP-UI 和 OpenAI Apps SDK 的做法:
- UI 是预先声明的资源,用
ui://标识,MIME 类型是text/html;profile=mcp-app,工具通过元数据_meta.ui.resourceUri关联界面。宿主可以在调用工具前预取模板,也可以提前审查 HTML。 - 宿主在沙箱 iframe 里渲染 HTML,iframe 和宿主之间用
postMessage传 MCP 的 JSON-RPC 消息。界面先发ui/initialize握手,宿主推送工具的输入和结果(ui/notifications/tool-input、ui/notifications/tool-result),界面也可以调用工具(tools/call)或者往对话里发消息(ui/message)。 - 安全方面:强制沙箱,资源可以提前审查,通信可以记录,界面发起的工具调用可以要求用户确认。资源可以声明 CSP,不声明时默认不能访问网络。
- 工具可以设置成只给模型用、只给界面用,或者两者都能用。
MCP Apps 适合嵌入第三方的界面。如果助手要接别人的 MCP 服务,对方又提供了界面,就用它;业务自己的组件没必要放进 iframe。
Vercel AI SDK
用 React + Next.js 的话,AI SDK 就是受控型的做法:工具在服务端定义和执行,前端从 message.parts 里读工具调用,类型是 tool-<工具名>,根据状态渲染组件:
{message.parts.map((part, i) => {
if (part.type === 'tool-showSalesChart') {
switch (part.state) {
case 'input-available':
return <ChartSkeleton key={i} />;
case 'output-available':
return <SalesChart key={i} {...part.output} />;
case 'output-error':
return <ErrorCard key={i} text={part.errorText} />;
}
}
if (part.type === 'text') return <Markdown key={i}>{part.text}</Markdown>;
})}input-available 相当于我们的 block_start,output-available 相当于 block_end,区别是 AI SDK 里一个块就是一次工具调用。
选择
| 场景 | 建议 |
|---|---|
| 业务自己的图表、文件、卡片、表单 | 自研块协议或 AI SDK,事件名参考 AG-UI |
| 组件多、布局经常变,想让 agent 自己组合 | A2UI,按自己的设计规范做组件目录 |
| 嵌入第三方的界面 | MCP Apps |
| React 技术栈,不想自己写协议 | AI SDK 或 CopilotKit |
| 要同时支持 Web、小程序、App | 自研协议加能力声明,或 A2UI |
持久化和回放
数据库里直接存块数组,包括图表的数据,回放时不用重新解析文本,也不用重新取数。有个问题要和产品先确认:看历史消息时,图表显示当时的数据,还是重新查最新的?一般显示当时的数据更合理,不然上周写的分析和今天的图会对不上。可以在块上加一个刷新按钮,需要时重新取数。
文件存 assetId,展示时再换签名 URL。
另外把轨迹单独存一份。用户反馈图表数据不对时,要能看到模型调了什么工具、参数是什么、接口返回了什么。轨迹可以按 ATIF 的格式存,图表 schema、块 ID 这些放在 step 的 extra 里,通过消息 ID 和块数组关联。
安全
- 不渲染模型输出的 HTML。Markdown 渲染器关掉原始 HTML 或者严格过滤,模型的输出可能被提示注入影响,比如它读的文档里藏了指令。
- 所有块的 payload 都做 schema 校验,后端生成的也一样。
- 动作只能来自后端注册的列表。模型可以决定展示哪个表单、预填什么值,但不能决定按钮调用哪个接口。api 动作执行时重新鉴权、校验参数。
- 提交、删除、发送这类有副作用的操作加
confirm,必须由用户点击触发。 - 第三方界面放进沙箱 iframe,声明严格的 CSP,通信走可记录的消息通道,和 MCP Apps 的做法一样。
- 取数、执行查询、生成文件都用当前用户的权限。
落地顺序
可以按这个顺序推进,每一步做完都能单独上线:
- 定事件协议:外壳加 7 种事件,写成文档,前后端一起维护。
- 先做图表块:后端拦截工具调用取数,前端做 reducer、注册表和图表组件,跑通占位、回填、模型根据数据继续写的流程。
- 加上
fallback和能力声明。 - 文件块:对象存储、签名 URL、预览数据。
- 动作接口和交互块:先做
link、prompt这种没有副作用的,再做api类,同时做状态落库、幂等和二次确认。 - 块数组入库,轨迹单独存。
- 组件多到维护不过来时再看 A2UI,要接第三方界面时再看 MCP Apps。
小结
做法上就是把消息从一段文本改成一个块数组:SSE 事件带类型和块 ID,块可以晚到、失败和增量更新;数据由后端取,取完交回模型;文件只传引用,交互走单独的接口;前端用 reducer 合并、注册表分发,每个块单独容错。协议的命名可以参考 AG-UI,组件多了可以看 A2UI,第三方界面用 MCP Apps。
参考资料
- AG-UI 文档:事件类型
- A2UI:What is A2UI
- A2UI:消息类型
- A2UI:Actions
- SEP-1865:MCP Apps - Interactive User Interfaces for MCP
- MCP Apps 规范(modelcontextprotocol/ext-apps)
- AI SDK:Generative User Interfaces
- CopilotKit:Generative UI Specs
- CopilotKit/generative-ui
- Generative UI in 2026: Controlled, A2UI Declarative, and Open-Ended Patterns on AG-UI
- OpenAI Ruby SDK:ChatCompletionNamedToolChoiceCustom