news 2026/9/14 2:03:19

CopilotKit 无头中断实战:基于 MS Agent Framework 的聊天外阻塞式排期交互

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CopilotKit 无头中断实战:基于 MS Agent Framework 的聊天外阻塞式排期交互

CopilotKit 无头中断实战:基于 MS Agent Framework 的聊天外阻塞式排期交互

【免费下载链接】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集成下的interrupt-headless演示为核心,讲解一个典型的“无头中断”(headless interrupt)交互:聊天框只负责触发 Agent,当 Agent 需要用户选择时间时,选择器弹窗渲染在**聊天区域之外的应用表面(app surface)**中,用户点击某个时段后才解析挂起的工具调用、Agent 再回到聊天中确认。读完本文,你可以掌握在没有原生interrupt()原语的 Microsoft Agent Framework(.NET)后端上,如何用useFrontendTool的异步 Promise handler 等价实现 LangGraph 版的中断/恢复流程,并看懂前后端完整的路由与挂载关系。

这个演示展示什么

演示页位于 page.tsx,页面布局是“左侧应用表面 + 右侧聊天栏”的双栏结构:

  • 聊天侧只负责触发 Agent(通过useConfigureSuggestions提供两条示例问法,如 “Book a call with sales”);
  • 当 Agent 调用schedule_meeting工具时,时间选择器弹窗不出现在聊天消息流里,而是出现在左侧应用表面;
  • 用户选择一个时段(或点 Cancel),挂起的工具调用被解析,弹窗消失,Agent 回到聊天中用一句话确认“已排期”或“已取消”。

源码中的注释把交互流程写得很直白(见 page.tsx):

// Layout: chat on the right, empty app surface on the left. The user triggers // the agent from a chat suggestion. When the agent calls `schedule_meeting`, // we render a time-picker popup IN THE APP SURFACE (left pane) — outside of // the chat. Picking a slot resolves the tool call, the popup vanishes, and // the agent confirms back in chat.

这种“聊天外 UI 承载中断”的模式与在聊天内联渲染选择器的做法相对,后者由兄弟演示gen-ui-interrupt承担,两者共用同一个 .NET 后端,差异完全在前端。

与 LangGraph 版的机制差异:为什么需要“适配”

原演示的 LangGraph 版本依赖一个自研的useHeadlessInterrupthook:它监听 AG-UI 流上 LangGraph 原生的interrupt()事件,并通过copilotkit.runAgent({ forwardedProps: { command: { resume } } })把用户选择回传给后端、恢复挂起的执行。

而 Microsoft Agent Framework(.NET)没有对应的 interrupt 原语——无法在工具执行中途暂停并携带调用方提供的值恢复。因此这个 .NET 移植版采用的是一种等价机制(shim):

维度LangGraph 版MS Agent Framework 适配版
暂停点后端工具内的原生interrupt()前端工具 handler 内的awaitPromise
恢复方式runAgent携带command: { resume }Promise 在用户点击弹窗时被 resolve
中断 UI 位置聊天外 app surface相同(聊天外 app surface)
用户可见体验等价等价

关键思想是:把“中断”从后端状态机问题,转化为前端的一个不 resolve 的 Promise。后端 Agent 只是被提示“凡是排期请求必须调用schedule_meeting工具”,工具定义由前端通过useFrontendTool注册;AG-UI 协议会把前端工具定义转发给模型,工具调用则回落到客户端 handler 执行,handler 阻塞多久,这次工具调用就“挂起”多久。

前端实现:schedule_meeting与 Promise 门控

工具注册与异步 handler

页面通过@copilotkit/react-core/v2注册前端工具,参数用 zod 描述,handler 返回Promise<string>。核心代码如下(摘自 page.tsx 的@region[headless-promise-primitives]区块):

