跳到主要内容

React 绑定层 — Provider、hooks 与重渲染

前三章讲的 CopilotKitCore 完全不认识 React。本章讲那层薄胶水:Provider 怎么把 React 世界(分散在各组件里的工具声明、上下文、渲染器)喂进 Core,以及 Core 的变化怎么变回 React 重渲染。

1. 它要解决的小问题

Core 是个普通 TS 类,状态存在它自己的 Map/Set 里,变化靠订阅者回调广播。React 想用它,得解决两件事:

  1. :用户在 <ThemeTool/> 里写 useFrontendTool(...),在 <Cart/> 里写 useAgentContext(...)——这些散落各处的声明,怎么汇集进同一个 Core 实例?
  2. :Core 里 agent 的消息流变了,怎么让 <CopilotChat/> 重渲染?

2. Provider:一个稳定的 Core 实例

CopilotKitProvider(CopilotKitProvider.tsx:271)做的第一件事是创建生命周期内只创建一次的 Core 实例,用 useRef 守住:

// 真实源码 packages/react-core/src/v2/providers/CopilotKitProvider.tsx:574
const copilotkitRef = useRef<CopilotKitCoreReact | null>(null);
if (copilotkitRef.current === null) {
copilotkitRef.current = new CopilotKitCoreReact({ runtimeUrl, headers, tools, ... });
}
const copilotkit = copilotkitRef.current;

注意类型是 CopilotKitCoreReact——core 的 React 子类(react-core.ts:31),它额外管「工具调用怎么渲染成 React 组件」(renderToolCalls)并覆写了 waitForPendingFrameworkUpdates(第 3 章 §8)。

配置变更不重建实例,而是通过一批 setter effect 推给 Core(CopilotKitProvider.tsx:693):setRuntimeUrl / setHeaders / setProperties / setAgents__unsafe_dev_only / setDebug 等。实例稳定很重要——它是所有 hooks 通过 context 共享的那一个。

3. 一个微妙的 effect 顺序问题

Provider 把工具/渲染器数组也同步给 Core,但首次挂载时故意跳过(CopilotKitProvider.tsx:733):

// 真实源码 packages/react-core/src/v2/providers/CopilotKitProvider.tsx:735
useEffect(() => {
if (!didMountRef.current) return; // 首次挂载跳过
copilotkit.setTools(allTools);
}, [copilotkit, allTools]);

为什么?React 的 effect 是自底向上跑的:子组件的 useFrontendTool effect(里面调 addTool)先于父 Provider 的 setter effect 执行。若父 setter 在挂载时就 setTools(allTools),会把子组件刚 addTool 进去的工具覆盖掉。所以挂载时让构造函数带初始值即可,setter 只处理后续的 prop 变更。didMountRef 在所有 setter effect 之后才置 true(CopilotKitProvider.tsx:757),保证它最后跑。

这是把「声明式 React」对接「命令式注册表」时的经典坑,值得记住。

4. 进:三个注册型 hook

hook注册什么怎么注册文件
useFrontendTool一个前端工具(+ 可选渲染器)addTool + addHookRenderToolCalluse-frontend-tool.tsx:13
useAgentContext一条页面状态上下文addContext(useLayoutEffect)use-agent-context.tsx:37
useHumanInTheLoop一个会暂停等用户的工具包装成 frontendTool 复用 useFrontendTooluse-human-in-the-loop.tsx:10

它们的共同形态:effect 里注册、cleanup 里注销useFrontendTool 在 mount 时 addTool,unmount 时 removeTool——但故意不移除渲染器,这样工具跑完后它的 UI 还能留在聊天历史里(use-frontend-tool.tsx:40):

// 真实源码 packages/react-core/src/v2/hooks/use-frontend-tool.tsx:38
return () => {
copilotkit.removeTool(name, tool.agentId);
// 故意不移除 render,让工具仍能在聊天历史里渲染
};

useAgentContextuseLayoutEffect(不是 useEffect)注册上下文,这和第 3 章 §8 的「让一拍」配套:工具改了状态后,useLayoutEffect 在 React commit 后同步把新上下文写进 store,续跑才能读到。

5. human-in-the-loop:用 Promise 把「等用户」变成 handler

useHumanInTheLoop 很巧妙(use-human-in-the-loop.tsx)。它要让 agent「调一个工具然后停住,等用户在 UI 上点确认」。实现:把工具的 handler 做成一个挂起的 Promise,把 resolve 存进 ref;渲染器收到 respond 回调,用户点击时调 respond(result) 去 resolve 那个 Promise:

// 示意,非源码——抓住核心思路
handler = () => new Promise((resolve, reject) => {
resolvePromiseRef.current = resolve; // 把 resolve 存起来,handler 就此挂起
signal?.addEventListener("abort", () => reject(...)); // 中止则 reject
});
// 渲染出的卡片上,用户点"确认":
respond = (result) => resolvePromiseRef.current?.(result); // resolve → handler 返回 → 工具完成

