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

624 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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"
```