useFrontendTool({ name: "schedule_meeting", description: "Ask the user to pick a time slot for a meeting via a picker popup " + "that appears outside the chat. Blocks until the user chooses a " + "slot or cancels.", parameters: z.object({ topic: z .string() .describe("Short human-readable description of the meeting."), attendee: z .string() .optional() .describe("Who the meeting is with (optional)."), }), // Async handler: sets the pending payload so the popup renders, then // returns a Promise that only resolves once the user interacts with the // popup. This is the MS Agent shim for the LangGraph headless interrupt // `resume` flow. handler: async ({ topic, attendee }: { topic: string; attendee?: string }): Promise<string> => { setPending({ topic, attendee }); const result = await new Promise<PickerResult>((resolve) => { resolverRef.current = resolve; }); setPending(null); if ("cancelled" in result && result.cancelled) { return "User cancelled. Meeting NOT scheduled."; } if ("chosen_label" in result) { return `Meeting scheduled for ${result.chosen_label}.`; } return "User did not pick a time. Meeting NOT scheduled."; }, // Render nothing inside the chat — the UI lives in the app surface. render: () => null, });

这段代码有三个值得注意的设计点:

  1. setPending驱动弹窗渲染:handler 一进入就调用setPending({ topic, attendee }),把“待决工具调用的载荷”提升为组件 state,左侧 app surface 据此渲染TimeSlotPopup。也就是说,弹窗的显隐完全由“是否有一个在途的schedule_meeting调用”决定。
  2. resolverRef保存 resolve 函数:handler 内部await一个new Promise<PickerResult>,并把 resolve 存进useRef。外部任何组件想“结束这次中断”,只需调用resolve(result)。这是一个典型的“Promise 作为跨组件异步握手”的写法。
  3. render: () => nulluseFrontendTool允许工具在聊天内联渲染自己的结果 UI;这里显式返回null,保证聊天消息流里不出现任何工具卡片——这正是 “headless” 的含义:工具调用有执行副作用(阻塞 + 返回字符串),但在聊天中无视觉存在。

配套的 resolve 封装(page.tsx):

const resolve = (result: PickerResult) => { const fn = resolverRef.current; resolverRef.current = null; fn?.(result); };

先把 ref 清空再调用,可避免同一 handler 被重复 resolve。

类型定义与时段数据

交互涉及三种类型(page.tsx):

type InterruptPayload = { topic?: string; attendee?: string; }; type TimeSlot = { label: string; iso: string }; type PickerResult = | { chosen_time: string; chosen_label: string } | { cancelled: true }; const DEFAULT_SLOTS: TimeSlot[] = [ { label: "Tomorrow 10:00 AM", iso: "2026-04-25T10:00:00-07:00" }, { label: "Tomorrow 2:00 PM", iso: "2026-04-25T14:00:00-07:00" }, { label: "Monday 9:00 AM", iso: "2026-04-28T09:00:00-07:00" }, { label: "Monday 3:30 PM", iso: "2026-04-28T15:30:00-07:00" }, ];

PickerResult是一个判别联合:要么携带用户选中的 ISO 时间与展示文案,要么是{ cancelled: true }。handler 里用"cancelled" in result/"chosen_label" in result做窄化,最后统一返回纯文本结果字符串回给模型——模型拿到这句文本后组织确认话语,这一返回值刻意与 LangGraph 版后端工具的返回文案保持一致,从而让两个版本的对话行为几乎不可区分。

弹窗组件与布局

应用表面AppSurface根据pending是否非空在“空状态 / 弹窗”之间切换(page.tsx):

<div className="relative flex flex-1 items-center justify-center p-8"> {pending ? ( <TimeSlotPopup payload={pending} onPick={(slot) => resolve({ chosen_time: slot.iso, chosen_label: slot.label }) } onCancel={() => resolve({ cancelled: true })} /> ) : ( <EmptyState /> )} </div>

TimeSlotPopupDEFAULT_SLOTS渲染成两列按钮网格,附带 Cancel 按钮;整个弹窗带role="dialog"data-testid="interrupt-headless-popup",每个时段按钮都有data-testid={interrupt-headless-slot-${slot.iso}}——这些测试钩子表明该演示被仓库的 e2e/回归测试所覆盖(testid 命名与演示目录一一对应)。

页面最外层还固定了 CopilotKit 的运行时与 Agent 名(page.tsx):

export default function InterruptHeadlessDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="interrupt-headless"> <Layout /> </CopilotKit> ); }

