CopilotKit Tool Rendering 实战:用 useRenderTool 把后端工具调用渲染成聊天内的 React 卡片
2026/9/13 11:21:32 网站建设 项目流程

CopilotKit Tool Rendering 实战:用 useRenderTool 把后端工具调用渲染成聊天内的 React 卡片

【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit

本篇基于 CopilotKit showcase 仓库中showcase/integrations/langgraph-typescript集成下的 Tool Rendering 演示,讲解如何把后端 Agent(LangGraph)发出的工具调用,在CopilotChat聊天记录中渲染为专属的 React 组件:每个"值得精心设计"的工具通过useRenderTool注册独立渲染器,未被认领的工具则统一交给useDefaultRenderTool注册的通配兜底渲染器。读完本篇,你可以掌握useRenderTool/useDefaultRenderTool的注册方式、parameters/result/status三要素的用法,以及配套后端工具定义、建议提示与端到端测试的完整链路。

演示的定位与核心机制

该演示的 README(README.md)给出了最简描述:

Backend agent tool calls are rendered as React components in the chat transcript. The frontend usesuseRenderToolto register a renderer per tool name, receivingargs,result, andstatusso the UI can reflect both in-flight and completed calls.

即:后端 Agent 的工具调用会以 React 组件的形式出现在聊天正文中;前端按工具名注册渲染器,渲染回调可以拿到工具入参、返回值与调用状态,从而同时呈现"进行中"和"已完成"两种 UI。

在 showcase 的 manifest(manifest.yaml)中,该 demo 的正式条目为tool-rendering,名称为 "Generative UI: Tool Rendering (Custom)",描述为"每个工具定制渲染器(WeatherCard、FlightListCard 等)外加一个通配兜底渲染器"。值得注意的是,manifest 中还存在一组"三方递进"的兄弟演示,它们共用同一个后端 Agent,只在前端如何渲染同一批工具调用上有所不同:

Demo id路由前端策略
tool-rendering-default-catchall/demos/tool-rendering-default-catchall零自定义渲染器,完全依赖 CopilotKit 内置默认 UI
tool-rendering-custom-catchall/demos/tool-rendering-custom-catchall只注册一个通配渲染器(useDefaultRenderTool),所有工具调用画同一张品牌卡片
tool-rendering(本篇主角)/demos/tool-rendering每个"有意思"的工具各配专属渲染器,通配兜底兜住漏网的
tool-rendering-reasoning-chain/demos/tool-rendering-reasoning-chain在工具卡片之外再穿插渲染 reasoning 消息槽

这种"同后端、不同前端"的设计让读者可以直接对比三种渲染策略的视觉效果与工程取舍,也是本仓库对该主题最完整的实操素材。

后端:LangGraph Agent 与四个 Mock 工具

三个 Tool Rendering 演示共用一个后端 Agent,源码位于 src/agent/tool-rendering.ts。文件头注释明确了这一共享关系:

All cells share this backend -- they differ only in how the frontend renders the same tool calls.

四个工具的定义

后端用@langchain/core/toolstool()工厂定义了 4 个确定性 mock 工具(src/agent/tool-rendering.ts#L58-L181):

工具名入参(zod schema)返回值
get_weatherlocation: string固定值:citytemperature: 68humidity: 55wind_speed: 10conditions: "Sunny"
search_flightsorigin: stringdestination: string3 班 mock 航班(United UA231 / Delta DL412 / JetBlue B6722),各含起降时间与price_usd
get_stock_priceticker: string,可选price_usdchange_pct默认随机股价;若显式传入price_usd/change_pct则原样回显,用于测试夹具的确定性输出
roll_d20可选value: number1–20 的随机点数;传入合法value时原样返回(同样是确定性测试钩子)

get_stock_priceroll_d20的"确定性覆盖参数"注释写得非常直白:它们允许 LLM(或 aimock 测试夹具)脚本化一个确定的报价/点数,便于 e2e 断言。这个细节后面在测试章节还会看到。

图结构与路由

Agent 用 LangGraphStateGraph组装(src/agent/tool-rendering.ts#L299-L310):

const workflow = new StateGraph(AgentStateAnnotation) .addNode("chat_node", chatNode) .addNode("tool_node", new ToolNode(tools)) .addEdge(START, "chat_node") .addEdge("tool_node", "chat_node") .addConditionalEdges("chat_node", shouldContinue as any); const memory = new MemorySaver(); export const graph = workflow.compile({ checkpointer: memory });

关键实现点:

  • 状态定义AgentStateAnnotation直接展开CopilotKitStateAnnotation.spec(来自@copilotkit/sdk-js/langgraph),使消息历史与 CopilotKit 状态(如前端 actions)共用同一份图状态。
  • 工具绑定chatNode中把前端注册的 actions 通过convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? [])转成动态结构化工具,再与 4 个后端 mock 工具一起model.bindTools(...),模型因此既能调后端工具也能触发前端行为。
  • 条件路由shouldContinue检查最后一条消息的tool_calls,若工具名不是前端 action 就走向tool_node,否则结束——保证后端工具由ToolNode执行、前端 action 交给客户端。
  • 消息归一化normalizeAssistantMessage处理 langchain-js 流式重组的一种边界情况:当模型在同一条消息里同时返回文本内容和工具调用时,工具调用可能只落在additional_kwargs.tool_calls而没有提升到顶层tool_calls。由于路由、ToolNode与 AG-UI 的 TOOL_CALL_* 事件发射都依赖顶层tool_calls,若不归一化,工具调用会被静默丢弃、前端也就渲染不出卡片。从源码注释看,这是为了与 Python 版create_agent的行为保持 TS↔Python 对等。

前端:按工具名注册渲染器(useRenderTool)

演示页面 page.tsx 顶部用注释画出了"工具 → 组件"的映射关系:

get_weather → <WeatherCard /> (per-tool renderer) search_flights → <FlightListCard /> (per-tool renderer) get_stock_price → <StockCard /> (per-tool renderer) roll_d20 → <D20Card /> (per-tool renderer) * → <CustomCatchallRenderer /> (wildcard fallback)

页面结构是标准的 v2 用法:CopilotKitruntimeUrl="/api/copilotkit"agent="tool-rendering")包裹一个Chat组件,Chat内部通过多个 Hook 完成渲染器注册,最后渲染<CopilotChat agentId="tool-rendering" />

