生成式 UI:Agent 流式渲染交互式组件
生成式 UI:Agent 流式渲染交互式组件
学习目标
理解 CopilotKit 的 Open Generative UI 中间件实现:
- 中间件如何拦截和解析 Agent 的 SSE 流
clarinetSAX 解析器如何增量解析流式 JSON- Activity 事件的发射与消费
- 沙盒 UI 的安全隔离
前置知识
本章涉及生成式 UI 模式和流式传输的通用原理,建议先阅读:
下文假设你已理解上述概念,直接聚焦 CopilotKit 的 Open Generative UI 实现。
项目实践
中间件架构
OpenGenerativeUIMiddleware 是 AG-UI 协议的中间件,拦截 Agent 的 SSE 事件流:
工具定义
Agent 使用名为 generateSandboxedUi 的工具生成 UI:
工具参数结构:{ html: "<div>...</div>", // HTML 内容 jsFunctions: "function foo() {...}", // JavaScript 函数 jsExpressions: ["expr1", "expr2"], // JS 表达式数组 css: ".my-class { ... }", // CSS 样式 initialHeight: 300, // 初始高度 placeholderMessages: ["加载中..."] // 占位消息}ArgsParser:流式 JSON 解析
ArgsParser 类是核心解析器,使用 clarinet(SAX 式 JSON 解析器)增量解析工具参数:
状态跟踪(源码验证):
currentKey:当前正在解析的 JSON keydepth:嵌套深度(跟踪对象开闭)currentArrayKey:当前是否在数组中snapshotEmitted:是否已发射完整快照streamingHtmlKey:是否正在流式解析 html 字段htmlEmittedLength:已发射的 html 长度
解析事件序列(源码验证):
SSE Chunk: "html": "<div class= ↓ onkey("html") → streamingHtmlKey = true onvalue("<div class=") → params.html += chunk → 发射 ActivityDeltaEvent
SSE Chunk: ">Hello</div>", "initialHeight": 300 ↓ onvalue("Hello</div>") → params.html += chunk → 发射 ActivityDeltaEvent(更新后的 html) onkey("initialHeight") → streamingHtmlKey = false onvalue(300) → params.initialHeight = 300
对象结束: } ↓ → 发射 ActivitySnapshotEvent(完整参数快照) → snapshotEmitted = trueActivity 事件
两种活动事件类型:
ActivityDeltaEvent(增量):
{ type: "ACTIVITY_DELTA", activityType: "open-generative-ui", toolCallId: "tool_abc", delta: { html: "<div>新解析的片段</div>" }}ActivitySnapshotEvent(完整快照):
{ type: "ACTIVITY_SNAPSHOT", activityType: "open-generative-ui", toolCallId: "tool_abc", snapshot: { html: "<div>完整 HTML</div>", jsFunctions: "...", initialHeight: 300 }}中间件启用
在 CopilotRuntime 中启用 Open Generative UI:
const runtime = new CopilotSseRuntime({ agents: { default: agent }, openGenerativeUI: true // 或配置对象:{ agents: ["my-agent"] }});作用域配置:
openGenerativeUI: true→ 对所有 Agent 启用openGenerativeUI: { agents: ["agent-a"] }→ 仅对指定 Agent 启用
沙盒渲染
生成的 UI 在沙盒中渲染,防止恶意代码执行。沙盒的关键安全考虑:
- HTML 和 JS 在隔离环境中执行
- 限制对父页面 DOM 的访问
- 控制网络请求权限
问题与规避
| 问题 | 规避策略 |
|---|---|
流式 JSON 中的字符串截断(如在 \n 中间断开) | SAX 解析器正确处理不完整的字符串;下一个 chunk 到达后继续解析 |
| 数组参数的增量解析不完整 | htmlArrayEmitted 标记数组是否完整;只有数组结束时才发射快照 |
| 并发 tool call 的解析交错 | 每个 toolCallId 独立的 ArgsParser 实例 |
| XSS 风险(Agent 生成的恶意 JS) | 沙盒隔离(iframe sandbox);限制 allow-scripts 后的权限 |
设计取舍
流式 JSON 解析 vs 等待完整 JSON
流式解析(clarinet SAX):
- 优势:UI 逐步呈现,用户感知延迟更低
- 代价:解析逻辑复杂,需要处理不完整 JSON 的各种边界情况
等待完整 JSON(JSON.parse):
- 优势:实现简单
- 代价:用户需等待 Agent 完成整个工具调用
CopilotKit 选择流式解析,因为生成式 UI 的核心价值就是”渐进式展示”。
ActivityDelta vs ActivitySnapshot
Delta(增量):
- 优势:每次只传输变化的部分,数据量小
- 代价:前端需要合并增量更新
Snapshot(全量):
- 优势:前端无需维护状态合并逻辑
- 代价:每次传输完整参数
CopilotKit 同时提供两种事件:Delta 用于渐进式更新,Snapshot 用于最终一致性确认。
参考来源
packages/runtime/src/v2/runtime/open-generative-ui-middleware.tspackages/runtime/src/v2/runtime/runner/in-memory.ts(中间件应用)- clarinet SAX JSON 解析器