GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-16-clarify-frontend.md

624 lines
17 KiB
Markdown
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
# Clarify 工具前端交互 + 微信降级 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Post-task review:** After each task commit, run the `simplify` skill to review changed code for reuse, quality, and efficiency.
**Goal:** 在前端 Agent 对话页面实现 clarify 工具的交互 UI并在后端为微信渠道实现纯文本降级方案。
**Architecture:** 收到 `clarify_request` WS 事件时,在聊天流中插入 `role='clarify'` 的特殊消息,由新建的 `clarify-card.vue` 组件渲染为交互卡片(选项按钮 + 自定义输入)。用户回答后通过 WS 发送 `clarify_response`,卡片变为只读。微信端通过 `agent_executor.ts` 透传 `onClarifyRequest` 回调,`agent_channel.ts` 用 pendingClarify Map 阻塞等待用户回复。
**Tech Stack:** Vue 3 + TypeScript + Element Plus + Pinia + Socket.IO (前端) / Midway.js + TypeScript (后端)
**Base path (前端):** `packages/frontend/src/modules/agent`
**Base path (后端):** `packages/backend/src/modules/netaclaw`
---
### Task 1: 类型定义扩展
**Files:**
- Modify: `packages/frontend/src/modules/agent/types/index.d.ts`
- [ ] **Step 1: 扩展 ChatMessage.role 联合类型**
`types/index.d.ts` 第 21 行,`ChatMessage.role` 联合追加 `'clarify'`
```typescript
// 旧:
role: 'user' | 'assistant' | 'tool' | 'system';
// 新:
role: 'user' | 'assistant' | 'tool' | 'system' | 'clarify';
```
- [ ] **Step 2: 扩展 WSClientMessage.type 联合类型并追加字段**
`types/index.d.ts` 第 131-136 行,修改 `WSClientMessage`
```typescript
// 旧:
export interface WSClientMessage {
type: 'chat' | 'ping';
sessionId?: string;
content?: string;
agentId?: number;
}
// 新:
export interface WSClientMessage {
type: 'chat' | 'ping' | 'clarify_response';
sessionId?: string;
content?: string;
agentId?: number;
requestId?: string;
answer?: string;
}
```
- [ ] **Step 3: 扩展 WSServerEvent.type 联合类型**
`types/index.d.ts` 第 142 行,`WSServerEvent.type` 联合追加 `'clarify_request'`
```typescript
// 旧:
type: 'token' | 'thinking' | 'tool_call' | 'tool_result' | 'skill_start' | 'skill_end' | 'progress' | 'token_update' | 'done' | 'error' | 'pong' | 'todo_update' | 'thinking_delta' | 'thinking_done';
// 新:
type: 'token' | 'thinking' | 'tool_call' | 'tool_result' | 'skill_start' | 'skill_end' | 'progress' | 'token_update' | 'done' | 'error' | 'pong' | 'todo_update' | 'thinking_delta' | 'thinking_done' | 'clarify_request';
```
- [ ] **Step 4: 新增 ClarifyRequestData 和 ClarifyMessageMeta 接口**
`types/index.d.ts` 文件末尾(`TokenUpdateEvent` 之后)追加:
```typescript
// === Clarify 工具类型 ===
export interface ClarifyRequestData {
requestId: string;
question: string;
choices?: string[];
}
export interface ClarifyMessageMeta {
requestId: string;
choices?: string[];
status: 'pending' | 'answered';
answer?: string;
}
```
- [ ] **Step 5: Commit**
```bash
git add packages/frontend/src/modules/agent/types/index.d.ts
git commit -m "feat(agent): extend WS types for clarify tool interaction"
```
---
### Task 2: Store 变更 — 处理 clarify_request + 发送 clarify_response
**Files:**
- Modify: `packages/frontend/src/modules/agent/store/chat.ts`
- [ ] **Step 1: 在 handleWSEvent 中新增 clarify_request 提前处理**
`store/chat.ts``handleWSEvent` 函数中clarify_request 不依赖 assistantMsg需要在现有的 `if (!loading.value ...)``const assistantMsg = ...` 守卫之前处理。在函数开头(第 246 行 `function handleWSEvent` 之后)插入:
```typescript
function handleWSEvent(event: WSServerEvent) {
// clarify_request 不依赖 assistantMsg提前处理
if (event.type === 'clarify_request') {
const data = event.data;
if (data?.requestId && data?.question) {
messages.value.push({
role: 'clarify',
content: data.question,
metadata: {
requestId: data.requestId,
choices: data.choices || [],
status: 'pending',
answer: undefined,
},
});
_onTokenCbs.forEach(cb => cb());
}
return;
}
// 以下是原有逻辑,不变
if (!loading.value && event.type !== 'done') return;
const assistantMsg = messages.value[messages.value.length - 1];
if (!assistantMsg || assistantMsg.role !== 'assistant') return;
switch (event.type) {
// ... 现有 cases 不变 ...
}
}
```
- [ ] **Step 2: 新增 sendClarifyResponse 函数**
`store/chat.ts``setThinkLevel` 函数之后(第 562 行后)追加:
```typescript
/**
* 发送 clarify 回答
*/
function sendClarifyResponse(requestId: string, answer: string) {
const msg = messages.value.find(
m => m.role === 'clarify' && m.metadata?.requestId === requestId
);
if (msg && msg.metadata) {
msg.metadata.status = 'answered';
msg.metadata.answer = answer;
}
if (wsInstance) {
wsInstance.send({
type: 'clarify_response',
sessionId: sessionId.value,
requestId,
answer,
} as any);
}
}
```
- [ ] **Step 3: 在 return 对象中导出 sendClarifyResponse**
`store/chat.ts` 第 738 行 `init` 之后追加 `sendClarifyResponse`
```typescript
return {
// ... 现有导出 ...
init,
sendClarifyResponse,
};
```
- [ ] **Step 4: Commit**
```bash
git add packages/frontend/src/modules/agent/store/chat.ts
git commit -m "feat(agent): handle clarify_request WS event and send clarify_response"
```
---
### Task 3: clarify-card.vue 交互卡片组件
**Files:**
- Create: `packages/frontend/src/modules/agent/components/clarify-card.vue`
- [ ] **Step 1: 创建 clarify-card.vue 模板和脚本**
创建 `packages/frontend/src/modules/agent/components/clarify-card.vue`
```vue
<template>
<div class="clarify-card" :class="{ 'is-answered': isAnswered }">
<div class="clarify-card__header">
<span class="clarify-card__icon">{{ isAnswered ? '✅' : '🤔' }}</span>
<span class="clarify-card__title">{{ isAnswered ? '已回答' : 'Agent 需要你的确认' }}</span>
</div>
<div class="clarify-card__question">{{ message.content }}</div>
<!-- pending 状态:显示选项和输入 -->
<template v-if="!isAnswered">
<div class="clarify-card__choices" v-if="choices.length">
<el-button
v-for="(choice, idx) in choices"
:key="idx"
class="clarify-card__choice-btn"
@click="selectChoice(choice)"
>
{{ choice }}
</el-button>
</div>
<div class="clarify-card__custom">
<el-input
v-model="customAnswer"
:placeholder="choices.length ? '或输入自定义回答...' : '输入你的回答...'"
size="default"
@keydown.enter.prevent="submitCustom"
/>
<el-button
type="primary"
:disabled="!customAnswer.trim()"
@click="submitCustom"
>
发送
</el-button>
</div>
</template>
<!-- answered 状态 -->
<div v-else class="clarify-card__answer">
{{ message.metadata?.answer }}
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type { ChatMessage } from '../types/index.d';
const props = defineProps<{
message: ChatMessage;
}>();
const emit = defineEmits<{
answer: [requestId: string, answer: string];
}>();
const customAnswer = ref('');
const choices = computed(() => props.message.metadata?.choices || []);
const isAnswered = computed(() => props.message.metadata?.status === 'answered');
function selectChoice(choice: string) {
const requestId = props.message.metadata?.requestId;
if (requestId) emit('answer', requestId, choice);
}
function submitCustom() {
const text = customAnswer.value.trim();
if (!text) return;
const requestId = props.message.metadata?.requestId;
if (requestId) emit('answer', requestId, text);
customAnswer.value = '';
}
</script>
```
- [ ] **Step 2: 添加样式**
在同一文件 `</script>` 之后追加样式(遵循 todo-card 的卡片风格):
```vue
<style lang="scss" scoped>
.clarify-card {
width: min(100%, var(--chat-content-max-width, 760px));
margin: 8px 0 10px;
border-radius: 12px;
border: 1px solid var(--el-color-warning-light-5);
background: linear-gradient(180deg, var(--el-bg-color) 0%, color-mix(in srgb, var(--el-color-warning) 4%, var(--el-bg-color)) 100%);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
overflow: hidden;
box-sizing: border-box;
animation: clarify-enter 0.4s ease-out;
&.is-answered {
border-color: var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
}
}
.clarify-card__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
font-size: 13px;
font-weight: 600;
}
.clarify-card__icon {
font-size: 16px;
}
.clarify-card__question {
padding: 0 14px 10px;
font-size: 14px;
line-height: 1.6;
color: var(--el-text-color-primary);
}
.clarify-card__choices {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 14px 10px;
}
.clarify-card__choice-btn {
border-radius: 999px !important;
}
.clarify-card__custom {
display: flex;
gap: 8px;
padding: 0 14px 14px;
}
.clarify-card__answer {
padding: 0 14px 14px;
font-size: 13px;
color: var(--el-text-color-secondary);
font-style: italic;
}
@keyframes clarify-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
```
- [ ] **Step 3: Commit**
```bash
git add packages/frontend/src/modules/agent/components/clarify-card.vue
git commit -m "feat(agent): add clarify-card interactive component"
```
---
### Task 4: 集成到聊天视图
**Files:**
- Modify: `packages/frontend/src/modules/agent/views/chat.vue`
- Modify: `packages/frontend/src/modules/agent/components/message-item.vue`
- [ ] **Step 1: chat.vue — 导入 ClarifyCard 组件**
`views/chat.vue``<script>` 部分 import 区域(第 284 行 `import MessageItem` 附近)追加:
```typescript
import ClarifyCard from '../components/clarify-card.vue';
```
- [ ] **Step 2: chat.vue — 在消息列表中渲染 clarify 卡片**
`views/chat.vue` 模板的消息列表中,`message-item` 循环(第 40-44 行)需要区分 clarify 消息。将:
```vue
<message-item
v-for="(msg, index) in precedingMessages"
:key="index"
:message="msg"
/>
```
改为:
```vue
<template v-for="(msg, index) in precedingMessages" :key="index">
<clarify-card
v-if="msg.role === 'clarify'"
:message="msg"
@answer="handleClarifyAnswer"
/>
<message-item v-else :message="msg" />
</template>
```
- [ ] **Step 3: chat.vue — 添加 handleClarifyAnswer 方法和 store 导出**
`views/chat.vue``<script setup>` 中,从 chatStore 解构中追加 `sendClarifyResponse`(第 335 行附近):
```typescript
const {
sendMessage,
stopGeneration,
loadSessions,
loadAgents,
selectAgent,
newSession,
switchSession,
deleteSession,
deleteAllSessions,
onToken,
init: initChat,
setThinkLevel,
sendClarifyResponse,
} = chatStore;
```
然后在 `handleSend` 函数之前添加:
```typescript
function handleClarifyAnswer(requestId: string, answer: string) {
sendClarifyResponse(requestId, answer);
}
```
- [ ] **Step 4: message-item.vue — hasVisibleBody 兼容 clarify**
`components/message-item.vue` 第 204 行的 `hasVisibleBody` computed 中clarify 消息不应由 message-item 渲染(已在 chat.vue 中用 `v-if` 分流),但为安全起见,确保 `hasVisibleBody``role='clarify'` 返回 false
```typescript
const hasVisibleBody = computed(() => {
if (props.message.role === 'clarify') return false;
return Boolean(
props.message.skillName ||
(props.message.role === 'assistant' && props.message.thinking) ||
props.message.content ||
messageImageUrl.value ||
(tokenUsage.value && props.message.role === 'assistant')
);
});
```
- [ ] **Step 5: Commit**
```bash
git add packages/frontend/src/modules/agent/views/chat.vue \
packages/frontend/src/modules/agent/components/message-item.vue
git commit -m "feat(agent): integrate clarify-card into chat view"
```
---
### Task 5: 后端 — agent_executor.ts 透传 onClarifyRequest
**Files:**
- Modify: `packages/backend/src/modules/netaclaw/service/agent_executor.ts`
- [ ] **Step 1: 扩展 execute() 参数类型**
`agent_executor.ts` 第 43 行的 `execute` 方法参数中追加 `onClarifyRequest`
```typescript
async execute(params: {
sessionId?: string;
message: string;
agentId?: number;
agentName?: string;
userId?: string;
onClarifyRequest?: (question: string, choices?: string[]) => Promise<string>;
}) {
```
- [ ] **Step 2: 透传 onClarifyRequest 给 runAgent**
`agent_executor.ts` 第 142 行的 `runAgent` 调用中追加 `onClarifyRequest`
```typescript
const result = await runAgent({
agentConfig,
tools: [...this.defaultTools, ...memoryTools, ...skillTools],
userMessage: params.message,
history: history.slice(0, -1),
onClarifyRequest: params.onClarifyRequest,
});
```
- [ ] **Step 3: 添加 clarifyTool 到 defaultTools**
`agent_executor.ts` 顶部 import 区域追加:
```typescript
import { clarifyTool } from '../tools/builtin/clarify.js';
```
在第 41 行 `defaultTools` 数组中追加 `clarifyTool`
```typescript
private readonly defaultTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool, patchTool, clarifyTool];
```
- [ ] **Step 4: 验证构建**
Run: `cd packages/backend && npm run build`
Expected: 构建成功
- [ ] **Step 5: Commit**
```bash
git add packages/backend/src/modules/netaclaw/service/agent_executor.ts
git commit -m "feat(netaclaw): agent_executor 透传 onClarifyRequest + 添加 clarifyTool"
```
---
### Task 6: 后端 — agent_channel.ts 微信 clarify 降级
**Files:**
- Modify: `packages/backend/src/modules/netaclaw/service/agent_channel.ts`
- [ ] **Step 1: 添加 pendingClarify Map 类型和实例**
`agent_channel.ts` 第 11-15 行 `RunnerState` 类型定义之后,添加 pendingClarify Map 属性到类中。在第 39 行 `private readonly runners` 之后追加:
```typescript
/** 微信 clarify 阻塞 Map — key: `${channelId}:${senderId}` */
private readonly pendingClarify = new Map<string, {
resolve: (answer: string) => void;
choices?: string[];
}>();
```
- [ ] **Step 2: handleInboundMessage — 检查 pending clarify**
`agent_channel.ts``handleInboundMessage` 方法中,在 `const text = this.weixinService.extractText(...)` 之后、`await this.persistRuntime(...)` 之前(第 339 行后),插入 pending clarify 检查:
```typescript
const text = this.weixinService.extractText(message.item_list || []);
if (!text) return;
// 检查是否有 pending clarify
const clarifyKey = `${channel.id}:${senderId}`;
const pending = this.pendingClarify.get(clarifyKey);
if (pending) {
this.pendingClarify.delete(clarifyKey);
// 数字映射到选项
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);
return; // 不启动新对话
}
await this.persistRuntime(channel.id, state);
```
- [ ] **Step 3: handleInboundMessage — 传递 onClarifyRequest 回调**
`handleInboundMessage` 中现有的 `agentExecutor.execute()` 调用(第 344-350 行)改为传递 `onClarifyRequest`
```typescript
const clarifyKey = `${channel.id}:${senderId}`;
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, choices });
});
},
});
```
注意:`clarifyKey` 变量在 Step 2 中已定义,这里需要确保 `clarifyKey` 的声明位置在两处都可访问。将 `const clarifyKey` 提到 `const text = ...` 之后即可。
- [ ] **Step 4: 验证构建**
Run: `cd packages/backend && npm run build`
Expected: 构建成功
- [ ] **Step 5: Commit**
```bash
git add packages/backend/src/modules/netaclaw/service/agent_channel.ts
git commit -m "feat(netaclaw): 微信渠道 clarify 纯文本降级 — pendingClarify Map + 编号映射"
```
---
### Task 7: 端到端验证
**Files:** 无新文件
- [ ] **Step 1: 前端构建验证**
Run: `cd packages/frontend && pnpm build`
Expected: 构建成功
- [ ] **Step 2: 后端构建验证**
Run: `cd packages/backend && npm run build`
Expected: 构建成功
- [ ] **Step 3: 最终 Commit如有修复**
```bash
git add -A
git commit -m "fix(agent): clarify frontend + WeChat degradation integration fixes"
```