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_STARTTOOL_CALL_ARGSTOOL_CALL_ENDACTIVITY_SNAPSHOTACTIVITY_DELTARUN_STARTEDRUN_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-coreReact 核心:hooks (useAgent, useFrontendTool, useHumanInTheLoop, useCopilotAction)、CopilotKitProvider、v2 聊天组件
@copilotkit/react-ui预构建 UI:CopilotPopupCopilotSidebarCopilotChat 等开箱即用的聊天界面
@copilotkit/vueVue 框架支持
@copilotkit/angularAngular 框架支持
@copilotkit/a2ui-rendererA2UI 协议渲染器(声明式 UI)
@copilotkit/runtime运行时核心:Express/Hono 集成、中间件链(OpenGenerativeUI、MCP Apps)
@copilotkit/runtime-client-gqlGraphQL 客户端
@copilotkit/shared共享类型和工具函数
@copilotkit/core核心 Web 实用工具
@copilotkit/sdk-jsJavaScript SDK

纠正CopilotPopupCopilotSidebar 等聊天 UI 组件由 @copilotkit/react-ui 导出,不是 react-corereact-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.tspackages/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。

渲染生命周期(三种模式共享)

阶段含义argsresult典型 UI
inProgressAgent 正在生成工具调用参数部分可用(流式)Loading + 参数预览
executinghandler 正在执行完整加载动画或交互 UI
complete执行完成完整完整最终组件

关键特性

  • 流式参数argsinProgress 阶段是部分可用的(partial JSON),可做渐进式展示
  • 完全类型安全parameters 用 Zod 定义,argsresult 类型自动推导
  • 完整 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 GenUIMCP 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/textColorText#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 自描述

前端注册的每个工具都带有 namedescriptionparameters(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_GUIDELINESpackages/shared/src/a2ui-prompts.ts)被注入到 LLM 的系统提示中,内容包含:

  • 必须调用 render_a2ui 工具,且必须提供 surfaceIdcomponents 参数
  • 组件 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 中渲染。

三种机制对比

维度受控 UIA2UIOpen GenUIMCP 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 需动态生成 UIOpen GenUIAgent 直接生成 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.tsPromise 挂起等待用户响应
前端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.tsxuseAgent — 代理连接控制
前端react-core/src/v2/components/OpenGenerativeUIRenderer.tsxOpen GenUI iframe 沙箱渲染
前端react-core/src/v2/components/MCPAppsActivityRenderer.tsxMCP Apps 双层 iframe 渲染
前端react-core/src/types/frontend-action.tsAction 状态机类型定义
运行时runtime/src/v2/runtime/open-generative-ui-middleware.ts流式 JSON → Activity 事件
运行时runtime/src/agent/index.tsconvertToolsToVercelAITools 工具格式转换、BuiltInAgent 核心逻辑
共享shared/src/a2ui-prompts.tsA2UI LLM 系统提示(A2UI_DEFAULT_GENERATION_GUIDELINES
核心core/src/core/run-handler.tsbuildFrontendTools() 序列化前端工具为 AG-UI 工具格式
A2UIa2ui-renderer/src/A2UI 声明式 UI 渲染器