17 KiB
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 thesimplifyskill 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':
// 旧:
role: 'user' | 'assistant' | 'tool' | 'system';
// 新:
role: 'user' | 'assistant' | 'tool' | 'system' | 'clarify';
- Step 2: 扩展 WSClientMessage.type 联合类型并追加字段
在 types/index.d.ts 第 131-136 行,修改 WSClientMessage:
// 旧:
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':
// 旧:
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 之后)追加:
// === 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
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 之后)插入:
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 行后)追加:
/**
* 发送 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:
return {
// ... 现有导出 ...
init,
sendClarifyResponse,
};
- Step 4: Commit
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:
<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 的卡片风格):
<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
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 附近)追加:
import ClarifyCard from '../components/clarify-card.vue';
- Step 2: chat.vue — 在消息列表中渲染 clarify 卡片
在 views/chat.vue 模板的消息列表中,message-item 循环(第 40-44 行)需要区分 clarify 消息。将:
<message-item
v-for="(msg, index) in precedingMessages"
:key="index"
:message="msg"
/>
改为:
<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 行附近):
const {
sendMessage,
stopGeneration,
loadSessions,
loadAgents,
selectAgent,
newSession,
switchSession,
deleteSession,
deleteAllSessions,
onToken,
init: initChat,
setThinkLevel,
sendClarifyResponse,
} = chatStore;
然后在 handleSend 函数之前添加:
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:
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
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:
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:
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 区域追加:
import { clarifyTool } from '../tools/builtin/clarify.js';
在第 41 行 defaultTools 数组中追加 clarifyTool:
private readonly defaultTools: AnyAgentTool[] = [bashTool, readFileTool, writeFileTool, listDirTool, patchTool, clarifyTool];
- Step 4: 验证构建
Run: cd packages/backend && npm run build
Expected: 构建成功
- Step 5: Commit
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 之后追加:
/** 微信 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 检查:
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:
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
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(如有修复)
git add -A
git commit -m "fix(agent): clarify frontend + WeChat degradation integration fixes"