CopilotKit 应用级 Human-in-the-Loop 实战:基于前端工具实现应用级审批弹窗(Claude Agent SDK / TypeScript)
【免费下载链接】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 智能体中,当 Agent 需要执行退款、降级套餐、升级工单等高风险操作时,必须把决策权交还给用户——这就是 Human-in-the-Loop(HITL)。本篇文章以 CopilotKit 仓库中 Claude Agent SDK (TypeScript) 集成示例的hitl-in-app演示为核心,讲解如何通过useFrontendTool注册一个异步阻塞的前端工具,在应用层(而非聊天流内部)弹出一个审批模态框,并将 Approve / Reject 的结果以工具返回值的形式交还给 Agent。读完本文,你将掌握应用级审批弹窗的完整实现链路、后端运行时配置,以及对应的 QA 验证方法与端到端测试断言。
一、什么是“In-App”级 HITL:弹窗在聊天之外
CopilotKit 提供两种 HITL 呈现形态:
- In-Chat(聊天内):审批 UI 渲染在聊天气泡树内部,属于对话流的一部分;
- In-App(应用级):审批 UI 以应用级模态框的形式渲染在聊天界面之外,例如
createPortal挂载到document.body。
本演示对应的 QA 文档位于 qa/hitl-in-app.md,其验收标准明确写道:“Verify the approval dialog renders OUTSIDE the chat (app-level modal)”。应用级模态框的价值在于:审批动作与聊天记录解耦,用户可以一边查看工单面板一边决策,而 Agent 在审批完成之前会一直阻塞等待——这正是异步前端工具(async frontend tool)的核心语义。
二、前置条件
QA 文档给出两条运行前提,它们对复现与验证都至关重要:
- Demo 已部署且可访问(Demo is deployed and accessible):即
/demos/hitl-in-app页面能够正常打开; - Agent 后端健康(Agent backend is healthy):CopilotKit 运行时需要能够连通 Claude Agent 后端进程。
在 运行时路由 中,GET健康探针会主动请求${AGENT_URL}/health(默认http://localhost:8000),并返回agent_status与ANTHROPIC_API_KEY是否设置的诊断信息,可用于快速确认前提 2 是否满足。
三、核心原理:异步前端工具如何阻塞 Agent
整个 In-App HITL 的基石是useFrontendTool。在 packages/react-core/src/v2/hooks/use-frontend-tool.tsx 中可以看到它的行为:组件挂载时通过copilotkit.addTool(tool)将工具注册进 CopilotKit 运行时;卸载时移除;若同名工具已存在会先移除再覆盖注册。也就是说,工具的“存在性”完全由前端页面决定,后端 Agent 通过 AG-UI 协议透明地把工具调用转发给前端。
Demo 页面 src/app/demos/hitl-in-app/page.tsx 注册了唯一的审批工具request_user_approval:
useFrontendTool({ name: "request_user_approval", description: "Ask the operator to approve or reject an action before you take it. " + "The operator will respond via an in-app modal dialog that appears " + "OUTSIDE the chat surface. The tool returns an object of the shape " + "{ approved: boolean, reason?: string }.", parameters: z.object({ message: z .string() .describe( "Short summary of the action needing approval (include concrete numbers / IDs).", ), context: z .string() .optional() .describe( "Optional extra context — e.g. the ticket ID or policy rule.", ), }), handler: async ({ message, context }) => { return await new Promise<{ approved: boolean; reason?: string }>( (resolve) => { setDialog((current) => { if (current.open) { resolve({ approved: false, reason: "Another approval request is already pending.", }); return current; } return { open: true, pending: { message, context }, resolve }; }); }, ); }, });关键设计有四点:
- 参数 Schema 用 zod 声明:
message(必须,包含具体数字 / ID 的操作摘要)与context(可选,如工单 ID 或策略规则)。Agent 会依据description决定何时调用该工具。 - Handler 返回一个悬而未决的 Promise:
resolve被存进组件 state(DialogState),弹窗里用户点击 Approve / Reject 时才调用它,从而“解封”handler,把结果作为工具返回值交还给 Agent。 - 并发防护:如果已有审批弹窗打开(
current.open为真),新请求会被立即拒绝,返回approved: false与原因 “Another approval request is already pending.”,保证同一时刻只有一个挂起的审批。 - 返回值契约:
{ approved: boolean, reason?: string },reason 可选,用户填写的备注会透传给 Agent。
从源码结构看,DialogState被建模为可辨识联合类型{ open: false } | { open: true; pending: PendingApproval; resolve: ResolveFn },其中ResolveFn正是从 Promise 捕获到的完成函数——这是“前端 Promise ↔ 模态框 ↔ Agent 工具结果”三者之间的唯一接线点。
四、审批弹窗:Portal 到<body>的应用级模态
审批弹窗组件位于 approval-dialog.tsx,它的核心是最后一行的createPortal(content, document.body):
export function ApprovalDialog({ pending, onResolve }: Props) { const [reason, setReason] = useState(""); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); if (!mounted) return null; const content = ( <div >const handleResolve = (result: { approved: boolean; reason?: string }) => { if (dialog.open) { dialog.resolve(result); setDialog({ open: false }); } };至此闭环完成:用户点击 → resolve 被调用 → handler 的 Promise 完成 → 工具结果经运行时回传 Agent → Agent 依据approved分支决策。
五、后端与运行时:透传式 Claude HttpAgent
与“后端拥有工具”的演示不同,本演示采用透传(pass-through)后端。在 claude-http-agent.ts 中,createClaudeHttpAgent只是new HttpAgent(...)的封装,本身不声明任何工具:
export function createClaudeHttpAgent(url: string): HttpAgent { return new HttpAgent(claudeHttpAgentConfig(url)); }运行时路由 的注释对此解释得很清楚:这个后端“转发 AG-UI 客户端提供的任何工具(前端通过useFrontendTool/useRenderTool注册的,以及运行时注入的)给 Claude。因此不同 demo 的行为差异来自前端,而不是每个 demo 一个后端图”。hitl-in-app被注册为共享 agent 之一,AGENT_URL默认指向http://localhost:8000的独立 TypeScript 进程;createCopilotRuntimeHandler以single-route模式、basePath: "/api/copilotkit"承载请求。也就是说,只要后端健康且提供 Anthropic 密钥,审批工具的存在与行为完全由前端页面驱动——这也解释了 QA 前置条件为何只要求“demo 可访问 + 后端健康”。
Demo 页面还通过 suggestions.ts 的useConfigureSuggestions预置了三条建议卡片(pill),分别对应三个工单:
- “Approve refund for #12345”——批准 Jordan Rivera 的 $50 重复扣款退款;
- “Downgrade plan for #12346”——将 Priya Shah 降级到 Starter 套餐;
- “Escalate ticket #12347”——将 Morgan Lee 卡在 pending 的支付工单升级给支付团队。
工单数据是硬编码在 tickets-panel.tsx 的SUPPORT_TICKETS中,为 Agent 提供了“真实可见”的上下文,方便测试者一键触发审批。
六、QA 测试步骤与验证要点
以下按 QA 文档逐条展开,并补充每个断言对应的实现依据:
导航到
/demos/hitl-in-app页面由HitlInAppDemo渲染,外层以<CopilotKit runtimeUrl="/api/copilotkit" agent="hitl-in-app">包裹,内层是Layout+CopilotPopup(agentId="hitl-in-app"、defaultOpen为 true)和工单面板。E2E 测试beforeEach中执行page.goto("/demos/hitl-in-app")后,会断言三个工单卡片(ticket-12345/ticket-12346/ticket-12347)、聊天输入框(占位符 “Type a message”)可见,且初始状态下approval-dialog-overlay数量为 0——即没有任何审批弹窗。让 Agent 执行需要审批的动作点击任一建议卡片即可。例如点击 “Approve refund for #12345” 后,Agent 会调用
request_user_approval工具。三条建议卡片均被 E2E 断言为可见(见 tests/e2e/hitl-in-app.spec.ts),且卡片文本精确引用各工单。验证审批弹窗渲染在聊天之外(应用级模态框)这是本用例的独有断言。测试用
body > [data-testid="approval-dialog-overlay"]定位弹窗,确认它是<body>的直接子节点——这正是createPortal的契约。同时断言approval-dialog与可选备注输入框approval-dialog-reason可见。QA 文档要求“dialog renders OUTSIDE the chat”,E2E 用 DOM 层级把这一条固化成可自动验证的标准。批准操作,验证 Agent 按用户决定继续点击
approval-dialog-approve后,断言弹窗在 5 秒内消失,然后断言助手消息包含分支专属的前导句,例如批准退款 #12345 后出现 “I am processing the $50 refund”。这里体现了分支语义:不同结果走不同的确定性夹具分支(fixture branch),消息文本与 approve/reject 一一对应。重复执行并拒绝,验证 Agent 尊重拒绝结果对同一建议卡片再点一次,这次点击
approval-dialog-reject,随后助手消息应包含拒绝分支的前导句,例如 “refund request was not approved”。升级工单场景同理:批准后出现 “Escalated ticket #12347”,拒绝后出现 “Not escalated ...”。验证无控制台错误QA 文档要求 “Verify no console errors”,对应 Playwright 默认收集页面 console / pageerror 的机制,测试全程无 UI 报错即通过。
预期结果(Expected Results)与自动化对应
QA 文档的三条预期结果,逐一对应测试断言:
- 异步前端工具阻塞直到用户解决模态框(Async frontend tool blocks until user resolves the modal):handler 返回的 Promise 悬而未决,E2E 用 60 秒超时等待
body > [data-testid="approval-dialog-overlay"]出现,再等待点击后弹窗消失,验证了“先阻塞、后解封”的时序; - Agent 结果在批准与拒绝之间不同(Agent outcome differs between approve and reject):同一建议卡片跑两条测试,approve 分支断言 “processing the $50 refund”,reject 分支断言 “not approved”——文本不对称意味着至少一个分支会失败,从而证明结果确实由用户选择驱动;
- 无 UI 错误(No UI errors):见上一条。
七、测试设计中的工程细节
理解 E2E 测试的断言策略,能帮你更稳地复现与验证该演示:
- 串行模式(serial mode)是承重的:夹具匹配器(aimock
sequenceIndex)在整个进程内计数,approve 测试(sequenceIndex 0)必须严格先于 reject 测试(sequenceIndex 1)运行,因此test.describe.configure({ mode: "serial" })必不可少; - 多任务并发审批回归:测试 “approve refund then click escalate — each pill mounts its own approval dialog” 验证了连点两个建议卡片时,每个 pill 都会挂载自己独立的审批弹窗(分别指向 #12345 与 #12347)。这正是页面中
if (current.open) resolve({ approved: false, ... })并发保护在单线程场景下的正确行为; - 已注释的降级用例:文件中
downgrade #12346的用例被有意跳过(上游 demo 在降级提示词下未稳定触发request_user_approval),注释明确了“待上游修复后重新启用”——这是仓库对已知上游缺陷的诚实标注,复现时不必把它当作失败; hasToolResult回归教训:注释中记录了 aimock 多 pill 缺陷的修复——通过request_user_approval的toolCallId链式串联后续夹具,并去掉工具发射夹具上的hasToolResult: false,避免第二个 pill 在首次工具结果已存在时被跳过。这条历史经验提醒我们:HITL 场景中夹具对工具调用的建模必须考虑线程已有的工具结果状态。
八、可复用的实现模式小结
从本演示可以提炼出一个通用的“应用级审批”模式,共五步:
- 注册工具:用
useFrontendTool声明工具名、语义化description、zod 参数 Schema 与 async handler; - 捕获 resolve:handler 返回
new Promise((resolve) => { setDialog(...) }),把resolve存入 state; - 渲染应用级 UI:用
createPortal(content, document.body)将审批模态框挂到<body>,脱离聊天树; - 用户决策:模态框的 Approve / Reject 回调调用
onResolve({ approved, reason? }),最终触发dialog.resolve(...); - Agent 分支决策:运行时把
{ approved, reason? }作为工具结果回传,Agent 依据approved字段继续执行或终止操作。
整个过程对用户透明、对 Agent 阻塞、对前端可控,配合 hitl-in-app.spec.ts 的断言矩阵(approve/reject × 多个工单 × 多任务串行),足以作为任何需要“高风险操作必须人工确认”的智能体前端(客服退款、审批流、运维变更等)的落地蓝本。若要进一步探索聊天内 HITL 的差异实现,可对照同一目录下的 qa/hitl-in-chat.md 及其对应的tests/e2e/hitl-in-chat.spec.ts。
【免费下载链接】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),仅供参考