GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-14-tool-visibility-classification.md
2026-05-20 21:39:12 +08:00

13 KiB
Raw Blame History

工具可见性分类机制 实施计划

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.

Goal: 在工具定义层引入 visibility 属性,从源头区分内部工具/普通工具/Skill消除前端重复展示和 filter hack。

Architecture: 后端工具类型新增 visibility 字段,执行引擎根据该字段决定是否触发 UI 回调。前端移除 todo 过滤 hack新建 tool-card.vue 组件区分 skill 和工具的展示。

Tech Stack: TypeScript, Midway.js, Vue 3, Element Plus, Socket.IO

Spec: docs/superpowers/specs/2026-04-14-tool-visibility-classification-design.md


Task 1: 后端 — 工具类型定义新增 visibility 字段

Files:

  • Modify: packages/backend/src/modules/netaclaw/tools/common.ts:5-19

  • Step 1: 在 common.ts 中新增 ToolVisibility 类型和 visibility 字段

AgentTool 类型定义之前,新增类型:

export type ToolVisibility = 'internal' | 'tool' | 'skill';

AgentToolWithMeta 类型中新增 visibility 可选字段:

export type AgentToolWithMeta<TParams extends TSchema, TResult> =
  AgentTool<TParams, TResult> & {
    ownerOnly?: boolean;
    displaySummary?: string;
    visibility?: ToolVisibility;
  };
  • Step 2: 验证后端编译通过

Run: cd packages/backend && npx tsc --noEmit 2>&1 | head -20 Expected: 无新增错误(已有错误可忽略)

  • Step 3: Commit
git add packages/backend/src/modules/netaclaw/tools/common.ts
git commit -m "feat(netaclaw): 工具类型新增 visibility 字段 (internal/tool/skill)"

Task 2: 后端 — 执行引擎根据 visibility 过滤回调

Files:

  • Modify: packages/backend/src/modules/netaclaw/runtime/attempt.ts:59-78

  • Step 1: 修改 attempt.ts 工具执行循环

将当前的工具执行循环(第 59-78 行)修改为根据 visibility 决定是否触发回调:

for (const tc of response.toolCalls) {
  toolCallCount++;
  const tool = tools.find(t => t.name === tc.name);
  const visibility = tool?.visibility ?? 'tool';

  // 仅非 internal 工具触发 UI 回调
  if (visibility !== 'internal') {
    onToolCall?.(tc.name, JSON.parse(tc.arguments));
  }

  let resultText: string;

  if (!tool) {
    resultText = `错误: 工具 "${tc.name}" 不存在`;
  } else {
    try {
      const result = await tool.execute(tc.id, JSON.parse(tc.arguments));
      resultText = typeof result === 'string' ? result : JSON.stringify(result);
    } catch (err: any) {
      resultText = `工具执行错误: ${err.message}`;
    }
  }

  // 仅非 internal 工具触发 UI 回调
  if (visibility !== 'internal') {
    onToolResult?.(tc.name, resultText);
  }
  conversation.push({ role: 'tool', content: resultText, toolCallId: tc.id });
}

关键点:

  • tool 变量的查找提前到回调判断之前

  • visibility 从 tool 对象读取,默认 'tool'

  • conversation.push 不受影响LLM 仍需看到工具返回值)

  • Step 2: 验证后端编译通过

Run: cd packages/backend && npx tsc --noEmit 2>&1 | head -20 Expected: 无新增错误

  • Step 3: Commit
git add packages/backend/src/modules/netaclaw/runtime/attempt.ts
git commit -m "feat(netaclaw): 执行引擎根据 visibility 过滤 tool_call/tool_result 回调"

Task 3: 后端 — todo 工具标记为 internal

Files:

  • Modify: packages/backend/src/modules/netaclaw/runtime/agent.ts:70-79

  • Step 1: 在 agent.ts 中为 todoTool 设置 visibility: 'internal'

将第 70-79 行的 todoTool 定义修改为:

const todoTool: AnyAgentTool = {
  ...todoToolSchema,
  label: '任务规划',
  visibility: 'internal',
  execute: async (_id: string, args: any) => {
    const result = executeTodo(todoStore, args);
    params.onTodoUpdate?.(result);
    return JSON.stringify(result);
  },
};

唯一改动:新增 visibility: 'internal' 一行。

  • Step 2: 验证后端编译通过

Run: cd packages/backend && npx tsc --noEmit 2>&1 | head -20 Expected: 无新增错误

  • Step 3: Commit
