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"** → 自动映射为 "活泼有趣"
|
||
**用户回复 "我想要文艺风"** → 直接使用原文
|