CopilotKit × Microsoft Agent Framework (.NET):实现 UI 与 Agent 双向共享状态的 Shared State Read/Write 演示
2026/9/14 12:30:02 网站建设 项目流程

CopilotKit × Microsoft Agent Framework (.NET):实现 UI 与 Agent 双向共享状态的 Shared State Read/Write 演示

【免费下载链接】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/ms-agent-dotnet集成中的shared-state-read-write演示为对象,完整拆解"UI 与 Agent 之间双向读写同一份共享状态"的实战实现:前端如何用一个agent.setState(...)把偏好表单写入 Agent 状态、用useAgent(...)订阅 Agent 回写的便签;.NET 后端如何从 AG-UI 共享状态读出偏好并注入系统提示词、再通过set_notes工具和状态快照事件把数据写回 UI。读完本文,你能掌握在 CopilotKit + Microsoft Agent Framework(.NET)技术栈下搭建双向共享状态功能的前后端完整链路,并能对照源码理解状态如何在一次对话回合内完成"读—注入—执行—写回"的闭环。

演示目标:同一份状态,两侧都能读也能写

演示的 README(README.md)将其定位为Bidirectional shared state——UI 与 Agent 双方都读写同一个状态对象,具体拆成三条行为线:

  • UI → agent(写):侧边栏偏好表单(name / tone / language / interests)通过agent.setState(...)写入state.preferences;后端每一轮对话都会读取它,并注入系统提示词。
  • agent → UI(读):Agent 的set_notes工具写入state.notes;侧边栏的"Agent Scratch pad"卡片在 Agent 每次更新时自动重新渲染。
  • Round-trip(闭环验证):在侧边栏修改偏好后,Agent 的下一轮回复会明显被"带偏"——语气(tone)、回复语言(language)、称呼(name)都会随之变化。

状态对象的前端类型定义在 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[]; }

对应的初始值INITIAL_PREFERENCES{ name: "", tone: "casual", language: "English", interests: [] }。后端 C# 侧存在同构的SharedStatePreferencesrecord(见 SharedStateReadWriteAgent.cs),其Empty缺省值与前端完全一致:空 name、casualEnglish、空 interests。值得注意的是,README 中提到的 Python 参考实现路径(src/agents/shared_state_read_write.py)来自本演示的原始 langgraph-python 版本;在当前 ms-agent-dotnet 集成中,实际后端是 C# 文件 agent/SharedStateReadWriteAgent.cs,文件头注释明确说明了它与 Python/Google ADK 参考实现的行为对齐(shape parity)。

实操步骤:先改偏好,再发三条建议消息

README 给出的交互路径是先编辑侧边栏偏好,然后依次尝试三条消息(对应 suggestions.ts 中通过useConfigureSuggestions注册的三个建议按钮):

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", });
  • "Say hi and introduce yourself.":验证 UI → agent 方向——如果你填了 name 并选了playful,Agent 会按名字称呼你并用俏皮语气打招呼。
  • "Remember that I prefer morning meetings and that I don't eat dairy.":验证 agent → UI 方向——Agent 调用set_notes后,侧边栏 Scratch pad 里出现 "Prefers morning meetings" 和 "Does not eat dairy" 两条便签。
  • "Suggest a weekend plan based on my interests.":验证 Round-trip——Agent 依据你勾选的 interests(Cooking / Travel / Tech / Music / Sports / Books / Movies 七个选项,见INTEREST_OPTIONS)给出周末计划。

页面上还有一处便于调试的细节:偏好卡片底部(preferences-card.tsx)会以<pre>实时打印当前state.preferences的 JSON,data-testid="pref-state-json",让"UI 写进共享状态的到底是什么"一眼可见。

前端实现:一个 Provider、一个订阅、一个写入口

1. CopilotKit Provider 绑定 Agent

页面组件的入口只有 6 行,Provider 通过runtimeUrl指向 Next.js 的 CopilotKit API 路由,用agent指定要绑定的 Agent id:

export default function SharedStateReadWriteDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="shared-state-read-write"> <DemoContent /> </CopilotKit> ); }

