316 lines
8.3 KiB
Markdown
316 lines
8.3 KiB
Markdown
|
|
# 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"
|
|||
|
|
|
|||
|
|
### 模板结构
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
<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-button` plain 样式
|
|||
|
|
- pending 状态有轻微脉冲动画提示用户需要操作
|
|||
|
|
|
|||
|
|
## 7. message-item.vue 变更
|
|||
|
|
|
|||
|
|
在消息渲染逻辑中新增 clarify 分支:
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<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()` 方法签名新增可选回调:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
/** 微信 clarify 阻塞 Map — key: `${channelId}:${senderId}` */
|
|||
|
|
private readonly pendingClarify = new Map<string, { resolve: (answer: string) => 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<string>((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<string, {
|
|||
|
|
resolve: (answer: string) => 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"** → 自动映射为 "活泼有趣"
|
|||
|
|
**用户回复 "我想要文艺风"** → 直接使用原文
|