渲染回调的三要素:parameters / result / status

get_weather的注册为例(page.tsx#L76-L98):

import { CopilotKit, CopilotChat, useRenderTool, useDefaultRenderTool, } from "@copilotkit/react-core/v2"; import { z } from "zod"; useRenderTool( { name: "get_weather", parameters: z.object({ location: z.string(), }), render: ({ parameters, result, status }) => { const loading = status !== "complete"; const parsed = parseJsonResult<WeatherResult>(result); return ( <WeatherCard loading={loading} location={parameters?.location ?? parsed.city ?? ""} temperature={parsed.temperature} humidity={parsed.humidity} windSpeed={parsed.wind_speed} conditions={parsed.conditions} /> ); }, }, [], );

要点逐条拆解:

  1. name:精确匹配工具名,注册表按agentId:name去重,后注册者覆盖先注册者(见 Hook 源码 use-render-tool.tsx)。

  2. parameters:一个 Standard Schema V1 兼容的 schema(示例用 Zod,Valibot / ArkType 等同样可用)。它决定了renderprops.parameters类型形状,也支持对参数做校验推断。

  3. render回调的入参是状态联合类型。从 Hook 源码(packages/react-core/src/v2/hooks/use-render-tool.tsx#L10-L41)可以看到,RenderToolProps是三种状态的联合:

    statusparameters 类型result 类型含义
    inProgressPartial<...>(参数可能还在流式拼装中)undefined工具调用参数尚未收齐
    executing完整推断类型undefined后端正在执行工具
    complete完整推断类型string工具已返回结果

    这解释了演示代码里反复出现的惯用法const loading = status !== "complete"inProgressexecuting两个阶段都当作"加载中"处理,卡片先展示占位态,等complete后再填充真实数据。

  4. 第二个参数deps:依赖数组,变化时会刷新注册。示例里传空数组[]表示注册一次即可。

  5. 返回值React.ReactElement | null;返回null表示该工具调用不渲染任何内容。

其余三个工具的注册(search_flightsFlightListCardget_stock_priceStockCardroll_d20D20Card)完全同构,区别只在各自的结果解析接口:

interface WeatherResult { city?: string; temperature?: number; humidity?: number; wind_speed?: number; conditions?: string; } interface FlightSearchResult { origin?: string; destination?: string; flights?: Flight[]; } interface StockResult { ticker?: string; price_usd?: number; change_pct?: number; }

一个值得学习的防御式写法:展示字段优先取流式中的parameters,取不到再回退到结果里回显的字段parameters?.location ?? parsed.city ?? "")。因为inProgress阶段参数本身是Partial的,而部分 mock 工具会把入参原样回显在结果中,双路取值能保证各阶段都有合理展示。roll_d20的渲染器还兼容了后端同时返回valueresult两个键的情况(page.tsx#L157-L165)。

卡片组件本身是普通受控组件,例如 weather-card.tsx:loading为真时显示 "Fetching weather..." 占位文案,完成后渲染温度大字号、湿度、风速与天气 emoji 映射,并且每个字段都带data-testidweather-cityweather-humidityweather-wind)供 e2e 断言。

结果解析:parseJsonResult 共享工具

工具返回值到达前端时可能是 JSON 字符串(Agent 按字符串发射),也可能已被 runtime 上游解码为对象。演示用一个共享 helper(parse-json-result.ts)统一处理两种形态:

export function parseJsonResult<T>(result: unknown): T { if (!result) return {} as T; try { return (typeof result === "string" ? JSON.parse(result) : result) as T; } catch { return {} as T; } }

缺失或不可解析时安全地返回{},卡片字段再配合?? "--"之类的占位显示,避免渲染崩溃。

通配兜底:useDefaultRenderTool 与品牌化 Catch-all

演示通过useDefaultRenderTool注册了一个通配渲染器(page.tsx#L172-L188),捕获所有没有被上面 4 个按名注册器认领的工具调用:

useDefaultRenderTool( { render: ({ name, parameters, status, result }) => ( <CustomCatchallRenderer name={name} parameters={parameters} status={status as CatchallToolStatus} result={result} /> ), }, [], );

custom-catchall-renderer.tsx 展示了一张信息完整的"通用工具卡片",其状态徽标把三个 status 映射成了用户友好的文案:

status徽标文案
inProgressstreaming
executingrunning
completedone

卡片分两段:Arguments区块用<pre>打印美化后的入参 JSON(safeStringify保证 stringify 失败时降级为String(value));Result区块在complete前显示斜体 "waiting for tool to finish…",完成后parseResult尝试JSON.parse,失败则原样输出。文件头注释还说明了一个工程取舍:该组件是故意从姊妹 cell(tool-rendering-custom-catchall)复制而非跨 cell import的,因为每个 showcase cell 都要求自包含,不能跨演示目录相互引用。

补充一点:useRenderTool本身也支持name: "*"的通配注册(见 Hook 源码中的重载与注释,packages/react-core/src/v2/hooks/use-render-tool.tsx),本演示选择用useDefaultRenderTool表达"默认渲染器"语义,两者在本例中效果等价为兜底。

建议提示与演示交互

suggestions.ts 用useConfigureSuggestions注册了 5 个建议药丸(pill),available: "always"表示始终可见:

药丸触发消息命中的工具路径
Weather in SF"What's the weather in San Francisco?"get_weather→ WeatherCard
Find flights"Find flights from SFO to JFK."search_flights→ FlightListCard
Stock price"What's the current price of AAPL?"get_stock_price→ StockCard
Roll a d20"Roll a 20-sided die."roll_d20→ D20Card
Chain tools"Chain a few tools in this single turn: get the weather in Tokyo, search flights from SFO to Tokyo, and roll a d20."单轮多工具调用

后端 system prompt 特意要求 "Call multiple tools in one turn if asked"(src/agent/tool-rendering.ts#L46-L52),因此 "Chain tools" 这条药丸可以验证一轮对话内多个工具调用各自独立挂载卡片的能力——roll_d20的注释也提到"每次工具调用都挂载自己的卡片,方便 e2e 计数"。

端到端测试:确定性夹具如何锁定断言

演示的 Playwright 用例位于 tool-rendering.spec.ts,它完整验证了上面注册链路的正确性:

  • 页面加载后断言 5 个建议药丸(data-testid="copilot-suggestion")全部可见;
  • 点击 "Weather in SF" 后,断言weather-card出现、城市为 "San Francisco"、湿度55%、风速10——正好对应后端 mock 的固定返回值;
  • 点击 "Find flights" 后断言flights-card的起降机场为 SFO→JFK,且航班行数>= 2
  • 点击 "Stock price" 后断言 ticker 为AAPL、价格$338.37、涨跌幅-2.96%。这正是前文提到的price_usd/change_pct确定性覆盖参数:测试夹具(spec 注释指明 aimock 夹具固定了每条药丸的工具调用序列)让 LLM 以固定参数调用工具,工具原样回显,从而把随机 mock 数据锁成可断言的确定值;
  • "Roll a d20" 断言生成恰好 5 张 d20 卡片且最后一张点数为 20,同样依赖value确定性覆盖。

同一集成下还有对应的tool-rendering-default-catchall.spec.tstool-rendering-custom-catchall.spec.ts,分别验证"零自定义渲染器"和"单通配渲染器"两种策略,配合本篇可完整复现三种策略的行为差异。

关键文件索引

关注点文件
演示入口与渲染器注册page.tsx
通配兜底卡片custom-catchall-renderer.tsx
品牌化天气卡片weather-card.tsx
航班列表卡片flight-list-card.tsx
股价 / d20 卡片stock-card.tsx、d20-card.tsx
建议提示suggestions.ts
共用后端 Agentsrc/agent/tool-rendering.ts
结果解析 helpersrc/app/demos/_shared/parse-json-result.ts
Hook 实现(渲染器注册/状态联合类型)packages/react-core/src/v2/hooks/use-render-tool.tsx、use-default-render-tool.tsx
e2e 断言tests/e2e/tool-rendering.spec.ts
演示清单条目manifest.yaml

小结

这个 demo 展示了 CopilotKit "Generative UI" 的一种轻量范式:工具调用即 UI 单元。工程上有四点值得直接搬走:按名注册器 + 通配兜底的两级渲染策略;用 Standard Schema(如 Zod)为parameters提供类型与安全;用status !== "complete"统一表达加载态、利用inProgress阶段的Partial参数与结果回显做防御式取值;以及为每个可断言字段预埋data-testid并为工具设计确定性覆盖参数,使 mock 随机数据也能被 e2e 测试稳定验证。

【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询