侧边栏聊天组件在 demo-layout.tsx 中同样以agentId="shared-state-read-write"关联同一个 Agent,保证聊天、建议按钮与侧边栏卡片操作的是同一份会话状态。

2. agent → UI:用 useAgent 订阅状态变更

这是 README "Technical Details" 第一条。page.tsx 中:

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 的每一次状态变更:只要后端在某轮结束时推送了包含notes新值的状态快照,该 hook 就会触发重渲染,notes-card.tsx里的便签列表随之更新。这是"agent → UI"方向能够实时刷新的唯一机制——NotesCard 组件本身完全不接触 agent,只是被父级传入notes后纯渲染(源码注释原话:"we never touch agent state ourselves — we just render it")。

3. UI → agent:用 agent.setState 写入

README 第二条对应的写入口在 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); }; // WRITE: let the user clear the agent-authored notes from the UI. const handleClearNotes = () => { agent.setState({ preferences, notes: [] } as RWAgentState); };

两个要点值得注意:

  1. 写入是"整对象"而非字段级 patch:每次改偏好都要带上当前的notes,否则会覆盖掉 Agent 之前写入的便签;"Clear" 按钮则是同一通道反向清空notes。这演示了同一个字段(notes)上双向写入的完整能力——Agent 写、UI 也能写。
  2. 表单组件与 Agent 解耦PreferencesCard是一个受控表单,onChange把新值冒泡到父级后由父级路由进agent.setState;卡片组件自身"不知道" agent 的存在,状态装配逻辑全部上提一层。

页面挂载时还有一次性的 seed:

useEffect(() => { if (!agentState?.preferences) { agent.setState({ preferences: INITIAL_PREFERENCES, notes: [], } as RWAgentState); } }, []);

目的是让 Agent 在第一轮对话时就有可读的偏好数据,而不是等用户手动编辑过表单之后才生效。

后端实现(.NET):从 AG-UI 共享状态读偏好、经工具写回

README 的第三条 Technical Detail 在 Python 参考实现中表现为PreferencesInjectorMiddleware.wrap_model_call读取request.state["preferences"]并前置一条SystemMessage,以及set_notes工具返回Command(update={"notes": ...})。在当前的 .NET 实现里,这些职责由SharedStateReadWriteAgent(一个DelegatingAIAgent装饰器)在单次流式执行中完成,整体流程与 Python 版行为一致。

路由与装配

Agent 在 Program.cs 中挂载到 AG-UI 端点,与前端agent="shared-state-read-write"对应:

var sharedStateReadWriteFactory = new SharedStateReadWriteAgentFactory(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions); app.MapAGUI("/shared-state-read-write", sharedStateReadWriteFactory.CreateAgent());

工厂(SharedStateReadWriteAgent.cs)用 OpenAI 客户端的gpt-4o-mini构建内层ChatClientAgent,并注册了唯一的工具set_notes

var setNotes = AIFunctionFactory.Create( (Func<List<string>, string>)(notes => { ArgumentNullException.ThrowIfNull(notes); _store.SetNotesForActiveThread(notes); return $"ok: {notes.Count} notes"; }), options: new() { Name = "set_notes", Description = "Replace the notes list with the FULL updated list (existing notes + new). Pass plain short note strings.", SerializerOptions = _jsonSerializerOptions, });

工具契约与 Python 参考版一致:总是用"完整的新列表"替换 notes 数组,而不是提交 diff(源码注释明确标注 this matches the documentedset_notescontract)。

每轮回合如何读取 UI 写入的 preferences

RunStreamingAsync(SharedStateReadWriteAgent.cs)中,偏好数据从 AG-UI 桥接层放进的ChatClientAgentRunOptions.AdditionalProperties["ag_ui_state"]里读出——这就是前端agent.setState({ preferences, notes })在后端的落点:

internal static bool TryGetAgUiState(AgentRunOptions? options, out JsonElement state) { if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } props } && props.TryGetValue("ag_ui_state", out JsonElement element) && element.ValueKind == JsonValueKind.Object) { state = element; return true; } state = default; return false; }

