624 lines
17 KiB
Markdown
624 lines
17 KiB
Markdown
# 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"
|
||
```
|