runtimeUrl指向 Next.js 侧的/api/copilotkit路由,agent="interrupt-headless"决定请求被路由到哪个后端 Agent——这条链路的下一环见下文“Agent 路由”。

后端实现:一个“只有提示词”的排期 Agent

.NET 后端位于 InterruptAgent.cs。它的工厂方法构造了一个ChatClientAgent(InterruptAgent.cs):

public AIAgent CreateInterruptAgent() { var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(); // No backend fallback tool is registered. If the frontend tool is // missing, the demo should fail visibly instead of bypassing the // picker with a server-side response. var chatClientAgent = new ChatClientAgent( chatClient, name: "InterruptAgent", instructions: @"You are a scheduling assistant. Whenever the user asks you to book a call or schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a short `topic` describing the purpose and `attendee` describing who the meeting is with. After the tool returns, confirm briefly whether the meeting was scheduled and at what time, or that the user cancelled.", tools: []); return new SharedStateAgent(chatClientAgent, _jsonSerializerOptions, _loggerFactory.CreateLogger<SharedStateAgent>()); }

从源码结构可以看到三个刻意的设计取舍:

  • tools: []——后端不注册任何工具,也没有兜底的schedule_meeting服务端实现。注释明确说明这是有意为之:如果前端工具缺失,演示应当“显式失败”,而不是被服务端悄悄绕过选择器、直接给出一个答案。这把“排期必须由用户在前端弹窗里决策”变成了强约束。
  • 系统提示词承担“中断语义”MUST call the schedule_meeting tool的措辞让模型在收到排期类请求时稳定地发起前端工具调用;工具说明(前端 zod schema + description)会经 AG-UI 转发给模型,模型据此生成topic/attendee参数。
  • 复用SharedStateAgent包装:与 showcase 中其他 Agent 保持一致的封装模式,虽然排期中断演示本身并不依赖状态同步。

挂载与路由:/interrupt-adapted如何被两个演示共用

Agent 通过 AG-UI 挂载点发布。在 .NET 宿主程序的 Program.cs 中:

// Interrupt-adapted agent: mounted on its own path so the Next.js runtime // can proxy the `gen-ui-interrupt` and `interrupt-headless` demo names to // it. The two demos share this single backend — the differentiation happens // on the frontend (in-chat picker vs. headless/app-surface picker). var interruptAgentFactory = new InterruptAgentFactory(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions); app.MapAGUI("/interrupt-adapted", interruptAgentFactory.CreateInterruptAgent());

注意这里的路径名是/interrupt-adapted:它表达的是“这是中断演示的适配版后端”,而不是某个具体演示。两个前端演示(聊天内选择器 vs 聊天外弹窗)共用这一个端点。

Next.js 侧的运行时路由 route.ts 完成 Agent 名到后端路径的映射:

// Agent names routed to the interrupt-adapted scheduling backend. Both // gen-ui-interrupt and interrupt-headless share the same MS Agent Framework // scheduling agent; only the frontend UX differs (inline in chat vs. external // popup driven from a button grid). const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"]; // Interrupt-adapted demos — frontend-tool shim for LangGraph `interrupt()`. // Both gen-ui-interrupt and interrupt-headless share the same scheduling agent; // only the frontend UX differs (inline time-picker vs. external popup). for (const name of interruptAgentNames) { agents[name] = createReplaySafeAgent("/interrupt-adapted", [ "schedule_meeting", ]); }

这里有两个细节:

  • createReplaySafeAgent("/interrupt-adapted", ["schedule_meeting"])表明该 Agent 是**回放安全(replay-safe)**的封装,且第二个参数声明了需要透传的前端工具名schedule_meeting——运行时借此知道该工具由客户端执行,调用不应落到后端。
  • 从源码结构看,同一 route 文件里hitl-in-apphitl-in-chat等 HITL 演示也采用“后端tools=[]+ 前端工具注入”的同款模式,说明“前端工具作为人机协作挂起机制”是该 showcase 中一套被反复复用的适配套路,而 interrupt-headless 是其中“UI 完全离开聊天”的特化形态。

一个需要留意的仓库事实:演示 README(README.md)“Related”一节沿用了 LangGraph 原版的表述,指向 Python 侧的src/agents/interrupt_agent.pysrc/agent_server.py;而在这个 .NET 集成中,实际对应的后端文件是 agent/InterruptAgent.cs 与 agent/Program.cs 中的MapAGUI("/interrupt-adapted", ...),Python 参考实现存在于仓库其他 Python 集成(如showcase/integrations/langgraph-python/src/agents/interrupt_agent.py)中。

与兄弟演示 gen-ui-interrupt 的对照

同一后端的另一个消费方是 gen-ui-interrupt 演示,其 README 对适配方式的描述与本文一致:LangGraph 参考实现用interrupt()在中途暂停后端工具并经useInterrupt暴露载荷;.NET 版则用useFrontendTool的 async handler,在聊天内渲染TimePickerCard、await 用户选择,并返回一句与 LangGraph 后端工具返回值一致的纯文本。

由此可以得到一个清晰的对照表:

对比项gen-ui-interruptinterrupt-headless(本文主题)
后端端点/interrupt-adapted(共用)/interrupt-adapted(共用)
前端工具schedule_meeting(async handler)schedule_meeting(async handler)
选择器位置聊天内联(TimePickerCard聊天外 app surface 弹窗
工具render渲染聊天内卡片() => null(聊天内零渲染)
阻塞机制handler 内 await Promisehandler 内 await Promise

两个演示证明了同一件事:在 MS Agent Framework 适配下,“中断体验”与“工具渲染位置”是正交的——挂起语义由 Promise handler 提供,UI 落在哪里则由render与组件布局决定。

小结与延伸阅读

interrupt-headless演示的价值在于给出了一套在无中断原语的 Agent 框架上实现“人在环中阻塞式交互”的可复用配方:后端只负责稳定的工具调用意图(提示词 + 空工具表),前端用useFrontendTool的 async handler 把用户决策变成一个不提前 resolve 的 Promise,render: () => null则保证聊天流不被侵入。这套模式对任何“Agent 需要用户在聊天外做出选择/确认”的场景(审批、排期、表单补全)都适用。

建议按以下路径继续深入:

  • 演示说明:interrupt-headless README
  • 前端完整实现:page.tsx
  • 后端 Agent:InterruptAgent.cs、Program.cs 挂载点
  • 路由映射:route.ts
  • 对照演示:gen-ui-interrupt README
  • 前端工具的通用用法可参考仓库核心包 packages/react-core 的useFrontendTool实现与测试

【免费下载链接】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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 2:02:59

Django图书管理系统源码拆解:ORM查询、事务与部署实践

简介&#xff1a;Python结合Django构建的图书管理系统源码包&#xff0c;面向刚接触Web框架的Python学习者、计算机专业课程设计与毕业设计人群&#xff0c;聚焦图书信息录入、分类检索、借还管理等典型后台业务场景。资源共51个文件&#xff0c;核心代码以py源文件为主&#x…

作者头像 李华
网站建设 2026/9/14 2:01:49

Python爬虫实战:京东商品数据抓取技术解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 1:58:25

英雄联盟S赛晋级机制与战队历史突破解析

我无法基于该标题生成符合要求的博文内容。 原因如下&#xff1a; 标题“创历史&#xff01;KC击败GX队史首次挺进S赛&#xff0c;为全球第十支进军的队伍”属于 电子竞技&#xff08;Esports&#xff09;领域 &#xff0c;特指《英雄联盟》&#xff08;League of Legends&…

作者头像 李华
网站建设 2026/9/14 1:56:42

Android Fragment生命周期详解与最佳实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华