CopilotKit × LangGraph TypeScript:用共享状态实现 UI 与 Agent 双向读写的实战解析
【免费下载链接】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/integrations/langgraph-typescript下的 Shared State (Read + Write) 示例为核心,完整拆解 UI 与 Agent 之间的双向共享状态模式:前端如何把用户偏好写进 Agent 状态并影响模型回复,Agent 又如何通过set_notes工具把“记忆”写回 UI 并实时触发重渲染。读完本文,你将掌握useAgent+agent.setState+ LangGraphCommand这套端到端双向状态同步的完整链路,并能独立复刻类似的“表单驱动 Agent 行为”应用。
示例定位:同一个状态对象,两边都能读和写
该示例(README)演示的是UI 与 Agent 之间的双向共享状态——两侧都读、都写同一个状态对象,具体包含三条主线:
- UI → Agent(写):页面侧栏表单(name / tone / language / interests)通过
agent.setState(...)把数据写入state.preferences;后端每一轮对话都会读取它并注入系统提示词(system prompt)。 - Agent → UI(写):Agent 的
set_notes工具把内容写入state.notes;侧栏的笔记卡片在 Agent 每次更新后自动重新渲染。 - 往返闭环(Round-trip):在侧栏修改偏好后,Agent 的下一条回复会肉眼可见地随之改变——语气(tone)、语言(language)、用名字称呼用户。
如何与示例交互
先编辑侧栏偏好,然后依次尝试以下提示词(它们也对应页面里由 suggestions.ts 注入的三枚建议按钮):
- "Say hi and introduce yourself."
- "Remember that I prefer morning meetings and that I don't eat dairy."
- "Suggest a weekend plan based on my interests."
观察要点:Agent 的回复会随偏好变化而改变;当你让它“记住”某些事时,侧栏笔记卡会实时出现新条目。
前端:状态形状、订阅与写入
状态形状:preferences由 UI 写,notes由 Agent 写
入口页面 page.tsx 定义了双向状态的整体形状:
// 双向共享状态的形状: // - `preferences` 由 UI 通过 agent.setState() 写入 // - `notes` 由 Agent 通过其 `set_notes` 工具写入、 // 由 UI 通过 useAgent() 读取 interface RWAgentState { preferences: Preferences; notes: string[]; }其中Preferences在 preferences-card.tsx 中定义,字段为:
export interface Preferences { name: string; tone: "formal" | "casual" | "playful"; language: string; interests: string[]; }页面通过<CopilotKit runtimeUrl="/api/copilotkit" agent="shared-state-read-write">把运行时地址和 Agent ID 绑定到整个页面。
读侧:useAgent订阅状态变更
const { agent } = useAgent({ agentId: "shared-state-read-write", updates: [UseAgentUpdate.OnStateChanged], }); const agentState = agent.state as RWAgentState | undefined; const preferences = agentState?.preferences ?? INITIAL_PREFERENCES; const notes = agentState?.notes ?? [];updates: [UseAgentUpdate.OnStateChanged]让组件订阅 Agent 的每一次状态变更:只要 Agent 侧(例如set_notes工具)修改了state.notes,该 hook 就会触发重渲染,侧栏笔记卡随之刷新——这就是 Agent → UI 方向的“读”的实现。
另外页面用useEffect在首次挂载时做了一次状态播种(seed):若agentState.preferences尚不存在,则调用agent.setState({ preferences: INITIAL_PREFERENCES, notes: [] }),保证 Agent 在第一轮就有可读取的偏好(tone: "casual"、language: "English"、空的 name 与 interests)。
写侧:所有编辑都流经agent.setState
侧栏表单的每一次变更都由 demo-layout.tsx 中的PreferencesCard(受控表单)上抛onChange,最终在页面层收敛为同一个调用:
const handlePreferencesChange = (next: Preferences) => { agent.setState({ preferences: next, notes, // 保留 Agent 已写入的笔记 } as RWAgentState); }; // UI 反向清空 Agent 写的笔记 const handleClearNotes = () => { agent.setState({ preferences, notes: [] } as RWAgentState); };两个关键细节值得注意:
setState是全量替换语义:写入preferences时必须把当前的notes一并带上,否则会覆盖 Agent 之前写入的笔记——这也是handlePreferencesChange里显式传notes的原因。- 同一字段双向可写:
notes既由 Agent 的set_notes工具写入,也能被 UI 的 “Clear” 按钮通过agent.setState({ notes: [] })清空。QA 清单(qa/shared-state-read-write.md)中专门验证了这一点:清空后再问 "What do you remember about me?",Agent 不应再引用被清掉的笔记,因为状态已被 UI 回写。
组件分层:表单组件不感知 Agent
从源码结构看,PreferencesCard和NotesCard(notes-card.tsx)都是“纯组件”:前者只接收value/onChange,后者只接收notes/onClear,自身从不触碰 Agent 状态;所有与 Agent 的接线都上移一层到页面组件中。这种分层让卡片可以被独立测试和复用。此外PreferencesCard底部用<pre>实时打印当前 preferences 的 JSON(data-testid="pref-state-json"),让“UI 到底写了什么进状态”变得可见、可断言——QA 脚本正是依赖这一预览做校验的。
后端:LangGraph 图中的注入、工具与路由
状态注解:CopilotKitStateAnnotation之上扩展业务槽位
Agent 实现位于 shared-state-read-write.ts。共享状态通过 LangGraph 的Annotation.Root声明,并在 CopilotKit 的基础槽位之上叠加两个业务字段:
const AgentStateAnnotation = Annotation.Root({ ...CopilotKitStateAnnotation.spec, // messages / copilotkit 等基础槽位 preferences: Annotation<Preferences | undefined>, notes: Annotation<string[]>, });CopilotKitStateAnnotation来自@copilotkit/sdk-js/langgraph,它带来了消息通道与copilotkit动作槽位;preferences与notes则是本示例自定义的双向共享通道。
UI 写入如何被模型“看见”:偏好注入
README 中提到后端有PreferencesInjectorMiddleware.wrap_model_call(该写法对应 Python 版 shared_state_read_write 的中间件实现)。在 TypeScript 版中,等价逻辑落在 chat node 内:每一轮都从状态里读出最新preferences,构造一条SystemMessage前置到消息列表:
function buildPreferencesMessage(prefs: Preferences | undefined): SystemMessage | null { if (!prefs) return null; const lines: string[] = []; if (prefs.name) lines.push(`- Name: ${prefs.name}`); if (prefs.tone) lines.push(`- Preferred tone: ${prefs.tone}`); if (prefs.language) lines.push(`- Preferred language: ${prefs.language}`); if (prefs.interests && prefs.interests.length > 0) { lines.push(`- Interests: ${prefs.interests.join(", ")}`); } if (lines.length === 0) return null; // 空偏好时跳过注入 return new SystemMessage({ content: [ "The user has shared these preferences with you:", ...lines, "Tailor every response to these preferences. Address the user by " + "name when appropriate.", ].join("\n"), }); }chatNode中,BASE_SYSTEM_PROMPT(要求模型尊重偏好、在用户要求“记住”时调用set_notes)与偏好消息一起前置:
const systemMessages = prefsMessage ? [baseSystem, prefsMessage] : [baseSystem]; const response = await modelWithTools.invoke([...systemMessages, ...state.messages], config);模型侧参数为temperature: 0、gpt-4o-mini,并关闭parallel_tool_calls(见makeChatOpenAI调用处)。由于注入发生在每一轮,UI 的写入无需重发即可持续影响后续回复——QA 清单中“把 tone 改为 playful 后连续两轮追问”的测试正是验证这种跨轮持久性。
Agent 写入:set_notes工具用Command双路回写
Agent → 共享状态的写路径由set_notes工具承担,核心是返回一个Command,同时完成两件事:向notes通道写入新值(UI 会因此重渲染),以及产出一条ToolMessage(让 LLM 在下一轮看到格式合法的工具结果):
const setNotes = tool( async ({ notes }, config: ToolRunnableConfig) => { const toolCallId = config.toolCall?.id; if (typeof toolCallId !== "string" || toolCallId.length === 0) { throw new Error("set_notes: missing tool_call_id — ..."); } return new Command({ update: { notes, messages: [ new ToolMessage({ status: "success", name: "set_notes", tool_call_id: toolCallId, content: "Notes updated.", }), ], }, }); }, { name: "set_notes", description: "Replace the notes array in shared state with the full updated list. ... " + "Always pass the FULL notes list (existing notes + any new ones), not a diff.", schema: z.object({ notes: z.array(z.string()).describe("The full updated notes list (replaces previous value)."), }), }, );这里有三个工程要点:
- 全量替换契约:工具描述明确要求传“完整的新列表(现有 + 新增)”而不是增量,且每条笔记建议小于 120 字符。QA 流程验证了这一点:先记住两条,再补一条 "Also remember I live in Berlin.",笔记列表应只增不丢。
tool_call_id校验:若工具脱离ToolNode上下文被调用(拿不到 tool call id),直接抛错拒绝发射空tool_call_id的ToolMessage,因为 OpenAI 会拒绝这类消息——这是对 LLM API 格式约束的显式防御。Command.update一次写两个通道:notes(共享状态)与messages(对话历史),保证 UI 视图与模型上下文一致。
路由与图编译:区分前端动作与后端工具
shouldContinue负责条件路由:若最后一条 AIMessage 携带的tool_calls中有不属于CopilotKit 前端动作(state.copilotkit.actions)的调用,则路由到tool_node,否则结束本轮:
const hasBackendToolCall = lastMessage.tool_calls.some((toolCall) => !actions || actions.every((action) => action.name !== toolCall.name) );即前端动作交由 CopilotKit 协议处理,后端工具(set_notes)留在图内执行。图结构为经典的 chat ↔ tool 循环:
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 graph = workflow.compile({ checkpointer: new MemorySaver() });MemorySavercheckpointer 提供会话内状态持久化,这正是notes跨轮保留的前提。
端到端闭环与注册链路
把两侧串起来,一条 “Remember that I prefer morning meetings..." 消息的完整链路是:
- UI 侧栏此前已通过
agent.setState({ preferences, notes })把偏好写入共享状态; - 消息进入
shared-state-read-write图,chatNode从state.preferences构建偏好SystemMessage前置后调用模型; - 模型决定调用
set_notes并给出完整笔记列表,路由到tool_node执行; Command.update同时写入notes通道与messages通道;- 状态变更经 CopilotKit 运行时推回前端,
useAgent({ updates: [OnStateChanged] })触发重渲染,笔记卡出现新条目。
该图在 langgraph.json 中注册为"shared_state_read_write": "./shared-state-read-write.ts:graph";前端 Agent 名到图名的映射("shared-state-read-write" -> "shared_state_read_write")则在 route.ts 中完成,两者命名不同(前端用短横线、后端用下划线)但必须一一对应,否则 Agent 无法命中图。
运行前提与验证方式
- 运行时依赖:LangGraph 部署需暴露
shared_state_read_write图,部署配置见 langgraph.json(Node 20,环境变量取自项目.env,需配置OPENAI_API_KEY); - 页面入口为
/demos/shared-state-read-write,依赖/api/copilotkit代理与健康的 Agent 后端(/api/health); - 完整的行为验证清单见 qa/shared-state-read-write.md,它按“UI 写 → Agent 读”“Agent 写 → UI 读”“UI 回写 Agent 槽位”“多轮持久化”“错误处理”五个维度覆盖了本示例的全部关键路径(含 testid 断言与响应时限),可作为复现该模式时的验收模板。
小结
本示例展示了双向共享状态的最小完备形态:Annotation声明共享槽位、agent.setState承担 UI 写入(注意全量替换语义)、Command(update)承担 Agent 侧“写状态 + 回消息”的双路更新、useAgent({ updates: [OnStateChanged] })承担读侧订阅、每轮注入系统消息让 UI 的写入持续影响模型。理解这条链路后,可以将其推广到任何需要“表单/画布驱动 Agent,且 Agent 能回写界面”的场景。
【免费下载链接】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),仅供参考