# Clarify 工具前端交互设计 > 日期: 2026-04-16 > 背景: 后端已实现 clarify 工具(WebSocket 协议 + 阻塞机制),但前端 Agent 对话页面缺少对应 UI,导致 clarify_request 事件无人处理。后端 Promise 无限等待直到用户回答。 ## 1. 目标 在前端 Agent 对话页面实现 clarify 工具的交互 UI: - 收到 `clarify_request` 时,在聊天流中显示交互卡片 - 用户可点选预设选项或输入自定义回答 - 回答后发送 `clarify_response` 回后端 - 卡片状态从"等待回答"变为"已回答" ## 2. 数据流 ``` 后端 clarify_request → WS message 事件 → store/chat.ts handleWSEvent 新增 case → 创建 role='clarify' 的 ChatMessage 插入 messages → chat.vue 渲染时识别 clarify 消息 → 渲染 clarify-card.vue 交互卡片 用户点选/输入 → clarify-card emit('answer', text) → store.sendClarifyResponse(requestId, answer) → WS emit clarify_response → 后端 Promise resolve,Agent 继续执行 ``` ## 3. 涉及文件 | 文件 | 操作 | 说明 | |------|------|------| | `types/index.d.ts` | 修改 | 扩展 WSServerEvent/WSClientMessage 类型,新增 ClarifyRequestData 接口 | | `store/chat.ts` | 修改 | handleWSEvent 新增 `clarify_request` case,新增 `sendClarifyResponse()` | | `components/clarify-card.vue` | 新建 | 交互卡片组件 | | `components/message-item.vue` | 修改 | 识别 `role='clarify'` 消息,渲染 clarify-card | 所有路径基于 `packages/frontend/src/modules/agent/`。 ## 4. 类型定义扩展 ```typescript // types/index.d.ts // WSServerEvent.type 联合追加 'clarify_request' // WSClientMessage.type 联合追加 'clarify_response' interface ClarifyRequestData { requestId: string; question: string; choices?: string[]; } // ChatMessage.role 联合追加 'clarify' // ChatMessage.metadata 中存储 ClarifyRequestData + status ``` ## 5. Store 变更 (store/chat.ts) ### handleWSEvent 新增 case ```typescript case 'clarify_request': { const data = event.data as ClarifyRequestData; messages.value.push({ role: 'clarify', content: data.question, metadata: { requestId: data.requestId, choices: data.choices, status: 'pending', // pending | answered answer: null, }, }); scrollToBottom(); break; } ``` ### sendClarifyResponse 函数 ```typescript function sendClarifyResponse(requestId: string, answer: string) { // 更新消息状态 const msg = messages.value.find( m => m.role === 'clarify' && m.metadata?.requestId === requestId ); if (msg) { msg.metadata.status = 'answered'; msg.metadata.answer = answer; } // 发送 WS 消息 wsInstance.send({ type: 'clarify_response', sessionId: sessionId.value, requestId, answer, }); } ``` ## 6. clarify-card.vue 组件设计 ### Props - `message: ChatMessage` — role='clarify' 的消息对象 ### 状态 - `pending`: 显示问题 + 选项按钮 + 自定义输入框 - `answered`: 只读,显示"已回答:xxx" ### 模板结构 ```
🤔 Agent 需要你的确认
{{ message.content }}
✅ 已回答:{{ answer }}
``` ### 样式 - 遵循 skill-card / todo-card 的卡片风格 - 圆角边框,浅色背景 - 选项按钮使用 Element Plus 的 `el-button` plain 样式 - pending 状态有轻微脉冲动画提示用户需要操作 ## 7. message-item.vue 变更 在消息渲染逻辑中新增 clarify 分支: ```vue ``` `handleClarifyAnswer` 调用 store 的 `sendClarifyResponse`。 ## 8. 微信端降级 微信端无法渲染自定义卡片,采用纯文本问答降级。 ### 8.1 数据流 ``` Agent 调用 clarify tool → onClarifyRequest 回调 → 格式化为纯文本(带编号选项) → weixinService.sendText() 发送给用户 → Promise 阻塞等待(无超时,与 Web 端一致) 用户回复微信消息 → handleInboundMessage() → 检查 pendingClarify Map 是否有该用户的 pending 请求 → 有:resolve Promise,Agent 继续执行 → 无:正常走 agentExecutor.execute() 新对话 ``` ### 8.2 涉及文件 | 文件 | 操作 | 说明 | |------|------|------| | `service/agent_executor.ts` | 修改 | execute() 新增可选 `onClarifyRequest` 回调,传递给 runAgent() | | `service/agent_channel.ts` | 修改 | 新增 pendingClarify Map + clarify 文本格式化 + 消息路由 | 所有路径基于 `packages/backend/src/modules/netaclaw/`。 ### 8.3 agent_executor.ts 变更 `execute()` 方法签名新增可选回调: ```typescript async execute(params: { sessionId?: string; message: string; agentId?: number; agentName?: string; userId?: string; onClarifyRequest?: (question: string, choices?: string[]) => Promise; }) { // ... 现有逻辑 ... const result = await runAgent({ agentConfig, tools: [...this.defaultTools, ...memoryTools, ...skillTools], userMessage: params.message, history: history.slice(0, -1), onClarifyRequest: params.onClarifyRequest, // 透传 }); } ``` ### 8.4 agent_channel.ts 变更 #### 模块级 Map ```typescript /** 微信 clarify 阻塞 Map — key: `${channelId}:${senderId}` */ private readonly pendingClarify = new Map void }>(); ``` #### handleInboundMessage 消息路由 在现有 `agentExecutor.execute()` 调用之前,检查是否有 pending clarify: ```typescript // 检查是否有 pending clarify const clarifyKey = `${channel.id}:${senderId}`; const pending = this.pendingClarify.get(clarifyKey); if (pending) { this.pendingClarify.delete(clarifyKey); pending.resolve(text); return; // 不启动新对话 } ``` #### onClarifyRequest 回调 传递给 `agentExecutor.execute()`: ```typescript const result = await this.agentExecutor.execute({ sessionId, message: text, agentId: channel.agentId, agentName: channel.agentName || undefined, userId: senderId, onClarifyRequest: async (question, choices) => { // 格式化纯文本 let msg = `❓ ${question}`; if (choices?.length) { msg += '\n' + choices.map((c, i) => `${i + 1}. ${c}`).join('\n'); msg += '\n\n请回复数字或直接输入你的回答'; } await this.weixinService.sendText(credential, senderId, msg, state.contextTokens[senderId]); // 阻塞等待用户回复 return new Promise((resolve) => { this.pendingClarify.set(clarifyKey, { resolve }); }); }, }); ``` #### 选项编号解析 用户回复数字时自动映射到对应选项(在 resolve 前处理): ```typescript if (pending) { this.pendingClarify.delete(clarifyKey); // 数字映射(如果有 choices 上下文) pending.resolve(text); return; } ``` 注:数字映射需要在 pendingClarify Map 中额外存储 choices 数组。Map value 扩展为: ```typescript private readonly pendingClarify = new Map void; choices?: string[]; }>(); ``` resolve 前检查: ```typescript let answer = text; if (pending.choices?.length) { const num = parseInt(text, 10); if (num >= 1 && num <= pending.choices.length) { answer = pending.choices[num - 1]; } } pending.resolve(answer); ``` ### 8.5 微信用户体验 **Agent 提问时用户看到:** ``` ❓ 你想要什么风格的商品描述? 1. 简洁专业 2. 活泼有趣 3. 高端奢华 请回复数字或直接输入你的回答 ``` **用户回复 "2"** → 自动映射为 "活泼有趣" **用户回复 "我想要文艺风"** → 直接使用原文