git add packages/backend/src/modules/netaclaw/runtime/agent.ts
git commit -m "feat(netaclaw): todo 工具标记为 internal不再推送 tool_call/tool_result"

Task 4: 前端 — SkillProgress 类型新增 source 字段

Files:

  • Modify: packages/frontend/src/modules/agent/types/index.d.ts:151-173

  • Step 1: 在 SkillProgress 接口中新增 source 字段

SkillProgress 接口中新增 source 可选字段(第 152 行之后):

export interface SkillProgress {
  name: string;
  label?: string;
  status: 'running' | 'done' | 'error';
  /** 来源类型skill=Skill技能, tool=普通工具调用 */
  source?: 'skill' | 'tool';
  input?: any;
  result?: any;
  step?: string;
  percent?: number;
  detail?: string;
  current?: number;
  total?: number;
  taskId?: string;
  images?: string[];
  tokens?: { input: number; output: number; total: number; apiCalls?: number };
}
  • Step 2: Commit
git add packages/frontend/src/modules/agent/types/index.d.ts
git commit -m "feat(agent): SkillProgress 类型新增 source 字段区分 skill/tool"

Task 5: 前端 — Store 层标记 source 字段

Files:

  • Modify: packages/frontend/src/modules/agent/store/chat.ts:264-316

  • Step 1: skill_start 事件处理中标记 source: 'skill'

修改第 271-275 行,在 push 时添加 source: 'skill'

case 'skill_start': {
  const startName = event.name;
  activeSkill.value = startName;
  const existingDone = skillProgress.value.find(s => s.name === startName && s.status === 'done');
  const existingRunning = skillProgress.value.find(s => s.name === startName && s.status === 'running');
  if (!existingDone && !existingRunning) {
    skillProgress.value.push({
      name: startName,
      label: event.label || startName,
      status: 'running',
      source: 'skill',
    });
  }
  break;
}
  • Step 2: tool_call 事件处理中标记 source: 'tool'

修改第 308-314 行,在 push 时添加 source: 'tool'

case 'tool_call': {
  const toolName = event.toolName || event.name || 'unknown';
  activeSkill.value = toolName;
  const existingRunning = skillProgress.value.find(s => s.name === toolName && s.status === 'running');
  if (!existingRunning) {
    skillProgress.value.push({
      name: toolName,
      label: toolName,
      status: 'running',
      source: 'tool',
    });
  }
  break;
}
  • Step 3: done 事件序列化中加入 source 字段

handleWSEventdone 分支中(约第 383 行),修改 skillExecutions 序列化,加入 source

lastAssistant.metadata.skillExecutions = skillProgress.value.map(sp => ({
  name: sp.name, label: sp.label, status: sp.status, result: sp.result, tokens: sp.tokens, source: sp.source
}));

唯一改动:在 map 对象中新增 source: sp.source

  • Step 4: Commit
git add packages/frontend/src/modules/agent/store/chat.ts
git commit -m "feat(agent): Store 层 skill/tool 事件标记 source 字段,序列化保留 source"

Task 6: 前端 — 新建 tool-card.vue 组件

Files:

  • Create: packages/frontend/src/modules/agent/components/tool-card.vue

  • Step 1: 创建 tool-card.vue

<template>
  <div class="tool-card" :class="[`tool-card--${status}`]">
    <div class="tool-card__header">
      <el-icon :class="{ 'is-rotating': status === 'running' }">
        <Loading v-if="status === 'running'" />
        <CircleCheck v-else-if="status === 'done'" />
        <CircleClose v-else />
      </el-icon>
      <span class="tool-card__name">{{ label || name }}</span>
      <el-tag v-if="status === 'done'" size="small" type="info">完成</el-tag>
      <el-tag v-else-if="status === 'error'" size="small" type="danger">失败</el-tag>
    </div>

    <transition name="fade">
      <div v-if="status === 'done' && result" class="tool-card__result">
        <el-button text size="small" @click="showResult = !showResult">
          {{ showResult ? '收起' : '查看结果' }}
        </el-button>
      </div>
    </transition>

    <el-collapse-transition>
      <div v-if="showResult && result" class="tool-card__result-content">
        <pre class="tool-card__result-pre">{{ typeof result === 'string' ? result : JSON.stringify(result, null, 2) }}</pre>
      </div>
    </el-collapse-transition>
  </div>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import { Loading, CircleCheck, CircleClose } from '@element-plus/icons-vue';

defineProps<{
  name: string;
  label?: string;
  status: 'running' | 'done' | 'error';
  result?: any;
}>();

const showResult = ref(false);
</script>

