7. 从零开始搭建一个 AI Agent —— UI 卡片渲染系统

UI 卡片渲染系统 ------ 让 Agent 不再只会"说话"

这不是一个"给聊天加个气泡"的教程。 我们要做的,是一个真正能 让 Agent 调用工具渲染交互式卡片支持用户点击选择交互结果回传给 Agent 继续对话 的 UI 卡片系统。 从方案选型到流式参数累积,从三层连环 Bug 到注册表模式,每一步都写清楚为什么这么做。


目录

  1. [回顾与问题:Agent 只能"说话"吗?](#回顾与问题:Agent 只能"说话"吗? "#1-%E5%9B%9E%E9%A1%BE%E4%B8%8E%E9%97%AE%E9%A2%98agent-%E5%8F%AA%E8%83%BD%E8%AF%B4%E8%AF%9D%E5%90%97")
  2. 方案选型:六条路里挑一条
  3. 整体架构:一张图看清数据流
  4. [后端工具定义:让 Agent 学会"画图"](#后端工具定义:让 Agent 学会"画图" "#4-%E5%90%8E%E7%AB%AF%E5%B7%A5%E5%85%B7%E5%AE%9A%E4%B9%89%E8%AE%A9-agent-%E5%AD%A6%E4%BC%9A%E7%94%BB%E5%9B%BE")
  5. 流式参数累积:一个绕不开的坑
  6. 前端卡片注册表:开闭原则的实践
  7. [卡片交互闭环:从点击到 Agent 继续思考](#卡片交互闭环:从点击到 Agent 继续思考 "#7-%E5%8D%A1%E7%89%87%E4%BA%A4%E4%BA%92%E9%97%AD%E7%8E%AF%E4%BB%8E%E7%82%B9%E5%87%BB%E5%88%B0-agent-%E7%BB%A7%E7%BB%AD%E6%80%9D%E8%80%83")
  8. [三层连环 Bug:一次真实的崩溃排查](#三层连环 Bug:一次真实的崩溃排查 "#8-%E4%B8%89%E5%B1%82%E8%BF%9E%E7%8E%AF-bug%E4%B8%80%E6%AC%A1%E7%9C%9F%E5%AE%9E%E7%9A%84%E5%B4%A9%E6%BA%83%E6%8E%92%E6%9F%A5")
  9. [未来演进:从注册表到 A2UI 协议](#未来演进:从注册表到 A2UI 协议 "#9-%E6%9C%AA%E6%9D%A5%E6%BC%94%E8%BF%9B%E4%BB%8E%E6%B3%A8%E5%86%8C%E8%A1%A8%E5%88%B0-a2ui-%E5%8D%8F%E8%AE%AE")
  10. 回顾与展望

1. 回顾与问题:Agent 只能"说话"吗?

在前六篇文章中,我们的 Agent 已经具备了不少能力:

篇目 能力
第一篇 项目初始化与基础 Agent
第二篇 文件系统操作与 Backend
第三篇 工具调用与 Permissions
第四篇 持久记忆与 Skills
第五篇 子 Agent 与任务委派
第六篇 Human-in-the-loop 中断审批

但有一个问题始终存在------Agent 和用户之间的沟通方式,只有纯文本

想象一下这个场景:

md 复制代码
用户:帮我查一下最近的订单

Agent:好的,以下是您的订单列表:

1. ORD-2024-001  MacBook Pro 14"  ¥14,999  已完成
2. ORD-2024-002  iPhone 15 Pro    ¥8,999   运输中
3. ORD-2024-003  AirPods Pro      ¥1,999   待发货
...

请问您想查看哪个订单的详情?

用户看到一坨文字,得手动复制订单号再发回去。这体验,跟 2005 年的 BBS 没什么区别。

如果 Agent 能直接渲染一个可点击的订单列表卡片呢?用户点一下就能看到详情,交互结果自动回传给 Agent------这才是 2026 年该有的样子。


2. 方案选型:六条路里挑一条

在动手写代码之前,我先花了两天时间调研了业界的做法。结果发现,"让 Agent 渲染 UI"这个问题,至少有六种解法。

2.1 六种方案一览

方案 原理 实现复杂度 LLM 友好度 交互能力 类型安全 流式渲染
A: Markdown 扩展 Agent 输出自定义容器语法,前端解析渲染
B: 服务端 HTML 后端直接返回 HTML 片段,前端 dangerouslySetInnerHTML
C: Server-Driven UI 后端返回 UI Schema JSON,前端通用渲染器动态组装
D: React Server Components 后端返回序列化的 React 组件树 极高
E: Vega-Lite 图表 Agent 输出 Vega-Lite spec,前端渲染图表
F: 工具调用 + 卡片注册表 Agent 调用工具传 type+data,前端注册表匹配组件

2.2 逐一拆解

方案 A:Markdown 扩展

Agent 输出 Markdown,前端用 markdown-it-container 识别自定义容器:

markdown 复制代码
:::order-list{title="您的订单列表"}
| 订单号 | 商品 | 金额 | 状态 |
|--------|------|------|------|
| ORD-001 | MacBook Pro | ¥14999 | 已完成 |
:::
  • LLM 最擅长的输出格式,流式渲染极好------逐 token 输出即可
  • 无法支持交互------纯静态内容,点击回调做不了
  • 适合:只读展示(表格、列表),不需要用户操作

方案 B:服务端 HTML

后端工具直接拼 HTML 字符串,前端用 dangerouslySetInnerHTML 渲染。

  • ✅ 实现最简单,前端零逻辑
  • XSS 风险------必须用 DOMPurify sanitize,否则 LLM 生成的 HTML 可能注入恶意脚本
  • ❌ LLM 拼 HTML 容易出错(标签不闭合、属性格式不对)
  • 适合:内部工具、快速原型

方案 C:Server-Driven UI(代表:Adaptive Cards / Slack Block Kit)

后端返回 UI Schema JSON,前端有一套通用组件库根据 Schema 动态组装:

json 复制代码
{
  "type": "table",
  "columns": [
    { "header": "订单号", "field": "id" },
    { "header": "金额", "field": "amount", "format": "currency" }
  ],
  "data": [{ "id": "ORD-001", "amount": 14999 }]
}
  • 扩展性极高------后端随意组合 UI,前端零改动
  • ✅ 安全性好------白名单组件,无 XSS
  • 实现成本高------需要设计完整的 Schema 规范 + 渲染引擎
  • 适合:企业级应用、低代码平台

方案 D:React Server Components

  • ✅ 完整的 React 交互体验
  • 技术栈绑定严重------必须 Next.js 全栈同构,不适合我们的 WebSocket 场景
  • ❌ LLM 无法直接生成 RSC

方案 E:Vega-Lite 图表

Agent 输出 Vega-Lite JSON spec,前端用 react-vega 渲染。

  • ✅ LLM 擅长生成结构化 JSON spec
  • ✅ 内置丰富的交互(缩放、tooltip、筛选)
  • 仅限图表------做不了订单列表、表单等通用 UI

方案 F:工具调用 + 卡片注册表(我们选的)

Agent 调用 show_ui_card 工具传 type + data,前端根据 type 在注册表中找到对应组件渲染。

  • 前后端解耦------后端只关心数据结构,前端独立维护卡片组件
  • 交互能力强 ------卡片可通过 onAction 回调与 Agent 双向通信
  • ✅ 类型安全------Zod schema 约束参数结构
  • ❌ 每种卡片需要预先开发组件(前端硬编码)
  • ❌ 流式渲染受限------需要等工具参数完整才能渲染

2.3 为什么选 F?

核心考量是当前阶段的实际需求

  1. 我们需要交互能力(用户点击订单查看详情)→ 排除 A、B
  2. 我们是 WebSocket 架构,不是 Next.js → 排除 D
  3. Vega-Lite 只能做图表 → 排除 E(但未来可以共存)
  4. Server-Driven UI 虽好,但实现成本太高,当前只有两种卡片类型,杀鸡不用牛刀

最终选择:工具调用 + 卡片注册表。简单、够用、交互能力强。 但这不是终点------后面会聊到未来的演进方向。


3. 整体架构:一张图看清数据流

架构确定后,先把整个数据流理清楚。UI 卡片系统涉及 后端工具 → WebSocket 传输 → 前端渲染 → 用户交互 → 回传 Agent 五个步骤:

md 复制代码
┌──────────────────────────────────────────────────────────────────┐
│                        完整数据流                                 │
│                                                                  │
│  用户:"查看我的订单"                                              │
│       │                                                          │
│       ▼                                                          │
│  ┌─────────┐    tool_call     ┌──────────────┐                   │
│  │  Agent   │ ──────────────► │ show_ui_card  │                   │
│  │ (后端)   │                 │ type:order_list                   │
│  │          │ ◄────────────── │ data:[...]    │                   │
│  │          │   工具结果       └──────────────┘                   │
│  └────┬────┘                                                      │
│       │ WebSocket                                                 │
│       │ { type: 'tool_call', name: 'show_ui_card',                │
│       │   args: { type: 'order_list', data: [...] } }             │
│       ▼                                                           │
│  ┌─────────────────┐                                              │
│  │  useWebSocket    │  检测到 show_ui_card                         │
│  │  (前端 Hook)     │  → 创建 UICardData                          │
│  │                 │  → 挂到 msg.uiCards                          │
│  └────────┬────────┘                                              │
│           ▼                                                       │
│  ┌─────────────────┐    registry.get('order_list')                │
│  │    UICard       │ ──────────────────────────► OrderListCard    │
│  │  (分发组件)      │                                              │
│  └────────┬────────┘                                              │
│           │ 渲染                                                   │
│           ▼                                                       │
│  ┌─────────────────┐                                              │
│  │ OrderListCard   │  用户点击某个订单                              │
│  │  ┌───────────┐  │  → onAction('select_order', order)           │
│  │  │ MacBook ▸ │  │                                              │
│  │  │ iPhone  ▸ │  │                                              │
│  │  │ AirPods ▸ │  │                                              │
│  │  └───────────┘  │                                              │
│  └────────┬────────┘                                              │
│           │ 交互消息                                               │
│           ▼                                                       │
│  ┌─────────────────┐                                              │
│  │  handleCardAction│  → 拼接成交互消息                             │
│  │  (useWebSocket)  │  → 作为新 chat 消息发给 Agent                │
│  └────────┬────────┘                                              │
│           │                                                       │
│           ▼                                                       │
│  Agent 收到交互消息 → 调用 show_ui_card(order_detail) → 渲染详情卡片 │
└──────────────────────────────────────────────────────────────────┘

这张图里有几个关键角色:

角色 职责 文件
show_ui_card 工具 Agent 的"画笔",告诉前端渲染什么卡片 src/tools.ts
get_order_history 工具 数据源,提供订单数据 src/tools.ts
WebSocket 传输层 把 tool_call 事件实时推给前端 src/server.ts
useWebSocket Hook 识别 show_ui_card,构建 UICardData frontend/src/hooks/useWebSocket.ts
UICard 组件 根据 type 分发到对应的卡片组件 frontend/src/components/UICard.tsx
cardRegistry 注册表,维护 type → 组件的映射 frontend/src/components/cardRegistry.ts
具体卡片组件 真正渲染 UI 的组件(OrderListCard 等) frontend/src/components/cards/

下面逐一展开。


4. 后端工具定义:让 Agent 学会"画图"

代码在 src/tools.ts 中。

4.1 思路

Agent 要渲染 UI 卡片,本质上需要两个能力:

  1. 获取数据 --- 得先知道订单有哪些
  2. 告诉前端"画什么" --- 不能直接操作 DOM,只能通过工具调用传递意图

所以我们需要两个工具:

工具 干什么 类比
get_order_history 查询订单数据 翻抽屉找订单
show_ui_card 告诉前端渲染哪种卡片 把订单贴到白板上

4.2 完整代码

先来看 get_order_history------一个返回模拟数据的查询工具:

typescript 复制代码
// src/tools.ts

export const getOrderHistoryTool = tool(
  async ({ userId, status }) => {
    // 模拟订单数据(生产环境这里会查数据库)
    const orders = [
      {
        id: 'ORD-2024-001',
        userId: 'u1',
        product: 'MacBook Pro 14"',
        amount: 14999,
        quantity: 1,
        status: 'completed',
        date: '2024-01-15',
        address: '北京市朝阳区xxx路xxx号',
        tracking: 'SF1234567890',
      },
      {
        id: 'ORD-2024-002',
        userId: 'u1',
        product: 'iPhone 15 Pro',
        amount: 8999,
        quantity: 1,
        status: 'shipping',
        date: '2024-01-20',
        address: '上海市浦东新区xxx路xxx号',
        tracking: 'YT9876543210',
      },
      // ... 更多订单
    ];

    // 按 userId 和 status 过滤
    const filtered = orders.filter(
      (o) => (!userId || o.userId === userId) && (!status || o.status === status)
    );

    return JSON.stringify(filtered, null, 2);
  },
  {
    name: 'get_order_history',
    description:
      '查询用户的历史订单数据。返回订单列表,包含订单号、商品名、金额、状态、地址等信息。' +
      '查询后建议调用 show_ui_card 以卡片形式展示结果。',
    schema: z.object({
      userId: z.string().optional().describe('用户 ID,不传则返回所有订单'),
      status: z
        .enum(['pending', 'shipping', 'completed', 'cancelled'])
        .optional()
        .describe('按状态过滤'),
    }),
  }
);

再看 show_ui_card------这个工具本身不做任何渲染,它只是一个"信号弹":

typescript 复制代码
// src/tools.ts

export const showUICardTool = tool(
  async ({ type, data, title }) => {
    // 工具本身只返回确认,实际渲染由前端完成
    return JSON.stringify({
      success: true,
      message: `UI 卡片已展示: ${type}`,
      title: title || type,
    });
  },
  {
    name: 'show_ui_card',
    description:
      '在聊天界面展示一个交互式 UI 卡片。用于展示结构化数据(如订单列表、订单详情等)。' +
      '卡片支持用户交互(点击选择、查看详情),交互结果会作为新消息回传给你。',
    schema: z.object({
      type: z
        .string()
        .describe('卡片类型标识,前端根据此值匹配对应的卡片组件。已注册:order_list、order_detail。'),
      data: z.any().describe('卡片数据,结构取决于卡片类型。'),
      title: z.string().optional().describe('卡片标题,显示在卡片顶部'),
    }),
  }
);

4.3 逐段拆解

部分 说明
showUICardTool 的函数体 只返回 JSON 确认,不做任何渲染。因为工具运行在后端,它无法直接操作前端 DOM。真正的渲染逻辑在前端。
type 参数 卡片类型标识,前端用它去注册表里查找对应的组件。就像 HTML 的 tag name
data 参数 z.any() 定义,因为不同卡片的数据结构不同。订单列表传数组,订单详情传对象。
description 的写法 这是给 LLM 看的。我们明确告诉它"查询后建议调用 show_ui_card",LLM 就会按这个流程走。

4.4 在 Agent 中注册工具 + 强制规则

工具定义好了,还得注册到 Agent 的工具列表里,并在 system prompt 中强调使用规则:

typescript 复制代码
// src/agents.ts

export const agent = createDeepAgent({
  model,
  tools: [calculatorTool, getCurrentTimeTool, showUICardTool, getOrderHistoryTool],
  // ...
});

注意 system prompt 中的强制规则------这一步非常关键:

typescript 复制代码
// src/agents.ts - system prompt 追加内容
'【UI 卡片展示 - 强制规则】\n' +
'当你使用 get_order_history 查询到订单数据后,你必须在回复中调用 show_ui_card 工具来展示订单列表卡片。\n' +
'这是强制要求,绝对禁止在调用 get_order_history 后直接用文本回复订单列表。\n' +
'\n' +
'正确流程:\n' +
'1. 用户问:"查看我的订单"\n' +
'2. 你调用:get_order_history({})\n' +
'3. 你调用:show_ui_card({ type: "order_list", data: <订单数组>, title: "您的订单列表" })\n' +
'4. 用户点击某个订单后,你会收到交互消息\n' +
'5. 你调用:show_ui_card({ type: "order_detail", data: <被点击的订单对象>, title: "订单详情" })\n'

为什么需要强制规则? LLM 有时候会"偷懒"------它拿到订单数据后,倾向于直接生成文本回复,而不是再调用一次工具。通过在 system prompt 中明确写出"正确流程"和"绝对禁止",可以大幅提高它按预期流程执行的概率。

4.5 卡片类型怎么确定?三种方式对比

在设计方案时,"卡片类型由谁决定"这个问题我纠结了很久。最终有三种思路:

方式 原理 优点 缺点
Agent 推断(当前选择) LLM 根据 system prompt 和 schema 自行决定 type 灵活,Agent 可动态选择 可能推断错误,需要 prompt 引导
工具绑定 工具返回值自带 _uiCard: { type, title } 100% 正确(代码决定),卡片展示最快 工具与卡片强耦合,灵活性低
前端映射 前端硬编码 get_order_history → order_list 简单 前后端耦合,新增卡片要改前端

当前选择了 Agent 推断,因为它最灵活。但"工具绑定"方式在展示速度和可靠性上更优------这是未来优化的方向(详见第 9 节)。


5. 流式参数累积:一个绕不开的坑

代码在 src/server.ts 中。

5.1 问题

在之前的实现中,WebSocket 服务端处理工具调用的逻辑很简单:

typescript 复制代码
// ❌ 旧代码:直接从 block.input 解析参数
if (block.type === 'tool_use' && block.name && block.id) {
  send(ws, {
    type: 'tool_call',
    name: block.name,
    args: block.input ? JSON.parse(block.input) : {},
    id: block.id,
  });
}

这在非流式场景下没问题。但流式传输时 ,LLM 的工具参数是通过多个 input_json_delta 事件逐步发送的:

md 复制代码
chunk 1: { type: 'tool_use', name: 'show_ui_card', id: 'xxx', input: '' }
chunk 2: { type: 'input_json_delta', input: '{"type":' }
chunk 3: { type: 'input_json_delta', input: '"order_list",' }
chunk 4: { type: 'input_json_delta', input: '"data":[...]}' }
chunk 5: { content: [] }  // 空数组 = 本轮结束

如果我们在 chunk 1 就急着发送 tool_call,前端拿到的 args 是空的 {}------卡片类型和数据全丢了。

5.2 解决方案:先攒后发

思路很简单:用一个 Map 累积所有 delta,等本轮结束后再统一发送

md 复制代码
┌─────────────────────────────────────────────────────────┐
│                  流式参数累积流程                          │
│                                                         │
│  chunk(tool_use)  ──► 注册到 pendingToolInputs           │
│       │                                                 │
│  chunk(delta)     ──► 追加 input 到 pendingToolInputs    │
│       │                                                 │
│  chunk(delta)     ──► 继续追加...                        │
│       │                                                 │
│  chunk([])        ──► 本轮结束,遍历 pendingToolInputs    │
│       │              → JSON.parse 累积的 input           │
│       │              → 发送完整的 tool_call               │
│       │              → 清空 pendingToolInputs             │
│       ▼                                                 │
│  stream 结束      ──► 兜底:发送剩余未发的 tool_call       │
└─────────────────────────────────────────────────────────┘

5.3 完整代码

typescript 复制代码
// src/server.ts - handleMessage 中的流式处理

// 累积流式 tool_use 的 input_json_delta
const pendingToolInputs = new Map<string, { name: string; input: string }>();
let currentToolId: string | null = null;

for await (const [msg, metadata] of stream) {
  // ... 省略非 AI 消息的处理

  if (Array.isArray(msg.content)) {
    for (const block of msg.content) {
      // 处理 tool_use block(初始声明,input 可能为空)
      if (block.type === 'tool_use') {
        if (block.name && block.id) {
          currentToolId = block.id;
          if (!pendingToolInputs.has(block.id)) {
            pendingToolInputs.set(block.id, { name: block.name, input: '' });
          }
          const pending = pendingToolInputs.get(block.id)!;
          if (block.input) {
            pending.input += block.input;
          }
        }
      }
      // 处理 input_json_delta(流式传输工具参数)
      if (block.type === 'input_json_delta' && currentToolId) {
        const pending = pendingToolInputs.get(currentToolId);
        if (pending) {
          pending.input += block.input || '';
        }
      }
    }

    // 空数组 chunk 表示本轮 AI 输出结束
    if (msg.content.length === 0) {
      for (const [id, pending] of pendingToolInputs) {
        if (!sentToolCallIds.has(id)) {
          sentToolCallIds.add(id);
          let parsedArgs = {};
          if (pending.input) {
            try { parsedArgs = JSON.parse(pending.input); } catch { parsedArgs = {}; }
          }
          send(ws, {
            type: 'tool_call',
            name: pending.name,
            args: parsedArgs,
            id,
          });
        }
      }
      pendingToolInputs.clear();
      currentToolId = null;
    }
  }
}

// Stream 结束后,兜底发送剩余的 tool_call
for (const [id, pending] of pendingToolInputs) {
  if (!sentToolCallIds.has(id)) {
    sentToolCallIds.add(id);
    let parsedArgs = {};
    if (pending.input) {
      try { parsedArgs = JSON.parse(pending.input); } catch { parsedArgs = {}; }
    }
    send(ws, {
      type: 'tool_call',
      name: pending.name,
      args: parsedArgs,
      id,
    });
  }
}

5.4 逐段拆解

部分 说明
pendingToolInputs Map<toolId, { name, input }>,累积每个工具调用的参数 JSON 字符串
currentToolId 记录当前正在接收参数的工具 ID,input_json_delta 需要知道往哪个工具追加
block.type === 'tool_use' 工具的"声明"阶段,此时 input 可能为空或不完整
block.type === 'input_json_delta' 参数的"增量"阶段,每次只来一小段 JSON
msg.content.length === 0 关键信号:空数组 chunk 表示 LLM 本轮输出结束,可以安全地解析和发送了
兜底循环 防止某些模型不发空数组 chunk 就直接结束 stream

踩坑记录 :最初我直接在收到 tool_use block 时就发送 tool_call,结果前端收到的 args 总是 {}。调试了半小时才发现,流式传输时 block.inputtool_use 阶段是空的,真正的参数要通过后续的 input_json_delta 逐步拼接。解决方案就是"先攒后发"------用 Map 累积,等空数组 chunk 或 stream 结束时再统一处理。


6. 前端卡片注册表:开闭原则的实践

代码在 frontend/src/components/ 目录中。

6.1 设计目标

前端需要解决一个问题:如何根据 show_ui_card 传来的 type 字段,渲染对应的卡片组件?

最简单的做法是写一个 switch

typescript 复制代码
// ❌ 硬编码方式
switch (card.type) {
  case 'order_list': return <OrderListCard ... />;
  case 'order_detail': return <OrderDetailCard ... />;
  // 每新增一种卡片,都要改这个文件
}

但这违反了开闭原则 (对扩展开放,对修改关闭)。每新增一种卡片类型,都要改 UICard.tsx,改着改着就会引入 bug。

更好的方案是注册表模式 (Registry Pattern)------就像一本电话簿,组件自己注册进来,UICard 只负责查号:

md 复制代码
┌──────────────────────────────────────────────────┐
│              卡片注册表模式                         │
│                                                  │
│  cards/index.ts(注册入口)                        │
│    registerCard('order_list', OrderListCard)      │
│    registerCard('order_detail', OrderDetailCard)  │
│         │                                        │
│         ▼                                        │
│  ┌─────────────────────┐                         │
│  │   cardRegistry       │  Map<string, Component> │
│  │   'order_list'  → OrderListCard               │
│  │   'order_detail' → OrderDetailCard            │
│  └─────────┬───────────┘                         │
│            │ getCardComponent(type)               │
│            ▼                                     │
│  ┌─────────────────────┐                         │
│  │   UICard.tsx         │  只负责分发,不关心具体   │
│  │   type → 查注册表     │  有哪些卡片类型          │
│  │   → 渲染对应组件      │                         │
│  └─────────────────────┘                         │
│                                                  │
│  新增卡片?只需:                                   │
│    1. 创建 cards/NewCard.tsx                      │
│    2. 在 cards/index.ts 注册一行                   │
│    UICard.tsx 和 cardRegistry.ts 完全不用动 ✅     │
└──────────────────────────────────────────────────┘

6.2 注册表:cardRegistry.ts

typescript 复制代码
// frontend/src/components/cardRegistry.ts

import type { ComponentType } from 'react';
import type { UICardData } from '../types';

/** 卡片组件的 props 接口 ------ 所有卡片都遵循这个契约 */
export interface CardComponentProps {
  card: UICardData;
  onAction: (action: string, payload: unknown) => void;
}

/** 卡片注册表:type → 组件的映射 */
const registry = new Map<string, ComponentType<CardComponentProps>>();

/** 注册卡片组件 */
export function registerCard(
  type: string,
  component: ComponentType<CardComponentProps>
): void {
  registry.set(type, component);
}

/** 获取卡片组件,未找到返回 null */
export function getCardComponent(
  type: string
): ComponentType<CardComponentProps> | null {
  return registry.get(type) ?? null;
}

/** 获取所有已注册的卡片类型(调试用) */
export function getRegisteredTypes(): string[] {
  return Array.from(registry.keys());
}

核心就是一个 Map<string, ComponentType>。简单,但够用。

6.3 注册入口:cards/index.ts

typescript 复制代码
// frontend/src/components/cards/index.ts

import { registerCard } from '../cardRegistry';
import { OrderListCard } from './OrderListCard';
import { OrderDetailCard } from './OrderDetailCard';

// 注册订单列表卡片
registerCard('order_list', OrderListCard);

// 注册订单详情卡片
registerCard('order_detail', OrderDetailCard);

// 新增卡片在此处注册...
// registerCard('your_type', YourCardComponent);

main.tsx 中导入这个文件,启动时自动完成注册:

typescript 复制代码
// frontend/src/main.tsx
import './components/cards'; // 副作用导入,自动注册所有卡片

6.4 分发组件:UICard.tsx

typescript 复制代码
// frontend/src/components/UICard.tsx

import type { UICardData } from '../types';
import { getCardComponent, getRegisteredTypes } from './cardRegistry';
import './UICard.css';

interface UICardProps {
  card: UICardData;
  onAction: (action: string, payload: unknown) => void;
}

export function UICard({ card, onAction }: UICardProps) {
  const CardComponent = getCardComponent(card.type);

  // 未注册的卡片类型 → 显示错误提示
  if (!CardComponent) {
    console.warn('[UICard] 未注册的卡片类型:', card.type);
    console.warn('[UICard] 已注册的类型:', getRegisteredTypes());
    return (
      <div className="ui-card ui-card-unknown">
        未注册的卡片类型: <code>{card.type || '(empty)'}</code>
        <div className="card-hint">请在 cards/ 目录创建组件并注册到 cardRegistry</div>
      </div>
    );
  }

  // 渲染卡片组件,捕获渲染错误防止整个页面崩溃
  try {
    return <CardComponent card={card} onAction={onAction} />;
  } catch (err: any) {
    console.error('[UICard] 渲染卡片失败:', err);
    return (
      <div className="ui-card ui-card-error">
        渲染卡片失败: <code>{err.message}</code>
        <div className="card-hint">卡片类型: {card.type}, data 类型: {typeof card.data}</div>
      </div>
    );
  }
}

注意两个细节:

细节 为什么
未注册时打印 getRegisteredTypes() 方便调试------Agent 传了 order_list,但前端没注册,控制台一眼就能看到问题
try/catch 包裹渲染 卡片数据可能格式异常,不能让一个卡片渲染失败导致整个聊天页面白屏

6.5 具体卡片:OrderListCard

typescript 复制代码
// frontend/src/components/cards/OrderListCard.tsx

import type { CardComponentProps } from '../cardRegistry';
import type { OrderData } from '../../types';
import '../UICard.css';

/** 订单状态映射 */
const STATUS_MAP: Record<string, { label: string; className: string }> = {
  pending: { label: '待发货', className: 'status-pending' },
  shipping: { label: '运输中', className: 'status-shipping' },
  completed: { label: '已完成', className: 'status-completed' },
  cancelled: { label: '已取消', className: 'status-cancelled' },
};

export function OrderListCard({ card, onAction }: CardComponentProps) {
  // ⚠️ data 可能是字符串(LLM 双重编码)或数组,需要兼容处理
  let orders: OrderData[];
  if (typeof card.data === 'string') {
    try { orders = JSON.parse(card.data); } catch { orders = []; }
  } else {
    orders = (card.data as OrderData[]) || [];
  }
  const title = card.title || '订单列表';

  if (!orders || orders.length === 0) {
    return (
      <div className="ui-card">
        <div className="card-header"><span className="card-title">{title}</span></div>
        <div className="card-empty">暂无订单数据</div>
      </div>
    );
  }

  return (
    <div className="ui-card">
      <div className="card-header">
        <span className="card-title">{title}</span>
        <span className="card-badge">{orders.length} 个订单</span>
      </div>
      <div className="order-list">
        {orders.map((order) => {
          const statusInfo = STATUS_MAP[order.status] || { label: order.status, className: '' };
          return (
            <div
              key={order.id}
              className="order-item"
              onClick={() => onAction('select_order', order)}
            >
              <div className="order-main">
                <span className="order-product">{order.product}</span>
                <span className={`order-status ${statusInfo.className}`}>{statusInfo.label}</span>
              </div>
              <div className="order-meta">
                <span className="order-id">{order.id}</span>
                <span className="order-date">{order.date}</span>
                <span className="order-amount">¥{order.amount.toLocaleString()}</span>
              </div>
            </div>
          );
        })}
      </div>
      <div className="card-hint">点击订单查看详情</div>
    </div>
  );
}

踩坑记录card.data 有时候是字符串,有时候是对象。为什么?因为 LLM 在调用 show_ui_card 时,data 参数经过 JSON 序列化后,某些情况下会被双重编码 (字符串里套 JSON 字符串)。所以卡片组件必须兼容两种情况:先检查 typeof,如果是字符串就 JSON.parse,否则直接用。这个坑的完整排查过程见第 8 节。


7. 卡片交互闭环:从点击到 Agent 继续思考

代码在 frontend/src/hooks/useWebSocket.ts 中。

7.1 识别 show_ui_card

当 WebSocket 收到 tool_call 事件时,useWebSocket 需要判断:这是普通工具调用,还是 UI 卡片?

typescript 复制代码
// frontend/src/hooks/useWebSocket.ts

// 在 tool_call 事件处理中
if (event.name === 'show_ui_card') {
  // 创建 UICardData 并挂到当前消息的 uiCards 数组
  const card: UICardData = {
    id: event.id,
    type: event.args.type as UICardType,
    title: event.args.title as string | undefined,
    data: event.args.data,
  };
  currentAssistantRef.current.uiCards = [
    ...(currentAssistantRef.current.uiCards || []),
    card,
  ];
} else {
  // 普通工具调用,走原来的逻辑
  currentAssistantRef.current.toolCalls = [
    ...(currentAssistantRef.current.toolCalls || []),
    toolInfo,
  ];
}

注意 uiCardstoolCalls并行存储 的------show_ui_card 既是一个工具调用(需要在 ToolCallViewer 中显示记录),又是一个 UI 卡片(需要渲染卡片组件)。在 ChatInterface 中,我们会过滤掉 show_ui_card 的工具调用显示,避免重复:

typescript 复制代码
// frontend/src/components/ChatInterface.tsx

{/* 工具调用可视化(过滤掉 show_ui_card,因为它有自己的卡片渲染) */}
{msg.toolCalls && msg.toolCalls.filter(t => t.name !== 'show_ui_card').length > 0 && (
  <ToolCallViewer toolCalls={msg.toolCalls.filter(t => t.name !== 'show_ui_card')} />
)}

{/* UI 卡片 */}
{msg.uiCards && msg.uiCards.length > 0 && (
  <div className="ui-cards-container">
    {msg.uiCards.map((card) => (
      <UICard
        key={card.id}
        card={card}
        onAction={(action, payload) => onCardAction(card.id, action, payload)}
      />
    ))}
  </div>
)}

7.2 交互回传

用户在卡片上点击后,交互结果需要作为新消息发给 Agent:

typescript 复制代码
// frontend/src/hooks/useWebSocket.ts

const handleCardAction = useCallback(
  (_cardId: string, action: string, payload: unknown) => {
    // 将卡片交互拼接成文本消息
    const message = `[用户从卡片交互] 操作: ${action}, 数据: ${JSON.stringify(payload)}`;

    // 添加用户消息到聊天列表(让用户看到自己做了什么操作)
    const userEntry: ChatEntry = {
      id: crypto.randomUUID(),
      role: 'user',
      content: message,
      timestamp: new Date(),
    };
    setMessages((prev) => [...prev, userEntry]);

    // 重置 assistant 引用,准备接收新一轮回复
    currentAssistantRef.current = null;
    pendingToolsRef.current.clear();
    setIsLoading(true);

    // 通过 WebSocket 发送给后端
    wsRef.current.send(
      JSON.stringify({ type: 'chat', message, threadId })
    );
  },
  [threadId],
);

当用户点击订单列表中的某个订单时,Agent 会收到类似这样的消息:

md 复制代码
[用户从卡片交互] 操作: select_order, 数据: {"id":"ORD-2024-001","product":"MacBook Pro 14"","amount":14999,...}

Agent 解析这条消息后,会调用 show_ui_card({ type: 'order_detail', data: <订单对象> }),前端渲染订单详情卡片------闭环完成


8. 三层连环 Bug:一次真实的崩溃排查

这个 Bug 值得单独拿出来讲,因为它完美展示了流式系统 + LLM 不可靠输出叠加时的典型问题。

8.1 症状

  1. Agent 调用 show_ui_card 后,前端显示 "未注册的卡片类型: (empty)"
  2. typetitledata 参数全部为 undefined
  3. 前端页面偶尔崩溃变空白,WebSocket 断开连接

8.2 根因:三层问题叠加

md 复制代码
┌──────────────────────────────────────────────────────────────┐
│                    三层连环 Bug                                │
│                                                              │
│  第一层:流式传输时序问题                                       │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ LLM 流式输出时,参数通过 input_json_delta 逐步传输       │  │
│  │ tool_use block 到达时 input 是空的                      │  │
│  │ 旧代码在此时立即发送 tool_call → args 为 {}             │  │
│  └────────────────────────────────────────────────────────┘  │
│                          ↓                                   │
│  第二层:空数组 chunk 触发时机                                  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ LangGraph 在每个工具调用完成后发送空数组 chunk []         │  │
│  │ 这是"参数传输完毕"的信号                                 │  │
│  │ 旧代码没有利用这个信号,而是等 stream 结束才 fallback     │  │
│  │ 但此时 ID 已在 sentToolCallIds 中,被跳过了              │  │
│  └────────────────────────────────────────────────────────┘  │
│                          ↓                                   │
│  第三层:前端数据类型不匹配                                     │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ LLM 生成的 data 参数是字符串化的 JSON(双重编码)         │  │
│  │ data: "[{"id": "ORD-001"}]"  ← 字符串,不是数组     │  │
│  │ OrderListCard 直接 card.data.map() → 崩溃              │  │
│  │ → WebSocket 断开 → 页面空白                             │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

8.3 调试过程

排查的关键是在每个节点加日志,逐层定位:

md 复制代码
🔧 [tool_use] name=show_ui_card, id=call_xxx, hasInput=false
🔧 [tool_use] ✅ 注册到 pendingToolInputs: show_ui_card
   [delta] currentToolId=call_xxx, input="{"type": "order"
   [delta] currentToolId=call_xxx, input="_list"
   ... (100+ delta)
   [delta] currentToolId=call_xxx, input="}"
🔧 [empty chunk] pendingToolInputs 数量: 1
🔧 [empty chunk] tool=show_ui_card, input长度=1198
🔧 [empty chunk] ✅ JSON解析成功, keys=type,data,title
🔧 [empty chunk] 📤 发送 tool_call: show_ui_card, args keys=type,data,title

排查步骤:

  1. 添加 [delta] 日志 → 确认 input_json_delta 正确到达 ✅
  2. 添加 [tool_use] 日志 → 确认 tool_use block 正确注册 ✅
  3. 添加 [empty chunk] 日志 → 确认空数组 chunk 触发发送 ✅
  4. 检查前端 → 发现 data 是字符串,.map() 崩溃 ❌

8.4 修复方案

后端 :累积 input_json_delta,空数组 chunk 时发送,移除直接发送 msg.tool_calls 的代码(见第 5 节完整代码)。

前端 :卡片组件兼容字符串和对象两种 data 格式:

typescript 复制代码
// OrderListCard.tsx - 防御性解析
let orders: OrderData[];
if (typeof card.data === 'string') {
  try { orders = JSON.parse(card.data); } catch { orders = []; }
} else {
  orders = (card.data as OrderData[]) || [];
}

UICard.tsx:添加错误边界,防止渲染崩溃导致整个页面白屏:

typescript 复制代码
try {
  return <CardComponent card={card} onAction={onAction} />;
} catch (err: any) {
  return (
    <div className="ui-card ui-card-error">
      渲染卡片失败: <code>{err.message}</code>
    </div>
  );
}

8.5 教训总结

教训 说明
流式传输 ≠ 一次性数据 工具参数可能分多个 chunk 到达,需要累积后再发送
空数组 chunk 是信号 LangGraph 用它标记"这个工具输出结束",是发送累积参数的时机
LLM 输出不可信 参数类型可能与 schema 不符(字符串 vs 数组),前端必须做防御性解析
错误边界很重要 组件崩溃不应导致整个页面空白,用 try-catch 包裹渲染
调试日志是王道 在关键节点加日志,快速定位问题层级

9. 未来演进:从注册表到 A2UI 协议

当前的"工具调用 + 卡片注册表"方案能用,但并不完美。在调研过程中,我发现了一个值得关注的开放协议------A2UI(Agent to UI),以及几个可以立即优化的方向。

9.1 当前方案的痛点

痛点 说明
卡片展示延迟 get_order_history 返回的数据已经包含完整订单信息,但前端不知道要渲染卡片。必须等 Agent 再调用 show_ui_card,而 show_ui_carddata 参数包含大量 JSON,需要 100+ 个 delta chunk 才能传完
Token 浪费 同样的数据传了两遍:一次在 get_order_history 的 tool_result 里,一次在 show_ui_card 的 data 参数里
Agent 可能推断错误 卡片类型由 LLM 推断,可能传错 type
仅支持 Web React 组件绑定,无法跨平台

9.2 短期优化:工具绑定卡片 + 引用模式

工具绑定卡片类型------让工具自己决定渲染方式:

typescript 复制代码
// 工具返回数据时自带 UI 元信息
const getOrderHistory = tool(async ({ userId }) => {
  const orders = await queryOrders(userId);
  return {
    data: orders,
    _uiCard: { type: "order_list", title: "您的订单" }  // ← 工具自己决定
  };
});

后端发送 tool_result 时附带 _uiCard 字段,前端收到后立即渲染卡片 ------不需要 Agent 再调用 show_ui_card

维度 旧方案(Agent 推断) 新方案(工具绑定)
卡片展示速度 等 show_ui_card 参数流完(慢) 工具执行完立即展示(快)
可靠性 Agent 推断(可能出错) 代码决定(100% 正确)
Token 消耗 高(数据传两遍) 低(不传 data)

show_ui_card 引用模式 ------复杂场景下,data 不传完整 JSON,而是传一个引用:

typescript 复制代码
// 旧方式:传完整数据(大量 JSON,100+ delta)
show_ui_card({ type: "order_list", data: [...大量JSON...], title: "..." })

// 新方式:传引用(几个字符,瞬间完成)
show_ui_card({ type: "order_list", data: { ref: "call_xxx" }, title: "..." })

前端收到引用后,从缓存中查找对应的 tool_result,立即用真实数据替换骨架屏。

9.3 中期展望:A2UI 协议

在调研中,我发现了 Google 发起的 A2UI(Agent to UI) 开放协议。它解决的核心问题是:Agent 如何安全地发送富 UI 到客户端?

A2UI 的核心设计:

md 复制代码
Agent (LLM) → A2UI JSON 消息 → 传输层 (SSE/WS/A2A)
                                      ↓
客户端 → 解析消息 → 渲染器 → 原生 UI (Angular/Flutter/React/...)
特性 说明
邻接表模型 组件扁平列表 + ID 引用,LLM 生成友好(不需要完美嵌套)
Surface 一个完整 UI 单元,支持增量更新
数据绑定 JSON Pointer 路径,结构与数据分离
Catalog 组件目录,限制 Agent 可用组件(安全)
Action 用户交互回传给 Agent

A2UI 与我们当前方案的对比

维度 当前方案(工具+注册表) A2UI
跨平台 仅 Web(React 组件) Web/Mobile/Desktop/Native
多 Agent 协作 不支持 原生支持
渐进式渲染 不支持(等完整参数) 原生支持(组件逐个到达即可渲染)
实现复杂度 高(需实现渲染器+数据绑定)
生态集成 自定义 与 A2A/MCP/AG-UI 无缝集成

但当前不急着迁移------A2UI 实现成本高,当前两种卡片类型用注册表模式完全够用。触发迁移的信号是:需要跨平台、需要复杂表单、需要多 Agent 协作。

9.4 演进路线图

md 复制代码
┌──────────────────────────────────────────────────────────────┐
│                      演进路线                                 │
│                                                              │
│  Phase 1(当前)                                               │
│  工具绑定卡片 + show_ui_card 引用模式                           │
│  → 解决卡片展示延迟和 Token 浪费                                │
│                                                              │
│  Phase 2(中期)                                               │
│  引入 A2UI 渲染器,支持跨平台和复杂表单                          │
│  → 卡片注册表作为 A2UI 的特化组件                               │
│                                                              │
│  Phase 3(长期)                                               │
│  全面迁移到 A2UI 协议                                          │
│  → 与 A2A/MCP 生态集成,支持多 Agent 协作                       │
│                                                              │
│  触发条件:                                                    │
│  • 需要移动端? → 进入 Phase 2                                 │
│  • 需要复杂表单? → 进入 Phase 2                               │
│  • 需要多 Agent 协作? → 进入 Phase 3                          │
│  • 只是简单 Web 应用? → 保持 Phase 1 即可                     │
└──────────────────────────────────────────────────────────────┘

核心原则:不要为了"架构先进"而迁移。当前方案能满足需求,就先优化当前方案。等真正遇到瓶颈再升级。


10. 回顾与展望

我们做了什么

  1. 方案选型 --- 对比了六种 UI 渲染方案(Markdown 扩展、服务端 HTML、Server-Driven UI、RSC、Vega-Lite、工具+注册表),选择了当前最务实的方案
  2. 定义了两个后端工具 --- get_order_history 查询订单数据,show_ui_card 告诉前端渲染卡片
  3. 解决了流式参数累积问题 --- 用 pendingToolInputs Map 先攒 input_json_delta,等空数组 chunk 或 stream 结束后再统一发送
  4. 实现了前端卡片注册表 --- cardRegistry + UICard 分发组件,新增卡片只需创建组件 + 注册一行,符合开闭原则
  5. 完成了卡片交互闭环 --- 用户点击卡片 → 交互结果拼接成消息 → 通过 WebSocket 回传 Agent → Agent 继续思考并渲染下一张卡片
  6. 排查了三层连环 Bug --- 流式时序 + 空数组 chunk 触发 + 双重编码,层层递进
  7. 规划了演进路线 --- 短期工具绑定卡片 + 引用模式,中期引入 A2UI 渲染器,长期全面迁移

完整运行

bash 复制代码
# 启动后端 + 前端
npm run dev

# 在聊天中输入
"帮我查看最近的订单"

# 预期效果:
# 1. Agent 调用 get_order_history 获取订单数据
# 2. Agent 调用 show_ui_card 渲染订单列表卡片
# 3. 聊天界面出现可点击的订单列表
# 4. 点击某个订单 → Agent 渲染订单详情卡片

后续可以做什么

  • 工具绑定卡片类型 --- 让工具自己决定渲染方式,消除卡片展示延迟
  • show_ui_card 引用模式 --- data 传引用而非完整 JSON,节省 Token
  • 更多卡片类型 --- 商品列表、用户画像、数据图表、确认对话框
  • 引入 Vega-Lite --- 让 Agent 能渲染图表类可视化
  • A2UI 渲染器 --- 当需要跨平台或复杂表单时引入

从纯文本到交互式卡片,Agent 的表达能力迈出了一大步。工具不只是让 Agent 有了"手",还让它有了"脸"------能画、能展示、能跟你互动。

相关推荐
槑有老呆1 小时前
从零搭建 LLM 流式对话前端:一文读懂 SSE、二进制流与 Vue 3 响应式
前端·javascript
Zzj_tju1 小时前
Alignment Science 与可控生成:偏好学习、宪法式训练和可验证对齐
人工智能·学习·语言模型
moMo1 小时前
让 AI 不再"凭感觉做事"——Claude Code Skills 的实践与思考
人工智能
万联WANFLOW1 小时前
月之暗面发布 Kimi K3:全球首个开源 3T 级大模型,前端编程竞技场登顶第一
网络·人工智能·架构·业界资讯
前端缘梦1 小时前
LangGraph 核心特性技术详解:流式输出、持久化、记忆体系与中断实战。
前端·人工智能·程序员
张龙6871 小时前
前端错误监控生产级方案:从 onerror 捕获到 Source Map 精准还原
前端·javascript
渣波1 小时前
从零手写 Vite Mock 流式 AI 对话,搞懂 LLM 的“打字机”特效
前端·javascript
CypressTel1 小时前
OpenAI推出ChatGPT Work:AI开始从“回答问题”走向“完成工作”——赛柏特AI快讯
人工智能·chatgpt
m沐沐1 小时前
【机器学习】基于 dlib 面部关键点的多表情分类
人工智能·python·深度学习·机器学习·计算机视觉·人脸识别·表情识别