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