GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-04-16-clarify-frontend-design.md
2026-05-20 21:39:12 +08:00

8.3 KiB
Raw Blame History

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 resolveAgent 继续执行

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-button plain 样式
  • 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 PromiseAgent 继续执行
  → 无:正常走 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" → 自动映射为 "活泼有趣" 用户回复 "我想要文艺风" → 直接使用原文