CopilotKit 生成式 UI 深度解析
项目概览
CopilotKit 是一个开源 AI 代理应用 SDK,专注于构建”代理原生应用”(agent-native applications),核心特色是支持三种生成式 UI、共享状态和人机协作工作流。
- GitHub: CopilotKit/CopilotKit
- Star 数: 31,000+
- 许可证: MIT(部分 UI 组件有企业许可证提示)
- 主要语言: TypeScript
- 架构: Nx monorepo,所有包在
packages/下,使用@copilotkit/scope
CopilotKit 是 AG-UI 协议(Agent-User Interaction Protocol)的创造者,该协议已被 Google、LangChain、AWS、Microsoft 等主流 AI 平台采用。
AG-UI 协议
AG-UI 定义了 AI 代理与用户界面之间的双向通信规范:
- 基于 SSE(Server-Sent Events)的事件流通信
- 支持状态同步、工具调用渲染、人类介入
- 平台无关,可与任何前端框架和后端 Agent 集成
- 核心事件类型:
TOOL_CALL_START、TOOL_CALL_ARGS、TOOL_CALL_END、ACTIVITY_SNAPSHOT、ACTIVITY_DELTA、RUN_STARTED、RUN_FINISHED
三层架构
Frontend (React/Angular/Vue/React Native)
↓ AG-UI Protocol (SSE 事件流)
Runtime (Express/Hono/Node)
↓ Tool Calling / MCP
Agent (LangGraph/CrewAI/BuiltIn/Custom)
核心包结构
| 包名 | 职责 |
|---|---|
@copilotkit/react-core | React 核心:hooks (useAgent, useFrontendTool, useHumanInTheLoop, useCopilotAction)、CopilotKitProvider、v2 聊天组件 |
@copilotkit/react-ui | 预构建 UI:CopilotPopup、CopilotSidebar、CopilotChat 等开箱即用的聊天界面 |
@copilotkit/vue | Vue 框架支持 |
@copilotkit/angular | Angular 框架支持 |
@copilotkit/a2ui-renderer | A2UI 协议渲染器(声明式 UI) |
@copilotkit/runtime | 运行时核心:Express/Hono 集成、中间件链(OpenGenerativeUI、MCP Apps) |
@copilotkit/runtime-client-gql | GraphQL 客户端 |
@copilotkit/shared | 共享类型和工具函数 |
@copilotkit/core | 核心 Web 实用工具 |
@copilotkit/sdk-js | JavaScript SDK |
纠正:
CopilotPopup、CopilotSidebar等聊天 UI 组件由@copilotkit/react-ui导出,不是react-core。react-core提供的是底层 hooks 和 Provider。
生成式 UI(Generative UI)三种模式
CopilotKit 按控制程度从高到低,提供三种生成式 UI 模式。它们共享同一个底层 AG-UI 协议,但在”谁决定渲染什么”和”渲染粒度”上有本质区别。
安全性高 ◄──────────────────────────────────► 灵活性高
受控 (AG-UI) 声明式 (A2UI) 开放式 (Open GenUI / MCP Apps)
──────────── ──────────── ────────────────────────────────
开发者定义组件 Agent 组合组件 Agent 生成完整 HTML/CSS/JS
Agent 只选+填值 JSON 描述组件树 iframe 沙箱渲染
完全 React 能力 主题映射渲染 独立运行环境
三阶段生命周期 组件级流式渲染 postMessage / Websandbox 通信
最高安全性 高安全性 沙箱隔离
最低灵活性 中等灵活性 最高灵活性
模式一:受控生成式 UI(Controlled — AG-UI)
核心思想:开发者预构建所有 UI 组件,Agent 只负责”选择哪个工具”和”传什么数据”。这是最安全、最常用的模式。
统一入口:useCopilotAction
文件: packages/react-core/src/hooks/use-copilot-action.ts
开发者通过 useCopilotAction 注册一个 action,附带 UI 渲染函数。内部通过 getActionConfig() 路由到三种实现:
useCopilotAction(action)
│
▼
action.name === "*" ?──── Yes ────→ useRenderToolCall (兜底渲染器)
│
No
│
有 renderAndWaitForResponse ?── Yes ──→ useHumanInTheLoop (人机协作)
│
No
│
有 available 属性 ?── "enabled"/"remote" ──→ useFrontendTool (前端工具)
│ "frontend"/"disabled" ──→ useRenderToolCall
No
│
有 handler ?── Yes ──→ useFrontendTool
数据流
开发者调用 useCopilotAction({...})
│
▼
getActionConfig() 判断类型
│
▼ (三种模式之一)
┌──────────────────┬────────────────────┬──────────────────┐
│ render 模式 │ hitl 模式 │ frontend 模式 │
│ │ │ │
│ useRenderToolCall│ useHumanInTheLoop │ useFrontendTool │
│ │ │ │ │ │ │
│ defineToolCall │ useFrontendTool │ copilotkit │
│ Renderer 注册到 │ (handler返回 │ .addTool() │
│ copilotkit │ Promise 等待用户) │ .addHookRender │
│ .renderToolCalls │ │ ToolCall() │
└──────────────────┴────────────────────┴──────────────────┘
│ │ │
└────────────────────┴──────────────────────┘
│
▼
CopilotKit Context (全局状态)
存储所有已注册的工具和渲染器
A. useRenderToolCall — 纯 UI 渲染
文件: packages/react-core/src/hooks/use-render-tool-call.ts
只注册渲染器,不注册工具。Agent 端已有这个工具,前端只负责如何显示。
const renderToolCall = defineToolCallRenderer({
name: "showStockPrice",
args: zodParameters, // 参数的 Zod schema
render: (args) => render({ ...args, result: parseJson(args.result) }),
});
copilotkit.renderToolCalls.push(renderToolCall);
B. useHumanInTheLoop — 人机协作
文件: packages/react-core/src/hooks/use-human-in-the-loop.tsx
核心机制:handler 返回一个 Promise,其 resolve 函数被保存到 ref 中,用户在 UI 中点击确认/取消时才调用。
// 内部实现
const resolvePromiseRef = useRef(null);
const handler = useCallback(async () => {
return new Promise((resolve) => {
resolvePromiseRef.current = resolve; // 保存 resolve
});
}, []);
const respond = useCallback(async (result) => {
if (resolvePromiseRef.current) {
resolvePromiseRef.current(result); // 用户交互时触发
resolvePromiseRef.current = null;
}
}, []);
示例:审批对话框
useCopilotAction({
name: "deleteAccount",
parameters: [{ name: "accountId", type: "string" }],
renderAndWaitForResponse: ({ args, status, respond }) => {
if (status === "inProgress") return <Spinner />;
if (status === "executing") {
return (
<ConfirmDialog
message={`确认删除账户 ${args.accountId}?`}
onConfirm={() => respond("confirmed")}
onCancel={() => respond("cancelled")}
/>
);
}
return <div>已处理</div>;
},
});
时序图:
用户: "删除账户 ABC123"
│
▼
Agent 决定调用 deleteAccount 工具
│ (TOOL_CALL_START + TOOL_CALL_ARGS 事件)
▼
前端收到事件 → status = "inProgress" → 显示 Spinner
│ (参数流式到达)
▼
前端收到完整参数 → status = "executing" → 调用 handler()
│
▼
handler() 返回 Promise (挂起,等待 resolve)
│
▼
渲染组件显示 ConfirmDialog
│
▼ 用户点击 "确认"
respond("confirmed") → resolvePromiseRef.current("confirmed")
│
▼
handler 返回 "confirmed" → Agent 收到工具调用结果
│
▼
status = "complete" → 显示 "已处理"
C. useFrontendTool — 前端执行工具
文件: packages/react-core/src/hooks/use-frontend-tool.ts → packages/react-core/src/v2/hooks/use-frontend-tool.tsx
同时注册工具定义和渲染器。工具在前端执行(而非 Agent 端)。
useFrontendTool({
name: "get_weather",
description: "Get current weather",
parameters: z.object({ location: z.string() }),
handler: async ({ location }) => {
return await fetchWeather(location); // 前端执行
},
render: ({ status, args, result }) => {
if (status === "complete" && result) {
return <WeatherCard data={JSON.parse(result)} />;
}
return <WeatherLoadingState location={args?.location} />;
},
});
底层调用 copilotkit.addTool(tool) 将工具注册到全局状态,Agent 请求时会收到这些前端工具的 schema。
渲染生命周期(三种模式共享)
| 阶段 | 含义 | args | result | 典型 UI |
|---|---|---|---|---|
inProgress | Agent 正在生成工具调用参数 | 部分可用(流式) | 无 | Loading + 参数预览 |
executing | handler 正在执行 | 完整 | 无 | 加载动画或交互 UI |
complete | 执行完成 | 完整 | 完整 | 最终组件 |
关键特性:
- 流式参数:
args在inProgress阶段是部分可用的(partial JSON),可做渐进式展示 - 完全类型安全:
parameters用 Zod 定义,args和result类型自动推导 - 完整 React 能力:渲染函数内的组件可以使用 hooks、状态、事件处理等
模式二:声明式生成式 UI(Declarative — A2UI)
核心思想:Agent 返回结构化的 JSON UI 描述,前端根据描述选择预定义组件并填入数据。开发者不预先绑定具体组件到工具调用,而是提供”组件主题”让渲染器自动映射。
数据流
用户消息 → Agent 生成 A2UI JSON(通过 a2uiComposer 工具)
→ AG-UI 协议以 activity 消息传输 → renderActivityMessages 处理
→ A2UI Renderer 解析 JSON → 主题映射渲染
服务端配置
const agent = new BuiltInAgent({
model: "openai/gpt-4o",
prompt: `You are a helpful assistant.
Use the a2uiComposer tool to generate UI.`,
});
Agent 调用 a2uiComposer 工具时,生成如下 JSONL 格式消息:
{"envelope": "surfaceUpdate", "surfaceId": "weather_display", "catalogId": "https://a2ui.org/.../basic/catalog.json"}
{"envelope": "surfaceUpdate", "surfaceId": "weather_display", "components": [{"id": "root", "component": "Column", "children": ["card"]}, {"id": "card", "component": "Card", "child": "content"}]}
{"envelope": "dataModelUpdate", "surfaceId": "weather_display", "path": "/weather/temperature", "value": 22}
客户端配置
import { createA2UIMessageRenderer } from "@copilotkit/a2ui-renderer";
const A2UIRenderer = createA2UIMessageRenderer({
theme: a2uiTheme, // 将 A2UI 抽象组件映射到实际 React 组件
});
<CopilotKitProvider
runtimeUrl="/api/copilotkit-a2ui"
renderActivityMessages={[A2UIRenderer]}
>
{children}
</CopilotKitProvider>
与受控模式的关键区别
| 维度 | 受控 (AG-UI) | 声明式 (A2UI) |
|---|---|---|
| 谁定义组件树 | 开发者(在 render 函数中) | Agent(生成 JSON) |
| 组件映射 | 硬编码 | 主题动态映射 |
| 灵活性 | 低(必须预知所有 UI) | 中(Agent 可组合组件树) |
| 安全性 | 最高(代码不可变) | 高(声明式,无执行代码) |
| 流式渲染 | 参数级流式 | 组件级流式(按任意顺序到达) |
| 跨平台 | 仅当前框架 | 同一 JSON 多端渲染 |
模式三:开放式生成式 UI
开放式有两种实现方式,共享”Agent 生成完整 HTML”的理念,但使用不同的沙箱技术:
3A. Open Generative UI(generateSandboxedUi 工具)
核心思想:Agent 调用 generateSandboxedUi 工具,流式生成 HTML/CSS/JS,前端通过 @jetbrains/websandbox 创建单层 iframe 沙箱渲染。
涉及文件:
- 运行时中间件:
packages/runtime/src/v2/runtime/open-generative-ui-middleware.ts - 前端渲染器:
packages/react-core/src/v2/components/OpenGenerativeUIRenderer.tsx
运行时:OpenGenerativeUIMiddleware 流式解析
中间件是一个 RxJS Middleware,拦截 Agent 事件流,将 generateSandboxedUi 工具调用的流式 JSON 参数解析为 Activity 事件。
Agent 输出是流式 JSON(参数不是一次性到达的):
TOOL_CALL_ARGS: '{"initialHeight":200,"css":"body{margin:0}'
TOOL_CALL_ARGS: '.container{padding:20px}","html":"<div cl'
TOOL_CALL_ARGS: 'ass="card"><h2>Hello</h2></div>","jsFunct'
TOOL_CALL_ARGS: 'ions":"function onClick(){alert(1)}","jsEx'
TOOL_CALL_ARGS: 'pressions":["onClick()"]}'
ArgsParser 使用 clarinet(SAX 风格流式 JSON 解析器)逐字符处理:
class ArgsParser {
private parser = clarinet.parser();
parser.onkey = (key) => {
this.currentKey = key;
if (key === "html") this.streamingHtmlKey = true;
};
parser.onvalue = (value) => {
if (this.streamingHtmlKey) {
this.emitPendingHtml(value);
this.emitParamDelta("htmlComplete", true);
} else if (this.currentKey === "css") {
this.emitParamDelta("css", value);
this.emitParamDelta("cssComplete", true);
}
};
// 每次 write() 后检查 clarinet 内部缓冲区,增量推送 HTML
private flushHtmlChunks() {
const textNode = this.parser.textNode;
if (textNode.length > this.htmlEmittedLength) {
const newContent = textNode.slice(this.htmlEmittedLength);
this.emitArrayItemDelta("html", newContent);
this.htmlEmittedLength = textNode.length;
}
}
}
事件抑制机制:中间件扣留 TOOL_CALL_START/ARGS/END 事件,直到第一个 Activity 事件发射。确保前端先看到沙箱渲染的 UI,再看到原始工具调用文本。
if (event.type === EventType.TOOL_CALL_START && name === "generateSandboxedUi") {
heldToolCallEvents.set(toolCallId, [event]); // 扣留
activeParsers.set(toolCallId, new ArgsParser(toolCallId, (activityEvent) => {
subscriber.next(activityEvent); // 先发 Activity 事件
flushHeldEvents(toolCallId); // 再释放扣留的事件
}));
return;
}
Activity 事件格式:
// ACTIVITY_SNAPSHOT — 初始快照
{
"type": "ACTIVITY_SNAPSHOT",
"messageId": "toolcall-123-activity",
"activityType": "open-generative-ui",
"content": { "initialHeight": 200, "generating": true }
}
// ACTIVITY_DELTA — 增量更新 (JSON Patch 格式)
{
"type": "ACTIVITY_DELTA",
"patch": [
{ "op": "add", "path": "/css", "value": "body{margin:0}" },
{ "op": "add", "path": "/cssComplete", "value": true }
]
}
// ACTIVITY_DELTA — HTML 流式推送
{
"type": "ACTIVITY_DELTA",
"patch": [
{ "op": "add", "path": "/html", "value": [] },
{ "op": "add", "path": "/html/-", "value": "<div cl" },
{ "op": "add", "path": "/html/-", "value": "ass=\"card\">" }
]
}
// ACTIVITY_DELTA — 生成完成
{
"type": "ACTIVITY_DELTA",
"patch": [{ "op": "add", "path": "/generating", "value": false }]
}
前端:OpenGenerativeUIActivityRenderer
内容 Schema:
const OpenGenerativeUIContentSchema = z.object({
initialHeight: z.number().optional(), // 初始高度
generating: z.boolean().optional(), // 是否还在生成
css: z.string().optional(), // 完整 CSS
cssComplete: z.boolean().optional(), // CSS 是否接收完毕
html: z.array(z.string()).optional(), // HTML 片段数组(流式追加)
htmlComplete: z.boolean().optional(), // HTML 是否接收完毕
jsFunctions: z.string().optional(), // JS 函数定义
jsFunctionsComplete: z.boolean().optional(),
jsExpressions: z.array(z.string()).optional(),// JS 表达式(按序执行)
jsExpressionsComplete: z.boolean().optional(),
});
渲染阶段(两个沙箱阶段):
阶段 1:Preview(预览沙箱)
│ 条件:cssComplete && 有 html chunk && !htmlComplete
│ 行为:创建轻量 iframe (Websandbox),逐步更新 body.innerHTML
│ 目的:让用户在流式传输中就看到 UI 雏形
│
▼ htmlComplete = true
│
阶段 2:Final(最终沙箱)
│ 条件:htmlComplete
│ 行为:销毁预览沙箱,创建完整沙箱
│ 注入完整 HTML + CSS + jsFunctions + jsExpressions
│ 注入开发者注册的 sandbox functions(通过 localApi)
│
▼ generating = false
│
阶段 3:完成
│ 测量实际高度 → 调整 iframe 高度
│ 移除 loading 遮罩
节流机制(1 秒节流,关键变化立即刷新):
function shouldFlushImmediately(prev, next) {
if (next.cssComplete && !prev?.cssComplete) return true; // CSS 完成 → 首次预览
if (next.htmlComplete) return true; // HTML 完成 → 切到最终沙箱
if (next.generating === false) return true; // 生成完毕 → 移除 loading
if (next.jsFunctions && !prev?.jsFunctions) return true; // JS 函数到达
if (next.html?.length && !prev?.html?.length) return true; // 首个 HTML chunk
return false;
}
沙箱实现:使用 @jetbrains/websandbox 创建隔离 iframe:
const sandbox = Websandbox.create(localApi, {
frameContainer: container,
frameContent: ensureHead(htmlContent),
});
sandbox.promise.then(() => {
sandbox.run(jsFunctions); // 执行函数定义
sandbox.run(jsExpressions); // 执行表达式调用
});
3B. MCP Apps(MCP 应用沙箱)
核心思想:Agent 通过 MCP Server 返回完整 HTML 页面,前端创建双层 iframe 沙箱托管渲染。
文件: packages/react-core/src/v2/components/MCPAppsActivityRenderer.tsx
安全架构(双重 iframe):
┌─ 主应用 (CopilotKit) ────────────────────────┐
│ ┌─ 外层 iframe (srcdoc, CSP 限制) ────────┐ │
│ │ ┌─ 内层 iframe (srcdoc, sandbox 属性) ┐ │ │
│ │ │ MCP App 的 HTML/JS/CSS │ │ │
│ │ │ 通过 postMessage 与 Agent 通信 │ │ │
│ │ └────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
外层 iframe 的 HTML 由 buildSandboxHTML() 生成,包含 CSP(Content Security Policy)头:
function buildSandboxHTML(extraCspDomains?: string[]): string {
return `<!doctype html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data:;
style-src * blob: data: 'unsafe-inline';
connect-src *; ..." />
</head>
<body>
<script>
// 创建内层 iframe,设置 sandbox 属性
const inner = document.createElement("iframe");
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
// 消息桥接:外层 → 内层、内层 → 父窗口
window.addEventListener("message", async (event) => {
if (event.source === window.parent) {
// 父窗口消息 → 转发到内层 iframe
if (event.data?.method === "ui/notifications/sandbox-resource-ready") {
inner.srcdoc = event.data.params.html;
} else {
inner.contentWindow.postMessage(event.data, "*");
}
} else if (event.source === inner.contentWindow) {
// 内层 iframe 消息 → 转发到父窗口
window.parent.postMessage(event.data, "*");
}
});
</script>
</body>
</html>`;
}
与 Open Generative UI 的区别:
| 维度 | Open GenUI | MCP Apps |
|---|---|---|
| Agent 交互方式 | 调用 generateSandboxedUi 工具 | 调用 MCP Server 的 App 工具 |
| HTML 来源 | Agent 直接流式生成 | MCP Server 返回 |
| 沙箱结构 | 单层(@jetbrains/websandbox) | 双层(外层 CSP + 内层 sandbox) |
| 通信方式 | Websandbox.run() (RPC) | postMessage (JSON-RPC) |
| 适用场景 | Agent 动态生成 UI | 复用已有 MCP 工具界面 |
端到端数据流全景图(Open Generative UI)
┌─────────────────────────────────────────────────────────────────┐
│ Agent (LangGraph/CrewAI/...) │
│ │
│ LLM 决定调用 generateSandboxedUi 工具,流式输出参数: │
│ {"initialHeight":200,"css":"...","html":"...","jsFunctions":"..."}
│ │
└──────────────────────────┬──────────────────────────────────────┘
│ AG-UI Protocol (SSE 事件流)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Runtime (packages/runtime) │
│ │
│ TOOL_CALL_START(name="generateSandboxedUi") ──→ 扣留 │
│ TOOL_CALL_ARGS(delta='{"initialHeight":200,') ──→ ArgsParser │
│ → ACTIVITY_SNAPSHOT({initialHeight:200, generating:true}) │
│ → 释放扣留的 TOOL_CALL_START │
│ TOOL_CALL_ARGS(delta='"css":"body{}"') ──→ ArgsParser │
│ → ACTIVITY_DELTA(patch:[{op:"add",path:"/css",value:"..."}]) │
│ → ACTIVITY_DELTA(patch:[{op:"add",path:"/cssComplete",...}]) │
│ TOOL_CALL_ARGS(delta='"html":"<div') ──→ ArgsParser │
│ → ACTIVITY_DELTA(patch:[{op:"add",path:"/html",value:[]}]) │
│ → ACTIVITY_DELTA(patch:[{op:"add",path:"/html/-",...}]) │
│ ... (更多 HTML chunks) ... │
│ TOOL_CALL_ARGS(delta='"htmlComplete":true') ──→ │
│ TOOL_CALL_END ──→ ACTIVITY_DELTA(generating:false) │
│ │
└──────────────────────────┬──────────────────────────────────────┘
│ AG-UI Protocol (SSE)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (packages/react-core) │
│ │
│ CopilotKitProvider 接收事件流 │
│ │ │
│ ├─ Activity 事件 → 匹配 activityType="open-generative-ui" │
│ │ → OpenGenerativeUIActivityRenderer │
│ │ │
│ ├─ 工具调用事件 → 匹配 renderToolCalls 注册表 │
│ │ → OpenGenerativeUIToolRenderer │
│ │ (显示 placeholderMessages) │
│ │ │
│ ▼ │
│ OpenGenerativeUIActivityRenderer │
│ │ │
│ │ 1. ACTIVITY_SNAPSHOT → 初始化 content 状态 │
│ │ content = {initialHeight:200, generating:true} │
│ │ │
│ │ 2. ACTIVITY_DELTA(css, cssComplete) │
│ │ → shouldFlushImmediately → 立即更新 │
│ │ │
│ │ 3. ACTIVITY_DELTA(html chunks) │
│ │ cssComplete=true && html.length>0 → 创建预览沙箱 │
│ │ → Websandbox.create() → iframe 显示预览 │
│ │ │
│ │ 4. ACTIVITY_DELTA(htmlComplete) │
│ │ fullHtml = html.join("") │
│ │ → 销毁预览沙箱 → 创建最终沙箱 │
│ │ │
│ │ 5. ACTIVITY_DELTA(jsFunctions) │
│ │ → sandbox.run(jsFunctions) │
│ │ │
│ │ 6. ACTIVITY_DELTA(jsExpressions) │
│ │ → sandbox.run(expr1) → sandbox.run(expr2) → ... │
│ │ │
│ │ 7. ACTIVITY_DELTA(generating:false) │
│ │ → 测量 iframe 内容高度 → 移除 loading 遮罩 │
│ │ │
└─────────────────────────────────────────────────────────────────┘
增量更新能力对比
场景:第一次对话生成了三个天气卡片,第二次对话要求”把上海的卡片文字改成红色”。
受控 UI:不能精准修改
每个 tool call 是聊天历史中的不可变记录,AG-UI 协议没有 TOOL_CALL_UPDATE 事件。Agent 再次调用工具只会追加新组件,不会修改旧的:
第一次: show_weather({city:"北京"}) → Card #1
show_weather({city:"上海"}) → Card #2
show_weather({city:"广州"}) → Card #3
第二次: show_weather({city:"上海", textColor:"red"})
→ 新增 Card #4(旧的 Card #2 依然存在)
结果: 4 张卡片,不是 3 张
变通方案:把数据抽到 agent.state,组件从 state 读取。Agent 通过 OnStateChanged 更新特定城市数据,React key-based reconciliation 只更新变化的卡片。
A2UI:天然支持精准修改
A2UI 把 UI 当数据库操作——组件是表结构,数据是行,path 是主键,dataModelUpdate 就是 UPDATE ... WHERE path = ?。
数据模型(一个 Surface,统一管理):
Surface: "weather_dashboard"
├── 组件树:
│ Column#root
│ ├── Card#beijing → Text#bj_temp → {path: "/cities/0/temperature"}
│ ├── Card#shanghai → Text#sh_temp → {path: "/cities/1/temperature"}
│ └── Card#guangzhou→ Text#gz_temp → {path: "/cities/2/temperature"}
│
└── 数据模型:
/cities/0: { name:"北京", temperature:"22°C", textColor:"#333" }
/cities/1: { name:"上海", temperature:"18°C", textColor:"#333" }
/cities/2: { name:"广州", temperature:"28°C", textColor:"#333" }
第二次对话 — 只需一条消息:
{"envelope":"dataModelUpdate","surfaceId":"weather_dashboard","path":"/cities/1/textColor","value":"red"}
组件树不变,只改了数据模型的一个叶子节点。渲染器自动找到引用 /cities/1/textColor 的 Text#sh_temp,只更新它的样式。北京和广州的卡片完全不受影响。
Open GenUI:全量替换
每次 generateSandboxedUi 调用都是全新的工具调用。旧沙箱被 destroy(),新沙箱从零创建。用户会看到闪烁/白屏。
源码证据(OpenGenerativeUIRenderer.tsx):
useEffect(() => {
if (previewSandboxRef.current) {
previewSandboxRef.current.destroy(); // 销毁旧的
previewSandboxRef.current = null;
}
const sandbox = Websandbox.create(localApi, {
frameContent: ensureHead(htmlContent), // 完整 HTML 从零注入
});
sandboxRef.current = sandbox;
}, [fullHtml, css, localApi]); // fullHtml 变了就全部重来
MCP Apps:取决于 App 实现
双层 iframe 架构下有 postMessage 通道。Agent 可以发送新 HTML 片段,MCP App 内部可以决定是整体替换 srcdoc 还是做局部 DOM 更新——但这由 App 实现,不是框架保证的。
对比总结
第二次 "改文字为红色"
─────────────────────
受控 UI Agent 再次调用工具 → 追加新组件
旧的不能改,只能越来越多
❌ 不支持精准修改
(变通:用 agent.state 驱动渲染)
A2UI Agent 发 dataModelUpdate
只改 /cities/1/textColor
组件树不变,一行消息搞定
✅ 真正 patch,最精细
Open GenUI Agent 重新生成完整 HTML/CSS/JS
旧 iframe 销毁 → 新 iframe 从零创建
❌ 全量替换,有闪烁
MCP Apps 通过 postMessage 发新内容
取决于 App 内部实现
⚠️ 可能可以,但无框架保证
A2UI 的组件交互能力
A2UI 的组件复杂度不受限制。Theme 映射层就是普通的 React 组件,可以包含任意交互:
const a2uiTheme = {
Chart: ({ data, zoomable, onPointClick }) => {
const [zoomLevel, setZoomLevel] = useState(1);
return (
<ECharts
data={data}
zoom={zoomable ? { type: 'inside' } : undefined}
onEvents={{
click: (params) => onPointClick?.(params),
datazoom: (params) => setZoomLevel(params.zoom),
}}
/>
);
},
};
Agent 侧只需引用组件名:
{"envelope":"surfaceUpdate","surfaceId":"dashboard","components":[
{"id":"chart","component":"Chart","data":{"path":"/chartData"},"zoomable":true}
]}
A2UI 的真正优势是既能做复杂交互,又能精准修改。限制不在组件复杂度,而在 Agent 只能使用 Catalog 中已定义的组件,不能凭空创造新的交互行为。
LLM 如何发现和使用组件
三种模式使用完全不同的机制让 LLM 了解”有哪些可用、怎么用”。
受控 UI:工具 Schema 自描述
前端注册的每个工具都带有 name、description、parameters(Zod → JSON Schema),通过标准 function calling 流程传递给 LLM:
useCopilotAction({ name: "showStockPrice", parameters: z.object({...}) })
↓
CopilotKitContext 收集所有前端工具
↓
buildFrontendTools() 序列化为
{ name, description, parameters: JSON Schema }
(文件:core/src/core/run-handler.ts:887)
↓
AG-UI 协议 RunAgentInput.tools[] 发送给 Runtime
↓
convertToolsToVercelAITools() 转换为 Vercel AI SDK ToolSet
(文件:runtime/src/agent/index.ts:589)
↓
streamText({ tools: allTools }) → LLM 看到完整工具定义
LLM 通过 function calling 看到每个工具的名称、描述和参数 JSON Schema。开发者写的 description 越清晰,LLM 选择越准确。
A2UI:系统提示 + Catalog Schema
信息来源有两层:
1. 系统提示注入
A2UI_DEFAULT_GENERATION_GUIDELINES(packages/shared/src/a2ui-prompts.ts)被注入到 LLM 的系统提示中,内容包含:
- 必须调用
render_a2ui工具,且必须提供surfaceId和components参数 - 组件 ID 规则(必须有
root,禁止循环引用) - 路径绑定语法(
{ "path": "/data/field" }) - 模板重复语法(
children: { componentId: "card", path: "/items" }) - 严格的”禁止凭空创造组件名”规则
关键约束原文:
ONLY use component names from the Available Components schema — do NOT invent component names or use names not in the schema.
2. Catalog Schema(组件目录)
运行时注入 A2UIMiddleware 时,开发者配置的 Catalog(JSON Schema 格式的组件目录)被附加到 LLM 上下文。Catalog 定义了每个抽象组件的名称、属性、支持的类型。LLM 从中知道有哪些组件可用、每个属性接受什么类型。
Open GenUI:固定工具
LLM 看到固定的 generateSandboxedUi 工具,参数 Schema:
{
initialHeight?: number,
placeholderMessages?: string[],
css?: string,
html?: string,
jsFunctions?: string,
jsExpressions?: string[]
}
LLM 不需要知道任何组件,它直接生成完整的 HTML/CSS/JS。OpenGenerativeUIMiddleware(运行时中间件)拦截这个工具调用,将流式参数转为 Activity 事件推送到前端。
MCP Apps:MCP 协议发现
LLM 通过 MCP 协议与 MCP Server 通信,自动发现其提供的工具列表和 Schema。工具描述中说明会返回 HTML 页面。MCPAppsMiddleware 拦截对应的工具调用,将 HTML 封装到双层 iframe 中渲染。
三种机制对比
| 维度 | 受控 UI | A2UI | Open GenUI | MCP Apps |
|---|---|---|---|---|
| LLM 看到的信息 | 工具名+描述+参数 Schema | 系统提示+Catalog Schema+工具定义 | 固定工具定义 | MCP 工具发现 |
| 组件发现方式 | 开发者定义每个工具 | Catalog 声明可用组件 | 无需组件(直接生成 HTML) | MCP Server 注册工具 |
| LLM 选择依据 | description + 参数匹配 | Catalog 中组件名+属性匹配 | 无选择(固定工具) | MCP 工具描述 |
| 安全约束 | Schema 限制参数类型 | 系统提示强制只用 Catalog 组件 | iframe 沙箱隔离 | 双层 iframe + CSP |
工具调用完整链路(谁在哪里执行)
三种受控 UI 模式的执行位置不同——这决定了数据流向和渲染时机。
前端工具(useFrontendTool)— 工具在前端执行
LLM 决定调用 get_weather({location:"北京"})
│
▼ Runtime 把工具调用转为 AG-UI 事件
│ TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END
│
▼ SSE 推送到前端
│
前端收到事件 → status: "inProgress" → 显示 Loading
前端收到完整参数 → status: "executing"
│
▼ 前端执行 handler(不是后台!)
│ handler: async ({location}) => fetchWeather(location)
│
前端拿到结果 → render 渲染组件 → status: "complete"
│
前端把结果回传给 Runtime → Runtime 喂回 LLM
│
LLM 继续生成后续文本(或调用更多工具)
数据流:LLM → Runtime → 前端(执行+渲染) → Runtime(回传结果) → LLM
纯渲染工具(useRenderToolCall)— 工具在后台执行
LLM/Agent 在后台调用 showStockPrice({symbol:"AAPL"})
│
▼ 后台拿到结果 + 转为 AG-UI 事件(已含 result)
│ TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_RESULT
│
▼ SSE 推送到前端
│
前端收到事件 → render 函数直接渲染组件
数据流:LLM → Runtime(执行) → 前端(只渲染)
人机协作(useHumanInTheLoop)— 前端执行,等待用户操作
LLM 决定调用 deleteAccount({accountId:"ABC"})
│
▼ Runtime 转为 AG-UI 事件 → SSE 推送到前端
│
前端收到事件 → status: "inProgress" → 显示 Loading
前端收到完整参数 → status: "executing"
│
▼ 前端执行 handler,返回一个挂起的 Promise
│ handler 返回 new Promise(resolve => ref.current = resolve)
│
前端渲染交互 UI(确认/取消对话框)
│
▼ 用户点击 "确认"
│ respond("confirmed") → resolve("confirmed")
│
Promise resolve → 结果回传给 Runtime → LLM 收到 "confirmed"
│
status: "complete" → 显示 "已处理"
数据流:LLM → Runtime → 前端(等用户) → Runtime(回传用户选择) → LLM
对比
| 模式 | 谁执行 handler | 事件流方向 |
|---|---|---|
useFrontendTool | 前端执行 | Runtime → 前端(执行)→ Runtime(回传结果) |
useRenderToolCall | 后台执行 | Runtime → 前端(只渲染) |
useHumanInTheLoop | 前端执行(等用户操作) | Runtime → 前端(等用户确认)→ Runtime(回传结果) |
核心区别:后台只负责告诉前端”LLM 想调用什么工具、参数是什么”,实际执行和渲染发生在前端(除 useRenderToolCall 外)。
两种 LLM 组件发现机制对比:工具 Schema vs 系统提示
CopilotKit 用两种方式让 LLM 知道”有哪些组件、怎么用”:工具 Schema(Function Calling)和系统提示 + Catalog。两者不只是”怎么告诉 LLM”的差异,而是决定了整个架构的能力边界。
方式一:工具 Schema(Function Calling)
代表:受控 UI、Open GenUI、MCP Apps
LLM 看到 N 个独立的 tool,每个 tool 对应一个组件:
tools: [
{
name: "showStockPrice",
description: "展示股票价格",
parameters: {
type: "object",
properties: { symbol: { type: "string" } },
required: ["symbol"]
}
},
{ name: "showWeather", ... },
{ name: "generateSandboxedUi", ... }
]
LLM 通过原生 function calling 选择调用哪个,输出结构化的 {tool_name, arguments}。
方式二:系统提示 + 单一工具(Prompt + Catalog)
代表:A2UI
LLM 只看到一个 render_a2ui 工具,但系统提示 + Catalog 告诉它如何组合组件树:
system: "你必须调用 render_a2ui 工具。
ONLY use components from the schema.
组件必须有 root。路径绑定用 {path:'/xxx'}。
模板重复用 children: {componentId:'card', path:'/items'}。..."
tools: [
{ name: "render_a2ui", parameters: { surfaceId, components, data } }
]
context (Catalog Schema):
availableComponents: {
Card: { props: { title: string, child: string } },
Text: { props: { value: string } },
Column: { props: { children: string[] } },
...
}
LLM 靠理解提示词自由组合组件树,输出仍然是结构化工具调用,但内容是动态组合的。
核心区别
| 维度 | 工具 Schema(多个 tool) | 系统提示 + Catalog(一个 tool) |
|---|---|---|
| 选择机制 | LLM 原生 function calling 从 N 个 tool 中选 | LLM 靠理解提示词,在参数中自由组合 |
| 组合能力 | 每次调用 = 一个组件,不可组合 | 一次调用 = 整棵组件树,任意组合 |
| 可靠性 | 高 — 原生 API 保证结构正确 | 中 — 依赖 LLM 遵守提示词规则 |
| 新增组件成本 | 写一个 useCopilotAction,自动暴露给 LLM | 更新 Catalog Schema + 提示词 |
| Token 消耗 | 每个工具定义占 token,组件多时开销大 | 系统提示一次性占 token,组件多时更省 |
| 动态性 | 低 — 工具列表是固定的 | 高 — LLM 可以创造性地组合组件 |
| 错误模式 | 参数 Schema 校验,错误可控 | LLM 可能用不存在的组件名、循环引用 |
| 迭代修改 | 只能追加新调用,不能修改旧的 | 可以精准 patch(dataModelUpdate) |
工具 Schema 的优缺点
优点:
- 可靠:原生 function calling 是 LLM 最擅长、最稳定的输出格式
- 简单:开发者只需定义 name + description + parameters,不需要写复杂提示词
- 类型安全:JSON Schema 校验参数,非法参数被拦截
- 调试容易:每个 tool call 是独立的,边界清晰
缺点:
- 不可组合:LLM 调用
showStockPrice只能得到一个组件,不能把 Card 和 Chart 组合在一起 - 扩展开销:50 个组件 = 50 个 tool 定义,每个都占 token
- 无法迭代修改:没有”更新之前调用”的机制,只能追加
系统提示 + Catalog 的优缺点
优点:
- 自由组合:LLM 可以根据用户意图动态组合组件树(Column > Card > Text),不受预定义组合的限制
- 精准修改:
dataModelUpdate通过路径修改单个数据节点,不需要重建 - Token 效率:100 个组件只需一个 tool + 一个 Catalog,比 100 个 tool 定义更省
- 跨平台:同一个 JSON 可以在不同框架(React/Vue/Angular)渲染
缺点:
- 依赖 LLM 遵守规则:如果 LLM 无视”只用 Catalog 中的组件”指令,会生成无效组件名导致运行时崩溃
- 调试困难:组件树是 LLM 动态生成的,输出不可预测
- 提示词脆弱:规则越多(ID 规则、路径语法、模板语法),LLM 犯错概率越高
- Catalog 维护:组件多了之后,Catalog Schema 本身变成一个需要维护的”第二代码库”
一句话总结
工具 Schema 是白盒 — 开发者完全控制每个组件的边界,LLM 只做选择和填值,安全但死板。
系统提示 + Catalog 是灰盒 — 开发者控制组件的种类,LLM 控制组件的组合方式,灵活但依赖 LLM 的”听话程度”。
这也解释了为什么 CopilotKit 两者都保留——90% 的场景用工具 Schema 就够了,需要动态组合和精准修改时才上 A2UI。
选型建议
| 场景 | 推荐模式 | 理由 |
|---|---|---|
| 绝大多数应用 | 受控 (AG-UI) | 安全、类型安全、调试容易,覆盖 90% 需求 |
| Agent 需动态组合布局 | 声明式 (A2UI) | Agent 可根据上下文选择组件组合方式 |
| Agent 需动态生成 UI | Open GenUI | Agent 直接生成 HTML,iframe 沙箱渲染 |
| 已有 MCP 工具需可视化 | MCP Apps | 直接渲染 MCP App 的 HTML 界面 |
| 跨平台 UI 一致性 | 声明式 (A2UI) | 同一 JSON 多端渲染 |
| 安全性要求极高 | 受控 (AG-UI) | Agent 无法生成任意代码 |
| 需要迭代式修改 UI | 声明式 (A2UI) | dataModelUpdate 按路径精准更新,不重建 |
| 一次性展示,不需要修改 | 任意模式均可 | 增量更新能力无差异 |
关键文件索引
| 层 | 文件 | 职责 |
|---|---|---|
| 前端 | react-core/src/hooks/use-copilot-action.ts | 统一入口,路由到三种模式 |
| 前端 | react-core/src/hooks/use-render-tool-call.ts | 注册 ToolCall 渲染器 |
| 前端 | react-core/src/hooks/use-human-in-the-loop.ts | Promise 挂起等待用户响应 |
| 前端 | react-core/src/hooks/use-frontend-tool.ts | 注册前端执行的工具 |
| 前端 | react-core/src/v2/hooks/use-frontend-tool.tsx | 底层工具注册实现 |
| 前端 | react-core/src/v2/hooks/use-human-in-the-loop.tsx | 底层 HITL 实现 |
| 前端 | react-core/src/v2/hooks/use-agent.tsx | useAgent — 代理连接控制 |
| 前端 | react-core/src/v2/components/OpenGenerativeUIRenderer.tsx | Open GenUI iframe 沙箱渲染 |
| 前端 | react-core/src/v2/components/MCPAppsActivityRenderer.tsx | MCP Apps 双层 iframe 渲染 |
| 前端 | react-core/src/types/frontend-action.ts | Action 状态机类型定义 |
| 运行时 | runtime/src/v2/runtime/open-generative-ui-middleware.ts | 流式 JSON → Activity 事件 |
| 运行时 | runtime/src/agent/index.ts | convertToolsToVercelAITools 工具格式转换、BuiltInAgent 核心逻辑 |
| 共享 | shared/src/a2ui-prompts.ts | A2UI LLM 系统提示(A2UI_DEFAULT_GENERATION_GUIDELINES) |
| 核心 | core/src/core/run-handler.ts | buildFrontendTools() 序列化前端工具为 AG-UI 工具格式 |
| A2UI | a2ui-renderer/src/ | A2UI 声明式 UI 渲染器 |