读出后调用MergeFromInbound合入按线程隔离的存储(合并不含偏好的字段时容错回退到旧值),然后构建系统提示词并前置到消息列表:

var inboundPreferences = TryReadPreferences(options) ?? TryReadPreferences(messageList); var inboundNotes = TryReadNotes(options); _store.MergeFromInbound(thread, inboundPreferences, inboundNotes); var systemPrompt = BuildPreferencesSystemPrompt(_store.GetPreferences(thread));

BuildPreferencesSystemPrompt生成的系统消息既包含人类可读的行,也原样内嵌 preferences JSON,并附上约束指令("Tailor every response to these preferences. Address the user by name when appropriate.")。基础系统提示词还规定:当用户要求记住某事时,必须调用set_notes传入完整的新便签列表。这正是 round-trip 生效的机制——UI 写的偏好每轮都被注入,模型据此调整语气/语言/称呼。

agent → UI 的回写:工具写 Store,轮末发快照

set_notes工具并不直接发消息给客户端,而是把便签写入SharedStateReadWriteStore;一轮流式输出结束后,装饰器补发一个状态快照更新:

// Emit the post-turn state snapshot so the UI's useAgent hook sees // tool-driven mutations to `notes` as well as the canonical copy of `preferences`. await foreach (var snapshotUpdate in EmitSnapshotAsync(thread, cancellationToken)) { yield return snapshotUpdate; }

EmitSnapshotAsync序列化{ preferences, notes }快照为application/jsonDataContent(SharedStateReadWriteAgent.cs)。按照文件头注释,.NET 的 AG-UI 桥接层会把这种DataContent("application/json")更新解释为状态快照事件推给客户端——前端useAgent订阅到的OnStateChanged正是由此触发,从而完成"agent → UI"的最后一公里。

按线程隔离的 Store 与 AsyncLocal 细节

SharedStateReadWriteStore(SharedStateReadWriteAgent.cs)以AgentThread的引用身份为 key 维护每会话的{ preferences, notes }槽位,没有线程时回退到实例级全局槽位。一个从源码注释中可以读出的工程细节:set_notes工具闭包收不到AgentThread参数,因此装饰器在执行内层 Agent 前调用_store.SetActiveThread(thread)把当前线程绑定进AsyncLocal,执行完在finally中恢复;否则工具写入会落进全局槽位,导致便签"从 UI 上消失"或在并发会话间泄漏。此外MergeFromInbound里偏好"永远以入站为准"(UI 是 preferences 的唯一事实源),而入站notes只在首次观察时采纳,避免运行时重放的旧快照覆盖工具刚写入的新值。

该演示还有确定性的演示回复分支(TryBuildDeterministicReply):三条建议按钮的消息在后端走固定文案,其中 "Remember something" 会确定性写入两条便签,保证演示在模型行为波动下依然可复现。后端单元测试位于 agent/tests/SharedStateAgentTests.cs,BuildPreferencesSystemPrompt特意声明为internal以便单测覆盖。

小结:双向共享状态的四步闭环

把前后端串起来,一次完整回合的数据流是:

  1. UI 写:表单变更 →agent.setState({ preferences, notes })→ 经/api/copilotkit到达 .NET 后端;
  2. 后端读RunStreamingAsyncag_ui_state取出preferencesBuildPreferencesSystemPrompt注入系统消息,模型据此调整回复;
  3. 后端写:模型调用set_notes(整列表替换)写入按线程隔离的 Store;
  4. UI 读:轮末DataContent(application/json)快照经 AG-UI 桥接成为状态快照事件,useAgent({ updates: [OnStateChanged] })触发侧边栏便签卡片重渲染。

这套模式的价值在于:状态本身是会话级、双向可写的单一事实源,UI 表单和聊天输入框操作同一对象,而"谁写的"由字段决定(preferences归 UI,notes归 Agent),两侧各自只需一种原语——setState写、useAgent读——就能实现跨进程的实时同步。所有代码均可在 showcase/integrations/ms-agent-dotnet/src/app/demos/shared-state-read-write 目录与 showcase/integrations/ms-agent-dotnet/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),仅供参考

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

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

立即咨询