CopilotKit × LangGraph TypeScript:双向共享状态(Read + Write)的完整实现与 QA 验证指南
【免费下载链接】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 仓库中 LangGraph (TypeScript) 集成演示的 QA 文档 shared-state-read-write.md 展开,讲解“UI 与 Agent 双向共享状态”这一核心模式:UI 通过agent.setState()写入用户偏好、Agent 每轮从状态中读取并注入 system prompt;Agent 通过set_notes工具写回状态、UI 通过useAgent()实时读取渲染。读完后你将掌握该模式的前后端完整实现链路、全部可执行测试步骤、预期结果基线,以及从源码结构确认的关键设计细节(如Command({ update })双通道写回、空偏好跳过注入的容错逻辑)。
前置条件
QA 文档明确列出了运行该演示所需的环境前提:
- 演示已部署并可在 dashboard 主机上通过
/demos/shared-state-read-write访问; - Agent 后端健康(
/api/health可访问); - Railway 上已设置
OPENAI_API_KEY; LANGGRAPH_DEPLOYMENT_URL指向一个暴露了shared_state_read_writegraph 的 LangGraph 部署。
前端入口在 page.tsx 中可见,CopilotKit组件通过runtimeUrl="/api/copilotkit"与agent="shared-state-read-write"两个属性完成与运行时和后端的绑定:
export default function SharedStateReadWriteDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="shared-state-read-write"> <DemoContent /> </CopilotKit> ); }状态模型:两个切片,四个方向的数据流
整个演示的共享状态只有一个对象,包含两个语义完全不同的切片。前端在 page.tsx 中定义了它的形状:
// Shape of the bidirectional shared state. // - `preferences` is WRITTEN by the UI via agent.setState(). // - `notes` is WRITTEN by the agent via its `set_notes` tool and READ // by the UI via useAgent(). interface RWAgentState { preferences: Preferences; notes: string[]; }对应的Preferences结构在 preferences-card.tsx 中定义:
export interface Preferences { name: string; tone: "formal" | "casual" | "playful"; language: string; interests: string[]; }数据流可以概括为四条链路:
- UI → Agent(写):侧边栏表单每次编辑都经
agent.setState({ preferences, notes })写入 Agent 状态; - Agent ← 读:后端 chat node 每轮从
state.preferences读出偏好并注入 system prompt; - Agent → UI(写):Agent 调用
set_notes工具更新state.notes; - UI ← 读 + 回写:UI 用
useAgent({ updates: [OnStateChanged] })订阅状态变更实时渲染笔记卡片,Clear 按钮则以agent.setState({ notes: [] })反向写回同一个切片。
测试步骤 1:基础功能验证
QA 文档的第一组检查项聚焦页面骨架的完整性,全部可通过 DOM 断言(data-testid)完成:
- 访问
/demos/shared-state-read-write,页面应在 3 秒内渲染出左侧边栏(preferences + notes 两张卡片)与右侧CopilotChat面板; - 验证
data-testid="preferences-card"可见,标题为 "Your preferences"——与 preferences-card.tsx 中<Card>useConfigureSuggestions({ suggestions: [ { title: "Greet me", message: "Say hi and introduce yourself." }, { title: "Remember something", message: "Remember that I prefer morning meetings and that I don't eat dairy.", }, { title: "Plan a weekend", message: "Suggest a weekend plan based on my interests.", }, ], available: "always", });- 发送 "Hello",10 秒内应出现助手文本回复,确认前端到 LangGraph 后端的完整链路可用。
测试步骤 2A:UI 写入 → Agent 读取(preferences)
这是“UI 写、Agent 读”方向的完整验证。逐步操作与预期:
- 在
data-testid="pref-name"输入 "Atai",data-testid="pref-state-json"应同步更新为包含"name": "Atai"; - 将
data-testid="pref-tone"改为formal,JSON 预览应反映"tone": "formal"; - 将
data-testid="pref-language"改为Spanish,JSON 预览应反映"language": "Spanish"; - 点击
Cooking和Travel兴趣徽章,两者应呈现选中样式(边框#BEC2FF、背景#BEC2FF1A),JSON 预览的interests数组应同时包含两项; - 发送 "What do you know about me?",10 秒内助手回复应引用 "Atai"、formal 语气、Spanish 语言以及 Cooking/Travel 兴趣——因为 chat node 每轮都会把偏好注入 system prompt;
- 点击 "Plan a weekend" 建议,回复应贴合已选兴趣。
源码层面,这组断言的“可解释性”来自两个组件的解耦设计。
PreferencesCard是一个纯受控表单,本身完全不感知 Agent——每个字段的变更都通过set局部函数走onChange冒泡:export function PreferencesCard({ value, onChange }: PreferencesCardProps) { const set = <K extends keyof Preferences>(key: K, v: Preferences[K]) => onChange({ ...value, [key]: v }); // ... name / tone / language / interests 各控件 }真正的状态写入发生在上一层 page.tsx:
// WRITE: every edit in the sidebar goes straight into agent state. const handlePreferencesChange = (next: Preferences) => { agent.setState({ preferences: next, notes, // preserve what the agent has written } as RWAgentState); };注意这里刻意携带了
notes字段——UI 写偏好时必须保留Agent 已写入的笔记,否则会覆盖掉set_notes的产出。卡片底部那个pref-state-json的<pre>直接JSON.stringify(value, null, 2),因此 QA 中“每次编辑后 JSON 预览同步更新”的断言是确定性的同步渲染结果。兴趣徽章的选中态与 QA 提到的
#BEC2FF/#BEC2FF1A颜色,来自 preferences-card.tsx 中按selected ? "selected" : "outline"切换的Badge变体。测试步骤 2B:Agent 写入 → UI 读取(notes)
这是反向链路的验证:
- 点击 "Remember something" 建议(实际发送 "Remember that I prefer morning meetings and that I don't eat dairy.");
- 15 秒内
data-testid="notes-list"应出现在 notes 卡片中,且至少包含 2 条data-testid="note-item",分别提及 "morning meetings" 和 "dairy"; data-testid="notes-empty"空态应不再渲染;- 发送 "Also remember I live in Berlin.",15 秒内笔记列表应增长(旧笔记保留、新笔记追加)——这一步验证了 Agent 每次调用
set_notes都传入完整更新后的列表,而非增量。
这条“传全量、不传 diff”的契约由后端工具的描述文字直接固化。在 shared-state-read-write.ts 中:
const setNotes = tool( async ({ notes }, config: ToolRunnableConfig) => { // ... 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. " + "Use this tool whenever the user asks you to 'remember' something, or " + "when you have an observation about the user worth surfacing in the " + "UI's notes panel. Always pass the FULL notes list (existing notes + " + "any new ones), not a diff. Keep each note short (< 120 chars).", schema: z.object({ notes: z .array(z.string()) .describe("The full updated notes list (replaces previous value)."), }), }, );从源码结构看,这里有两个关键设计点:
Command({ update })一石二鸟:同一次工具返回里既更新了notes通道(UI 侧通过共享状态立即重渲染),又追加了一条携带tool_call_id的ToolMessage(让 LLM 在下一轮看到格式合法的 tool result)。工具内还显式校验config.toolCall?.id非空,否则拒绝生成空tool_call_id的ToolMessage(OpenAI 会拒绝这类消息),说明该实现是面向真实 LLM 提供商约束做了防御性处理;- 渲染侧保持纯读:notes-card.tsx 只接收
notesprop 并渲染编号列表,自身不触碰 Agent 状态。UI 之所以能“实时”看到 Agent 的写入,是因为父组件订阅了状态变更:
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 ?? [];UseAgentUpdate.OnStateChanged使 Agent 侧的任意状态变更(包括set_notes触发的notes通道更新)都会触发组件重渲染,这正是 QA 断言“15 秒内笔记出现”的底层机制。测试步骤 2C:UI 回写 Agent 拥有的切片(清空笔记)
同一个
notes字段既被 Agent 写、也被 UI 写,QA 文档用三步验证这个双向回环:- 有笔记存在时,
data-testid="notes-clear-button"应可见(源码中该按钮仅在notes.length > 0时渲染,与断言一致); - 点击 Clear,笔记列表消失,
data-testid="notes-empty"重新渲染; - 再问 "What do you remember about me?",Agent 不应再引用被清空的笔记——因为 UI 已通过
agent.setState({ notes: [] })把状态写回。
对应源码:
// WRITE: let the user clear the agent-authored notes from the UI. const handleClearNotes = () => { agent.setState({ preferences, notes: [] } as RWAgentState); };这一步同时演示了双向共享状态的一个语义要点:UI 的清空是直接改状态,而不是发消息让 Agent 去删。下一轮对话时 Agent 从状态里读到的就是空数组,自然“忘记”了那些笔记。
测试步骤 2D:多轮状态持久性
- 将 tone 改为
playful并添加Music兴趣,发送 "Write me a one-line haiku greeting.",回复应是俏皮风格并提及音乐; - 追加发送 "Do it again in French.",回复应保持 playful、切换为法语、且继续体现音乐兴趣——确认偏好在多轮对话中无需重发即持续生效(因为偏好存在 Agent 状态里,而非聊天记录里);
- 刷新页面后,偏好应重置为默认值(
tone: casual、language: English、空 interests、空 name),笔记也重置为空。
刷新即重置的行为由 page.tsx 中的初始化逻辑解释:
const INITIAL_PREFERENCES: Preferences = { name: "", tone: "casual", language: "English", interests: [], }; // Seed initial preferences + empty notes into agent state once, so the // agent has something to read on the very first turn. useEffect(() => { if (!agentState?.preferences) { agent.setState({ preferences: INITIAL_PREFERENCES, notes: [], } as RWAgentState); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []);即状态是按会话(per-session)由页面
useEffect播种的,QA 清单中“reload 后重置”的预期结果由此得出。后端图结构:chat node、tool node 与偏好注入
前端之外,Agent 侧的完整实现在 shared-state-read-write.ts。状态声明把 CopilotKit 自带的通道与业务通道合并到同一个
Annotation:export interface Preferences { name?: string; tone?: "formal" | "casual" | "playful"; language?: string; interests?: string[]; } const AgentStateAnnotation = Annotation.Root({ ...CopilotKitStateAnnotation.spec, // messages + copilotkit(actions 等) preferences: Annotation<Preferences | undefined>, notes: Annotation<string[]>, });“UI 写入如何影响模型”的关键在
buildPreferencesMessage+chatNode:每轮调用前,从state.preferences构造一条SystemMessage拼在消息序列最前面:function buildPreferencesMessage(prefs: Preferences | undefined) { 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({ /* "The user has shared these preferences..." */ }); } async function chatNode(state: AgentState, config: RunnableConfig) { const model = makeChatOpenAI(config, { temperature: 0, model: "gpt-4o-mini", modelKwargs: { parallel_tool_calls: false }, }); const modelWithTools = model.bindTools!([ ...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []), ...tools, ]); const prefsMessage = buildPreferencesMessage(state.preferences); const systemMessages = prefsMessage ? [baseSystem, prefsMessage] : [baseSystem]; const response = await modelWithTools.invoke( [...systemMessages, ...state.messages], config, ); return { messages: response }; }这段实现直接解释了 QA 中的两条预期:QA 断言“chat node 每轮把偏好注入 system prompt”(因此 "What do you know about me?" 能答出全部偏好);同时也解释了 2E 的容错项——当
preferences为空对象(所有字段 falsy)时buildPreferencesMessage返回null,chat node 跳过注入而不报错,对应 QA 中“清空偏好后问 'Who am I?',Agent 应正常作答不崩溃”。图编译部分则是标准的 LangGraph 结构:
chat_node与ToolNode成环,MemorySaver提供 checkpoint: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); export const graph = workflow.compile({ checkpointer: new MemorySaver(), });路由函数
shouldContinue有一个值得注意的 CopilotKit 专属细节:模型可能同时产生后端工具调用(如set_notes)和CopilotKit 前端 action 调用。只有当存在“不属于state.copilotkit.actions的任何工具调用”时才进入tool_node,否则直接__end__,让前端 action 走 CopilotKit 运行时通道:function shouldContinue({ messages, copilotkit }: AgentState) { const lastMessage = messages[messages.length - 1] as AIMessage; if (lastMessage.tool_calls?.length) { const actions = copilotkit?.actions; const hasBackendToolCall = lastMessage.tool_calls.some((toolCall) => !actions || actions.every((action) => action.name !== toolCall.name) ); if (hasBackendToolCall) return "tool_node"; } return "__end__"; }测试步骤 3:错误处理与边界情况
QA 文档列出的三条边界检查及源码依据:
- 空消息发送应为 no-op:不产生用户气泡、不产生助手回复;
- 清空全部偏好与姓名后问 "Who am I?":Agent 正常作答不崩溃——源码依据即上文
buildPreferencesMessage的lines.length === 0 → return null分支; - 全程 DevTools Console 无未捕获错误:这是跨所有流程的全局不变量。
预期结果基线
汇总 QA 文档给出的验收基线,可用于回归时快速对照:
- 页面 3 秒内完成加载;助手文本回复在 10 秒内出现;
- 偏好写入在每次变更时同步反映到
pref-state-json预览; - Agent 写入的笔记在 "remember" 类提示后 15 秒内出现在
notes-card,且后续每次set_notes调用都保留此前的完整列表; - Clear 按钮完成 UI → Agent 状态的回环,下一轮对话中 Agent 不再拥有被清空的笔记;
- 全程无 UI 布局破坏、无未捕获的控制台错误。
小结与延伸阅读
这个演示是 LangGraph TypeScript + CopilotKit 双向共享状态的最小完整范式:一个共享状态对象、两个方向相反的读写切片、四个数据流,且每一侧的代码都保持单一职责(卡片组件不碰 Agent,状态接线集中在 page 层)。相关可追溯的仓库文件:
- QA 清单(本文主体):shared-state-read-write.md
- 演示说明:README.md
- 前端页面与状态接线:page.tsx、preferences-card.tsx、notes-card.tsx、suggestions.ts
- 后端 Agent 图:shared-state-read-write.ts
需要说明的适用前提:以上路径均位于
showcase/integrations/langgraph-typescript集成演示包内,运行依赖已部署的 CopilotKit runtime(/api/copilotkit)与暴露shared_state_read_write图的 LangGraph 部署;QA 清单中个别文案(notes 空态占位文本)与当前仓库代码存在版本差异,执行自动化断言前建议以检出时的源码为准校准。【免费下载链接】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),仅供参考