CopilotKit × Mastra:基于 useInterrupt 的聊天内 HITL 中断实战(含完整 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 仓库中 Mastra 集成示例的gen-ui-interruptdemo 展开,讲解如何在聊天转写区(inline)内实现「人在环」(Human-in-the-Loop)交互:后端 Mastra 工具通过suspend()挂起 agentic 循环,@ag-ui/mastra桥接层将其映射为 AG-UI interrupt 事件,前端用useInterrupt低层原语渲染时间选择卡片,用户选择后resolve(...)恢复 Mastra run 并携带resumeData重新执行工具。读完本文,你将掌握这条原生中断链路的后端/前端完整实现细节、关键实现陷阱,以及一套可直接执行的端到端 QA 验证清单。
1. 前置条件与链路总览
1.1 前置条件(摘自 QA 基线)
- Demo 已部署并可访问,位于 dashboard host 的
/demos/gen-ui-interrupt路径; - Agent 后端健康检查通过(
/api/health);OPENAI_API_KEY已在 Railway 环境变量中设置; - 关键机制说明(QA 文档原注):时间选择卡片通过
useInterrupt({ renderInChat: true })内联渲染在聊天转写区内。与 LangGraph 的interrupt()API 不同,Mastra 路径是一个原生 suspend 工具:后端schedule_meeting工具(src/mastra/tools/interrupt.ts)调用suspend({ topic, attendee, slots }),@ag-ui/mastra桥接层把该 suspend 映射为 AG-UI interrupt(legacyon_interruptCUSTOM 事件 + 标准的RUN_FINISHEDinterrupt-outcome),随后useInterrupt渲染TimePickerCard;用户点选某个 slot 后resolve(...)会恢复 Mastra run(工具的execute以resumeData被重新调用)。
1.2 中断生命周期
整条链路可以概括为六个阶段:
- 用户发送 "Book a call with sales"(建议项)或等价措辞;
- 模型依据 agent 指令调用
schedule_meeting工具(参数topic、可选attendee); - 工具首次执行时返回
suspend({ topic, attendee, slots }),agentic 循环在此暂停; @ag-ui/mastra桥接层把 suspend 暴露为 AG-UI interrupt(mastra_suspend包装 +on_interruptCUSTOM 事件,run 以 interrupt-outcome 结束RUN_FINISHED);- 前端
useInterrupt的render回调解析 payload 并在聊天流内渲染TimePickerCard; - 用户选择 slot 或取消 →
resolve(...)→ Mastra run 恢复 →execute第二次进入,executionContext.agent.resumeData携带用户选择 → 工具返回确认文案 → agent 生成最终回复。
这条链路与旧版「Strategy-B」workaround(前端自造schedule_meeting配合useHumanInTheLoop)不同,是真实的后端挂起(见 src/mastra/tools/interrupt.ts 顶部注释)。
2. 后端实现:原生 suspend 工具schedule_meeting
后端工具定义在 showcase/integrations/mastra/src/mastra/tools/interrupt.ts,核心结构如下:
export const scheduleMeetingInterruptTool = createTool({ id: "schedule_meeting", description: "Ask the user to pick a meeting time. Surfaces an interactive time-picker " + "to the user and returns their selection. Call this whenever the user asks " + "to book or schedule a meeting.", inputSchema: z.object({ topic: z.string().describe("What the meeting is about (e.g. 'Intro with sales')."), attendee: z.string().optional().describe("Who the meeting is with (e.g. 'Alice'), if known."), }), suspendSchema: z.object({ topic: z.string(), attendee: z.string().optional(), slots: z.array(z.object({ label: z.string(), iso: z.string() })), }), resumeSchema: z.object({ chosen_time: z.string().optional(), chosen_label: z.string().optional(), cancelled: z.boolean().optional(), }), execute: async (inputData, executionContext) => { const { suspend, resumeData } = executionContext?.agent ?? {}; // 第二次进入:用户已 resolve,带选择恢复 if (resumeData) { if (resumeData.cancelled) { return "The user cancelled — no meeting was scheduled."; } const when = resumeData.chosen_label ?? resumeData.chosen_time ?? "the chosen time"; return `Scheduled "${inputData.topic}" for ${when}.`; } // 第一次进入:挂起并携带 picker payload return suspend?.({ topic: inputData.topic, attendee: inputData.attendee, slots: generateCandidateSlots(), }); }, });几个源码级要点(都是该文件注释明确标注的「load-bearing」细节):
- 必须
return suspend(...)直接返回,不能写成await suspend(); return x。后者会让工具「完成」,在 fast streaming 下 agentic 循环会越过暂停点继续执行。 suspend/resumeData位于executionContext.agent子对象下(AgentToolExecutionContext),不在executionContext顶层。直接从顶层解构会得到undefined,导致suspend is not a function工具错误,模型反复重调直至撞上 step 上限,且永远没有tool-call-suspendedchunk。- 候选 slot 由后端生成:
generateCandidateSlots()基于当前时间生成固定四个相对标签 ——Tomorrow 10:00 AM、Tomorrow 2:00 PM、Monday 9:00 AM、Monday 3:30 PM,并附带 ISO 时间戳。「下周一」的计算保证至少距今 2 天以上,避免周日/周一时 "Monday" 与 "Tomorrow" 语义冲突。前端只在这些字段缺失时才回退到自己的生成器。
2.1 Agent 与运行时装配
承载该工具的 agent 定义在 showcase/integrations/mastra/src/mastra/agents/index.ts:
id: "interrupt-agent",模型gpt-4o-mini,仅注册一个工具schedule_meeting;- 系统指令要求:只要用户要求预约/安排会议就必须调用
schedule_meeting,并传入简短topic(已知时传attendee);工具挂起后由 picker 处理决策,agent 不得自行征求批准;工具返回后简短确认是否已排期及时间,或说明用户已取消。 - 记忆使用
LibSQLStore(working memory 启用,schema 为共享的AgentState)。
resume 能力依赖实例级 storage:Mastra 实例在 showcase/integrations/mastra/src/mastra/index.ts 中配置了storage: new LibSQLStore({ id: "mastra-storage", url: ":memory:" }),挂起的 agentic-loop 快照才能被重新加载完成恢复。前端路由侧则在 showcase/integrations/mastra/src/app/api/copilotkit/route.ts 中将别名gen-ui-interrupt映射到interruptAgent(并校验其存在,缺失时报 "interruptAgent missing from Mastra config")。
3. 前端实现:useInterrupt 低层原语
demo 页面在 showcase/integrations/mastra/src/app/demos/gen-ui-interrupt/page.tsx,完整继承 QA 文档所验证的交互契约:
<CopilotKit runtimeUrl="/api/copilotkit" agent="gen-ui-interrupt"> <div className="flex justify-center items-center h-screen w-full"> <div className="h-full w-full max-w-4xl"> {/* QA: max-w-4xl、full-height 容器 */} <Chat /> </div> </div> </CopilotKit>Chat组件内的关键 hook:
useInterrupt({ agentId: "gen-ui-interrupt", renderInChat: true, // 内联渲染到转写区(非 portal 到 body) render: ({ event, resolve }) => { // Mastra 将 suspend 值包装为 { type: "mastra_suspend", toolName, suspendPayload, ... } // 且 AG-UI adapter 会将其 JSON 字符串化 —— 先 parse,再读 suspendPayload const raw = event.value ?? {}; const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw) as { suspendPayload?: SuspendPayload } & SuspendPayload; const payload: SuspendPayload = parsed.suspendPayload ?? parsed; const slots = payload.slots && payload.slots.length > 0 ? payload.slots : generateFallbackSlots(); return ( <TimePickerCard topic={payload.topic ?? "a call"} attendee={payload.attendee} slots={slots} onSubmit={(result) => { // 延迟 resolve:等 React 先提交 picked/cancelled 徽标, // 再让 useInterrupt 清除 interrupt 元素(单个 rAF 不可靠) setTimeout(() => resolve(result), 500); }} /> ); }, });源码注释揭示了三个前端陷阱:
- payload 是包装对象:
event.value里的原始值不是业务数据,而是mastra_suspend包装;业务字段(topic/attendee/slots)在suspendPayload内,且整体可能被 JSON 字符串化,需要「字符串则 parse、再取suspendPayload,取不到就整体当 payload」的容错解包。 - resolve 必须延迟:先让本地 state 把
time-picker-picked/time-picker-cancelled徽标渲染出来并 commit,再调用resolve(...);否则useInterrupt会立刻清除 interrupt 元素,用户看不到「Booked / Cancelled」的只读结果。单个requestAnimationFrame不够可靠,demo 使用 500mssetTimeout。 - slot 兜底:若 suspend payload 未携带 slots,回退到 showcase/integrations/mastra/src/app/demos/_shared/interrupt-fallback-slots.ts 的
generateFallbackSlots()(与后端生成逻辑镜像,同样相对Date.now()计算,避免硬编码日期一周内过期)。
3.1 TimePickerCard 三态状态机
卡片组件 showcase/integrations/mastra/src/app/demos/gen-ui-interrupt/_components/time-picker-card.tsx 由本地 state(picked/cancelled)驱动三种互斥形态,每种形态对应 QA 文档断言的data-testid:
| 形态 | data-testid | 内容 |
|---|---|---|
| 可交互(初始) | time-picker-card | 头部Book a calloutline 徽标 +(若有)With {attendee}行 + 主题标题 + "Pick a time that works for you." 描述;2x2 grid 恰好 4 个 slot 按钮(time-picker-slot);底部 ghost 按钮 "None of these work"(time-picker-cancel) |
| 已选择 | time-picker-picked | 绿色Bookedsuccess 徽标 + 所选 label 加粗显示;交互卡片整体卸载 |
| 已取消 | time-picker-cancelled | 红色Cancelled徽标 + "No time picked." |
防重复提交由disabled = picked !== null || cancelled保证:首次点击后立即禁用所有按钮,因此快速双击只会提交一次选择。提交回调的 payload 严格对齐后端resumeSchema:选择 slot 时发送{ chosen_time: s.iso, chosen_label: s.label },点击取消时发送{ cancelled: true }。
3.2 建议项(Suggestion Pills)
建议项在 showcase/integrations/mastra/src/app/demos/gen-ui-interrupt/suggestions.ts 中通过useConfigureSuggestions注册,available: "always",两条 verbatim 标题:
- "Book a call with sales"(消息:"Book an intro call with the sales team to discuss pricing.")
- "Schedule a 1:1 with Alice"(消息:"Schedule a 1:1 with Alice next week to review Q2 goals.")
QA 文档要求校验的是 pill 标题的逐字内容,而非消息体。
4. QA 验证流程(完整检查清单)
以下清单完整继承自 showcase/integrations/mastra/qa/gen-ui-interrupt.md,可直接作为回归验收脚本执行。
4.1 基础功能(Basic Functionality)
- 访问
/demos/gen-ui-interrupt,页面在 3 秒内渲染完成,CopilotChat位于max-w-4xl、全高、rounded-2xl容器中; - 首次加载时输入框 placeholder 可见、转写区为空,且页面上不存在
data-testid="time-picker-card"; - 发送 "Hello",agent 仅以纯文本回复(不出现 picker —— 只有明确要求预约/排期时 agent 才会调用
schedule_meeting)。
4.2 功能专项检查(Feature-Specific Checks)
建议项(Suggestions)
- 两个建议 pill 均可见,标题逐字为 "Book a call with sales" 与 "Schedule a 1:1 with Alice"。
中断触发 + 内联渲染(useInterrupt 低层原语)
- 点击 "Book a call with sales"(或手动输入 "Use schedule_meeting to book an intro call with the sales team about pricing.");
- 60 秒内 agent 调用
schedule_meeting,后端工具suspend(),且 picker内联出现(data-testid="time-picker-card"); - 确认该卡片是聊天转写区的后代节点(未 portal 到
<body>,区别于hitl-in-app的模态形态)——断言不存在body > [data-testid="time-picker-card"]; - 卡片头部显示 "Book a call" eyebrow 徽标、主题标题(topic)以及 "Pick a time that works for you." 描述;
- 2x2 网格中恰好 4 个 slot 按钮(
data-testid="time-picker-slot"),标签为后端生成的相对时间:Tomorrow 10:00 AM、Tomorrow 2:00 PM、Monday 9:00 AM、Monday 3:30 PM; - 网格下方存在 "None of these work" ghost 按钮(
data-testid="time-picker-cancel")。
选择 slot 的恢复路径(Pick-a-Slot Resume Path)
- 点击四个 slot 之一(例如 "Monday 9:00 AM");
- 卡片切换为
data-testid="time-picker-picked"—— "Booked" 成功徽标 + 所选 label 加粗 —— 且交互卡片卸载(不再有time-picker-card); - agent 恢复运行并产出确认消息(后端返回
Scheduled "{topic}" for {chosen_label}.,agent 据此回复)。
取消路径(Cancel Path)
- 发送 "Use schedule_meeting to book a 1:1 with Alice next week to review Q2 goals.";
- 新的 picker 内联渲染(
data-testid="time-picker-card");当 agent 提供了 attendee 时,eyebrow 旁出现 "With Alice" 行; - 点击 "None of these work";
- 卡片切换为
data-testid="time-picker-cancelled"—— "Cancelled" 徽标 + "No time picked."; - agent 恢复运行并回复会议未被排期。
多轮(Multi-Turn)
- 在一次选择或取消之后,再发送一条预约指令;确认新的独立 picker 渲染(旧卡片保持已解决态),interrupt 生命周期干净地重放,第二次 resume 端到端可用。
契约检查:interrupt 是低层原语(Contract Check)
- 仅工具触发路径会渲染 picker:普通对话消息(如 "What's the weather?")不应出现 picker;
- 不出现审批对话框式模态(本 demo 是内联而非 modal);
- 注意:picker 展示的
topic来自模型的工具调用参数,非确定性—— QA 不要对 topic 文案做断言。
4.3 错误处理(Error Handling)
- 发送空消息:应为 no-op;
- 快速双击 slot 按钮:仅提交一次选择(首次 pick/cancel 后按钮即禁用);
- 贯穿 pick / cancel / 多轮全过程,无未捕获的 console 错误。
4.4 预期结果(Expected Results)
- 聊天页 3 秒内加载完成;纯文本响应 10 秒内返回;
- 收到预约类提示后 60 秒内内联渲染 picker;
- picker 通过 slot 按钮(
{chosen_time, chosen_label})或 "None of these work"({cancelled: true})解决;解决后卡片只读; - agent resume 产生的确认消息引用了所选 slot 或取消事实;
- 无布局破坏、无未捕获 console 错误、单次 interrupt 不出现重复 picker。
5. 关键要点与陷阱小结
- Mastra 的中断是工具级原生 suspend,而非 LangGraph
interrupt()那样的图节点机制;桥接层负责把 suspend chunk 翻译为 AG-UI interrupt 事件族(on_interruptCUSTOM +RUN_FINISHEDinterrupt-outcome)。 return suspend(...)与解构位置是后端两个最易踩的坑:前者写错会让循环越过暂停点,后者写错会让suspend为undefined并引发工具调用死循环(见 interrupt.ts 中对应注释)。- resume 需要实例 storage:
new Mastra({ storage })缺省时挂起的循环快照无法重载,恢复链路断裂。 - 前端解包 + 延迟 resolve:
mastra_suspend包装、JSON 字符串化、以及 500ms 延迟 resolve 三者共同保证「先展示结果徽标、再清除 interrupt 元素」的视觉正确性。 - 确定性边界:slot 标签由后端按当前时间生成(四个固定相对标签),但
topic由模型产出、不可断言;QA 断言应锚定data-testid、按钮数量、徽标文案等结构化特征。 - 内联 vs 模态:
renderInChat: true使卡片成为转写区后代节点;如需应用表面模态(approval-dialog 风格),参照同仓库的hitl-in-appdemo,二者是同一 interrupt 机制在不同渲染面上的用法。
【免费下载链接】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),仅供参考