8.3 KiB
8.3 KiB
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. 类型定义扩展
// 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
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 函数
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"
模板结构
<div class="clarify-card">
<!-- 头部:图标 + 标题 -->
<div class="header">
🤔 Agent 需要你的确认
</div>
<!-- 问题文本 -->
<div class="question">{{ message.content }}</div>
<!-- pending 状态 -->
<template v-if="status === 'pending'">
<!-- 选项按钮(如果有 choices) -->
<div class="choices" v-if="choices?.length">
<el-button v-for="choice in choices" @click="selectChoice(choice)">
{{ choice }}
</el-button>
</div>
<!-- 自定义输入 -->
<div class="custom-input">
<el-input v-model="customAnswer" placeholder="输入自定义回答..." />
<el-button @click="submitCustom" :disabled="!customAnswer.trim()">
发送
</el-button>
</div>
</template>
<!-- answered 状态 -->
<div v-else class="answered">
✅ 已回答:{{ answer }}
</div>
</div>
样式
- 遵循 skill-card / todo-card 的卡片风格
- 圆角边框,浅色背景
- 选项按钮使用 Element Plus 的
el-buttonplain 样式 - pending 状态有轻微脉冲动画提示用户需要操作
7. message-item.vue 变更
在消息渲染逻辑中新增 clarify 分支:
<template v-if="message.role === 'clarify'">
<clarify-card :message="message" @answer="handleClarifyAnswer" />
</template>
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() 方法签名新增可选回调:
async execute(params: {
sessionId?: string;
message: string;
agentId?: number;
agentName?: string;
userId?: string;
onClarifyRequest?: (question: string, choices?: string[]) => Promise<string>;
}) {
// ... 现有逻辑 ...
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
/** 微信 clarify 阻塞 Map — key: `${channelId}:${senderId}` */
private readonly pendingClarify = new Map<string, { resolve: (answer: string) => void }>();
handleInboundMessage 消息路由
在现有 agentExecutor.execute() 调用之前,检查是否有 pending clarify:
// 检查是否有 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():
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<string>((resolve) => {
this.pendingClarify.set(clarifyKey, { resolve });
});
},
});
选项编号解析
用户回复数字时自动映射到对应选项(在 resolve 前处理):
if (pending) {
this.pendingClarify.delete(clarifyKey);
// 数字映射(如果有 choices 上下文)
pending.resolve(text);
return;
}
注:数字映射需要在 pendingClarify Map 中额外存储 choices 数组。Map value 扩展为:
private readonly pendingClarify = new Map<string, {
resolve: (answer: string) => void;
choices?: string[];
}>();
resolve 前检查:
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" → 自动映射为 "活泼有趣" 用户回复 "我想要文艺风" → 直接使用原文