因为第 3 章的工具执行是 await handler(...),handler 一直 pending 就等于「agent 停在这里」。signal 来自顶层 AbortController,用户中止时 reject,core 会把它记成一条错误工具结果(use-human-in-the-loop.tsx:36)。

6. 出:useAgent 把变化转成重渲染

useAgent(use-agent.tsx:53)是聊天重渲染的引擎。它做两件事:

(a) 拿到 agent 实例。 useMemocopilotkit.getAgent(agentId);runtime 还在连接时返回一个缓存的临时代理 agent(避免每次渲染造新实例触发不必要的 connect),连上后换成真实例(use-agent.tsx:76)。

(b) 订阅 agent、转成 forceUpdate。 effect 里用 subscribeToAgentWithOptions(第 1 章 §5)订阅消息/状态/run 变化,回调是一个 forceUpdate(use-agent.tsx:202)。这里有个细节——forceUpdatemicrotask 批处理(use-agent.tsx:171):

// 真实源码 packages/react-core/src/v2/hooks/use-agent.tsx:172
const batchedForceUpdate = () => {
if (!batchScheduled) {
batchScheduled = true;
queueMicrotask(() => { batchScheduled = false; if (active) forceUpdate(); });
}
};

同一 tick 内的多个通知(如 OnStateChanged + OnRunStatusChanged 一起来)合并成一次重渲染,避免流式期间内容高度抖动导致滚动跳动(注释引了 issue #3499)。throttleMs 则在更粗的时间窗上进一步限流。

7. 出:useRenderToolCall 把工具调用画成组件

生成式 UI 的落地点是 useRenderToolCall(use-render-tool-call.tsx:115)。它返回一个函数:给一个 toolCall(+ 可选结果消息),返回对应的 React 元素。

它用 useSyncExternalStore 读 Core 的渲染器表(use-render-tool-call.tsx:122)——因为渲染器可能在任意时刻、任意订阅顺序下变化,useSyncExternalStore 保证拿到的永远是最新值。匹配优先级:先按 name 精确匹配(优先当前 agentId)→ 再找无 agentId 的 → 再找通配 "*" → 都没有就用内置默认渲染器(use-render-tool-call.tsx:149)。

每个工具调用用 React.memo 包的 ToolCallRenderer 渲染,自定义比较函数只在 id/name/参数串/结果/执行状态变化时重渲染(use-render-tool-call.tsx:34),避免流式期间疯狂重画。工具的执行中状态来自 Provider 级追踪的 executingToolCallIds——它在 Provider 层订阅 onToolExecutionStart/End(CopilotKitProvider.tsx:642),这样即便子组件还没挂载,重连一个有待处理工具调用的线程时执行态也已就绪。

8. 闭合全图:一次用户发言的完整 React 视角

用户在 CopilotChat 输入框回车
└─ onSubmitInput: agent.addMessage(user) → copilotkit.runAgent({agent}) CopilotChat.tsx:431
└─ (第3章) RunHandler 发 HTTP、收 SSE、累积进 agent.messages
└─ agent 通知订阅者 onMessagesChanged
└─ useAgent 的 batchedForceUpdate → CopilotChat 重渲染
└─ 逐字出现的文本 / useRenderToolCall 画出工具卡片
└─ 若 agent 调了前端工具:RunHandler 跑 handler
├─ onToolExecutionStart → Provider 把 id 记进 executingToolCallIds → 卡片显示"执行中"
├─ (HITL) handler 挂起,等用户点 respond
└─ 结果插成 tool 消息 → 续跑(让一拍给 React 后)→ 回到上面

这就是 agent-ui 的完整闭环:React 声明 → Core 编排 → 替身传输 → 工具循环 → 订阅广播 → React 重渲染。

9. 代码地图

主题文件符号
Provider / 稳定实例packages/react-core/src/v2/providers/CopilotKitProvider.tsxCopilotKitProvidercopilotkitRef
effect 顺序防覆盖packages/react-core/src/v2/providers/CopilotKitProvider.tsxdidMountRefsetTools/setRenderToolCalls effects
React 子类packages/react-core/src/v2/lib/react-core.tsCopilotKitCoreReactrenderToolCallswaitForPendingFrameworkUpdates
注册前端工具packages/react-core/src/v2/hooks/use-frontend-tool.tsxuseFrontendTool
注册上下文packages/react-core/src/v2/hooks/use-agent-context.tsxuseAgentContext
human-in-the-looppackages/react-core/src/v2/hooks/use-human-in-the-loop.tsxuseHumanInTheLooprespond
订阅 agent 重渲染packages/react-core/src/v2/hooks/use-agent.tsxuseAgentbatchedForceUpdate
渲染工具调用packages/react-core/src/v2/hooks/use-render-tool-call.tsxuseRenderToolCallToolCallRenderer
发言入口packages/react-core/src/v2/components/chat/CopilotChat.tsxonSubmitInput
执行态追踪packages/react-core/src/v2/providers/CopilotKitProvider.tsxexecutingToolCallIds