494 lines
13 KiB
Markdown
494 lines
13 KiB
Markdown
# 工具可见性分类机制 实施计划
|
||
|
||
> **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` 类型定义之前,新增类型:
|
||
|
||
```typescript
|
||
export type ToolVisibility = 'internal' | 'tool' | 'skill';
|
||
```
|
||
|
||
在 `AgentToolWithMeta` 类型中新增 `visibility` 可选字段:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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` 决定是否触发回调:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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 定义修改为:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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 行之后):
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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'`:
|
||
|
||
```typescript
|
||
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'`:
|
||
|
||
```typescript
|
||
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 字段**
|
||
|
||
在 `handleWSEvent` 的 `done` 分支中(约第 383 行),修改 `skillExecutions` 序列化,加入 `source`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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 类似但更简洁,无进度条区域):
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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 导入附近)添加:
|
||
|
||
```typescript
|
||
import ToolCard from '../components/tool-card.vue';
|
||
```
|
||
|
||
- [ ] **Step 2: 移除 visibleSkillProgress 计算属性**
|
||
|
||
删除第 341-343 行:
|
||
|
||
```typescript
|
||
// 删除这段
|
||
const visibleSkillProgress = computed(() => {
|
||
return skillProgress.value.filter(sp => sp.name !== 'todo');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 修改模板中的 Skill 卡片渲染区域**
|
||
|
||
将第 56-59 行:
|
||
|
||
```vue
|
||
<!-- Skill 执行卡片(替代 skill-indicator) -->
|
||
<div class="skill-card-row" v-for="sp in visibleSkillProgress" :key="sp.name">
|
||
<skill-card v-bind="sp" />
|
||
</div>
|
||
```
|
||
|
||
替换为:
|
||
|
||
```vue
|
||
<!-- 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**
|
||
|
||
```bash
|
||
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(如有修复)**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix(agent): 端到端验证修复"
|
||
```
|