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

17 KiB
Raw Permalink Blame History

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'

// 旧:
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.tshandleWSEvent 函数中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.tssetThinkLevel 函数之后(第 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 分流),但为安全起见,确保 hasVisibleBodyrole='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.tshandleInboundMessage 方法中,在 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"