样式部分(与 skill-card 类似但更简洁,无进度条区域):

<style lang="scss" scoped>
.tool-card {
  width: min(100%, var(--chat-content-max-width, 760px));
  margin: 6px 0 8px;
  padding: 8px 12px;
  border-radius: 10px;
  border: 1px solid color-mix(in srgb, var(--tool-border-color) 18%, transparent);
  border-left-width: 3px;
  background: var(--el-bg-color);
  font-size: 13px;
  box-sizing: border-box;
  transition: all 0.24s ease;
}

.tool-card--running { --tool-border-color: var(--el-color-info); }
.tool-card--done { --tool-border-color: var(--el-color-info); }
.tool-card--error { --tool-border-color: var(--el-color-danger); }

.tool-card__header {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}

.tool-card__name {
  flex: 1;
  font-weight: 500;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--el-text-color-secondary);
}

.is-rotating {
  animation: rotating 1.5s linear infinite;
}

@keyframes rotating {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.tool-card__result { margin-top: 6px; }

.tool-card__result-content { margin-top: 6px; }

.tool-card__result-pre {
  margin: 0;
  padding: 8px 10px;
  background: var(--el-fill-color-lighter);
  border: 1px solid var(--el-border-color-extra-light);
  border-radius: 8px;
  font-size: 12px;
  line-height: 1.5;
  max-height: 160px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-all;
}

:deep(.el-tag) {
  height: 18px;
  padding: 0 6px;
  border-radius: 999px;
  font-size: 11px;
}

:deep(.el-button.is-text) {
  padding: 0;
  font-size: 12px;
}

.fade-enter-active,
.fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>
  • Step 2: Commit
git add packages/frontend/src/modules/agent/components/tool-card.vue
git commit -m "feat(agent): 新建 tool-card.vue 工具调用卡片组件"

Task 7: 前端 — chat.vue 移除 hack 并根据 source 选择组件

Files:

  • Modify: packages/frontend/src/modules/agent/views/chat.vue:56-59 (模板)

  • Modify: packages/frontend/src/modules/agent/views/chat.vue:341-343 (计算属性)

  • Step 1: 在 chat.vue 的 script 中导入 tool-card 组件

在已有的 import 区域skill-card 导入附近)添加:

import ToolCard from '../components/tool-card.vue';
  • Step 2: 移除 visibleSkillProgress 计算属性

删除第 341-343 行:

// 删除这段
const visibleSkillProgress = computed(() => {
  return skillProgress.value.filter(sp => sp.name !== 'todo');
});
  • Step 3: 修改模板中的 Skill 卡片渲染区域

将第 56-59 行:

<!-- Skill 执行卡片替代 skill-indicator -->
<div class="skill-card-row" v-for="sp in visibleSkillProgress" :key="sp.name">
  <skill-card v-bind="sp" />
</div>

替换为:

<!-- Skill / 工具执行卡片 -->
<div class="skill-card-row" v-for="sp in skillProgress" :key="sp.name">
  <skill-card v-if="sp.source === 'skill'" v-bind="sp" />
  <tool-card v-else v-bind="sp" />
</div>
  • Step 4: 验证前端编译通过

Run: cd packages/frontend && npx vue-tsc --noEmit 2>&1 | head -20 Expected: 无新增错误

  • Step 5: Commit
git add packages/frontend/src/modules/agent/views/chat.vue
git commit -m "feat(agent): chat.vue 移除 todo filter hack根据 source 区分 skill-card/tool-card"

Task 8: 端到端验证

Files: 无新增改动

  • Step 1: 启动后端验证编译

Run: cd packages/backend && npx tsc --noEmit 2>&1 | head -30 Expected: 无新增错误

  • Step 2: 启动前端验证编译

Run: cd packages/frontend && npx vue-tsc --noEmit 2>&1 | head -30 Expected: 无新增错误

  • Step 3: 功能验证清单

手动测试(需启动 pnpm dev

  1. 发送一条需要任务规划的消息(如"帮我分析这个项目的架构列出3个改进点"
  2. 验证 todo-card 正常显示并实时更新(划掉已完成任务)
  3. 验证对话流中不再出现 todo 的"工具完成"卡片
  4. 如果 Agent 调用了其他工具(如搜索),验证 tool-card 正常显示
  5. 如果 Agent 调用了 Skill验证 skill-card 正常显示(带进度条)
  6. 切换会话后,验证历史消息中的 skill/tool 卡片正确恢复
  • Step 4: 最终 Commit如有修复
git add -A
git commit -m "fix(agent): 端到端验证修复"