361 lines
12 KiB
Markdown
361 lines
12 KiB
Markdown
|
|
# P3: 碰撞检测与诊断系统 实施计划
|
|||
|
|
|
|||
|
|
> **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:** 在 skill 扫描时收集诊断信息(名称碰撞、验证警告、环境缺失),通过 API 暴露,前端展示诊断横幅和 per-skill 诊断详情。
|
|||
|
|
|
|||
|
|
**Architecture:** `SkillLoaderService` 内部维护 `diagnostics: SkillDiagnostic[]` 数组,`scanSkills()` 时清空并重新收集。Admin controller 新增 `/diagnostics` 端点。前端 skills.vue 顶部新增诊断横幅,skill-detail.vue 新增诊断 tab。
|
|||
|
|
|
|||
|
|
**Tech Stack:** TypeORM, Midway.js, Element Plus
|
|||
|
|
|
|||
|
|
**Spec:** `docs/superpowers/specs/2026-04-27-skill-system-evolution-design.md` Section 6
|
|||
|
|
|
|||
|
|
**Depends on:** P0 (envSchema for ENV_NOT_CONFIGURED check), P1 Tasks 1-2 (SkillConfigService integrated into SkillLoaderService), P2 Task 1 (validateSkillName function). Execute these prerequisites first.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 1: 诊断数据结构和收集
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `packages/shared/types/skill.types.ts`
|
|||
|
|
- Modify: `packages/backend/src/modules/netaclaw/service/skill_loader.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 在 shared/types/skill.types.ts 新增 SkillDiagnostic 类型**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
export interface SkillDiagnostic {
|
|||
|
|
level: 'error' | 'warning' | 'info';
|
|||
|
|
code: string;
|
|||
|
|
skillName: string;
|
|||
|
|
message: string;
|
|||
|
|
path?: string;
|
|||
|
|
detail?: Record<string, unknown>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 在 SkillLoaderService 中新增诊断收集**
|
|||
|
|
|
|||
|
|
在 class 内部新增:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
private diagnostics: SkillDiagnostic[] = [];
|
|||
|
|
|
|||
|
|
getDiagnostics(level?: string): SkillDiagnostic[] {
|
|||
|
|
if (level) return this.diagnostics.filter(d => d.level === level);
|
|||
|
|
return [...this.diagnostics];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private addDiagnostic(diag: SkillDiagnostic): void {
|
|||
|
|
this.diagnostics.push(diag);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 在 scanSkills 开头清空诊断**
|
|||
|
|
|
|||
|
|
在 `scanSkills()` 方法的 `this.skills.clear()` 之后添加:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
this.diagnostics = [];
|
|||
|
|
const realPathSet = new Set<string>();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3.5: 在 scanSkills 循环中添加 symlink 去重(Spec 10.11)**
|
|||
|
|
|
|||
|
|
在解析每个 skill 目录时,碰撞检测之前添加:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// symlink 去重
|
|||
|
|
const skillDirPath = path.join(this.skillsDir, entry.name);
|
|||
|
|
let realPath: string;
|
|||
|
|
try {
|
|||
|
|
realPath = await fs.realpath(skillDirPath);
|
|||
|
|
} catch {
|
|||
|
|
realPath = skillDirPath;
|
|||
|
|
}
|
|||
|
|
if (realPathSet.has(realPath)) continue; // 同一物理目录,静默跳过
|
|||
|
|
realPathSet.add(realPath);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 在 scanSkills 循环中添加诊断收集**
|
|||
|
|
|
|||
|
|
在解析每个 skill 的过程中,添加以下检查:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 名称验证
|
|||
|
|
const nameErrors = validateSkillName(skill.name, entry.name);
|
|||
|
|
for (const err of nameErrors) {
|
|||
|
|
if (err.includes('does not match')) {
|
|||
|
|
this.addDiagnostic({ level: 'warning', code: 'NAME_MISMATCH', skillName: skill.name, message: err, path: skillMdPath });
|
|||
|
|
} else {
|
|||
|
|
this.addDiagnostic({ level: 'warning', code: 'NAME_INVALID', skillName: skill.name, message: err, path: skillMdPath });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 碰撞检测
|
|||
|
|
if (this.skills.has(skill.name)) {
|
|||
|
|
const existing = this.skills.get(skill.name)!;
|
|||
|
|
this.addDiagnostic({
|
|||
|
|
level: 'warning', code: 'NAME_COLLISION', skillName: skill.name,
|
|||
|
|
message: `Name collision: "${skill.name}" already loaded`,
|
|||
|
|
detail: { winnerPath: path.join(this.skillsDir, existing.name), loserPath: skillMdPath },
|
|||
|
|
});
|
|||
|
|
continue; // 跳过,保留先发现的
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// description 检查
|
|||
|
|
if (!skill.description) {
|
|||
|
|
this.addDiagnostic({ level: 'error', code: 'DESC_MISSING', skillName: skill.name, message: 'Missing description', path: skillMdPath });
|
|||
|
|
continue; // 不加载
|
|||
|
|
}
|
|||
|
|
if (skill.description.length > 1024) {
|
|||
|
|
this.addDiagnostic({ level: 'warning', code: 'DESC_TOO_LONG', skillName: skill.name, message: `Description exceeds 1024 chars (${skill.description.length})`, path: skillMdPath });
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 在 scanSkills 末尾添加 config 相关诊断**
|
|||
|
|
|
|||
|
|
在所有 skill 加载完成后,遍历检查 compute skill 的环境就绪状态:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 扫描完成后检查 compute skill 状态
|
|||
|
|
for (const [name, _skill] of this.skills) {
|
|||
|
|
const config = this.skillConfig.getConfig(name);
|
|||
|
|
if (!config) {
|
|||
|
|
// 检查是否有解析错误(区分"无 config 文件"和"config 解析失败")
|
|||
|
|
const parseErr = this.skillConfig.getParseError(name);
|
|||
|
|
if (parseErr) {
|
|||
|
|
this.addDiagnostic({ level: 'error', code: 'CONFIG_PARSE_ERROR', skillName: name, message: `skill.config.yaml parse failed: ${parseErr}` });
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// config 解析错误已在 loadConfig 中处理
|
|||
|
|
|
|||
|
|
// runtime 可用性检查
|
|||
|
|
if (config.runtime === 'python') {
|
|||
|
|
const venvPath = path.join(this.skillsDir, name, '.venv');
|
|||
|
|
try { await fs.access(venvPath); } catch {
|
|||
|
|
this.addDiagnostic({ level: 'warning', code: 'VENV_MISSING', skillName: name, message: 'Python .venv not found, run dependency install' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (config.runtime) {
|
|||
|
|
const runtimeChecks: Record<string, string> = { python: 'python3 --version', node: 'node --version', dotnet: 'dotnet --version' };
|
|||
|
|
const checkCmd = runtimeChecks[config.runtime];
|
|||
|
|
if (checkCmd) {
|
|||
|
|
try { await execAsync(checkCmd, { timeout: 5000 }); } catch {
|
|||
|
|
this.addDiagnostic({ level: 'error', code: 'RUNTIME_UNAVAILABLE', skillName: name, message: `Runtime "${config.runtime}" not available on this system` });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 系统依赖检查
|
|||
|
|
if (config.dependencies?.system) {
|
|||
|
|
for (const dep of config.dependencies.system) {
|
|||
|
|
try { await execAsync(dep.check, { timeout: 5000 }); } catch {
|
|||
|
|
this.addDiagnostic({ level: 'warning', code: 'SYSTEM_DEP_MISSING', skillName: name, message: `System dependency "${dep.name}" not found (check: ${dep.check})` });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// fingerprint 变更检查
|
|||
|
|
const lockfile = await this.registry?.readLockfile?.();
|
|||
|
|
if (lockfile?.skills?.[name]) {
|
|||
|
|
const currentFp = await this.registry.computeFingerprint(path.join(this.skillsDir, name));
|
|||
|
|
if (currentFp !== lockfile.skills[name].fingerprint) {
|
|||
|
|
this.addDiagnostic({ level: 'info', code: 'FINGERPRINT_CHANGED', skillName: name, message: 'SKILL.md content changed since last install/update' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// env 配置检查
|
|||
|
|
if (config.env) {
|
|||
|
|
const entity = await this.skillRepo.findOneBy({ name });
|
|||
|
|
const hasSecrets = !!entity?.secrets;
|
|||
|
|
for (const envItem of config.env) {
|
|||
|
|
if (envItem.required && !hasSecrets) {
|
|||
|
|
this.addDiagnostic({ level: 'warning', code: 'ENV_NOT_CONFIGURED', skillName: name, message: `Required env "${envItem.name}" not configured` });
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/shared/types/skill.types.ts packages/backend/src/modules/netaclaw/service/skill_loader.ts
|
|||
|
|
git commit -m "feat(skill): add diagnostic collection during skill scanning"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 2: Admin Controller 诊断端点
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `packages/backend/src/modules/netaclaw/controller/admin/skill.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 新增 diagnostics 端点**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
@Get('/diagnostics', { summary: '获取 skill 诊断信息' })
|
|||
|
|
async diagnostics(@Query('level') level?: string) {
|
|||
|
|
const diags = this.skillLoader.getDiagnostics(level);
|
|||
|
|
return this.ok(diags);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/backend/src/modules/netaclaw/controller/admin/skill.ts
|
|||
|
|
git commit -m "feat(skill): add diagnostics admin endpoint"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 3: 前端诊断横幅
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `packages/frontend/src/modules/agent/views/skills.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 在 skills.vue 中新增诊断数据加载**
|
|||
|
|
|
|||
|
|
在 script 区域添加:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
const diagnostics = ref<any[]>([]);
|
|||
|
|
const errorCount = computed(() => diagnostics.value.filter(d => d.level === 'error').length);
|
|||
|
|
const warningCount = computed(() => diagnostics.value.filter(d => d.level === 'warning').length);
|
|||
|
|
const showDiagDetails = ref(false);
|
|||
|
|
|
|||
|
|
async function loadDiagnostics() {
|
|||
|
|
try {
|
|||
|
|
const res = await service.request({ url: '/admin/netaclaw/skill/diagnostics' });
|
|||
|
|
diagnostics.value = res || [];
|
|||
|
|
} catch { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
在 `refresh()` 函数末尾添加 `await loadDiagnostics();`
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 在 template 中 skill-header 之后添加诊断横幅**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- 诊断横幅 -->
|
|||
|
|
<div v-if="errorCount > 0" class="diag-banner diag-error" @click="showDiagDetails = !showDiagDetails">
|
|||
|
|
⚠ {{ errorCount }} 个错误需要处理
|
|||
|
|
</div>
|
|||
|
|
<div v-else-if="warningCount > 0" class="diag-banner diag-warning" @click="showDiagDetails = !showDiagDetails">
|
|||
|
|
{{ warningCount }} 个警告
|
|||
|
|
</div>
|
|||
|
|
<div v-if="showDiagDetails && diagnostics.length > 0" class="diag-list">
|
|||
|
|
<div v-for="(d, i) in diagnostics" :key="i" class="diag-item" :class="'diag-' + d.level">
|
|||
|
|
<el-tag :type="d.level === 'error' ? 'danger' : d.level === 'warning' ? 'warning' : 'info'" size="small">
|
|||
|
|
{{ d.code }}
|
|||
|
|
</el-tag>
|
|||
|
|
<span class="diag-skill">{{ d.skillName }}</span>
|
|||
|
|
<span>{{ d.message }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 添加样式**
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
.diag-banner { padding: 8px 16px; border-radius: 6px; margin-bottom: 16px; cursor: pointer; font-size: 13px; }
|
|||
|
|
.diag-error { background: #fef0f0; color: #f56c6c; border: 1px solid #fbc4c4; }
|
|||
|
|
.diag-warning { background: #fdf6ec; color: #e6a23c; border: 1px solid #f5dab1; }
|
|||
|
|
.diag-list { background: #f5f7fa; border-radius: 6px; padding: 12px; margin-bottom: 16px; max-height: 200px; overflow-y: auto; }
|
|||
|
|
.diag-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
|
|||
|
|
.diag-skill { font-weight: 600; color: #303133; }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/frontend/src/modules/agent/views/skills.vue
|
|||
|
|
git commit -m "feat(skill): add diagnostic banner to skills management page"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 4: skill-detail 诊断 Tab
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `packages/frontend/src/modules/agent/components/skill-detail.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 在 el-tabs 中新增诊断 tab**
|
|||
|
|
|
|||
|
|
在 P0 Task 6 已创建的 tab 结构中,添加第三个 tab:
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<el-tab-pane label="诊断" name="diagnostics">
|
|||
|
|
<div v-if="skillDiagnostics.length === 0" class="empty-hint">无诊断信息</div>
|
|||
|
|
<div v-else>
|
|||
|
|
<div v-for="(d, i) in skillDiagnostics" :key="i" class="diag-item">
|
|||
|
|
<el-tag :type="d.level === 'error' ? 'danger' : d.level === 'warning' ? 'warning' : 'info'" size="small">
|
|||
|
|
{{ d.code }}
|
|||
|
|
</el-tag>
|
|||
|
|
<span>{{ d.message }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</el-tab-pane>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 新增诊断数据加载逻辑**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
const skillDiagnostics = ref<any[]>([]);
|
|||
|
|
|
|||
|
|
async function loadSkillDiagnostics() {
|
|||
|
|
if (!props.skill?.name) return;
|
|||
|
|
try {
|
|||
|
|
const res = await service.request({ url: '/admin/netaclaw/skill/diagnostics' });
|
|||
|
|
skillDiagnostics.value = (res || []).filter((d: any) => d.skillName === props.skill.name);
|
|||
|
|
} catch { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 在 watch skill 的回调中添加
|
|||
|
|
loadSkillDiagnostics();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/frontend/src/modules/agent/components/skill-detail.vue
|
|||
|
|
git commit -m "feat(skill): add diagnostics tab to skill-detail drawer"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 5: skills.vue 卡片 type 标签
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `packages/frontend/src/modules/agent/views/skills.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 在 metas API 返回中包含 skillTypeV2**
|
|||
|
|
|
|||
|
|
在 `SkillLoaderService.getSkillMetas()` 中,返回对象添加:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
skillTypeV2: this.skillConfig.classify(fs.name),
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 在 skill 卡片的 tags 区域添加 type 标签**
|
|||
|
|
|
|||
|
|
在 `skill-tags` div 中,现有标签之前添加:
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<el-tag size="small" :type="skill.skillTypeV2 === 'compute-entry' ? 'success' : skill.skillTypeV2 === 'compute-toolkit' ? '' : 'info'">
|
|||
|
|
{{ skill.skillTypeV2 || 'prompt' }}
|
|||
|
|
</el-tag>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add packages/backend/src/modules/netaclaw/service/skill_loader.ts \
|
|||
|
|
packages/frontend/src/modules/agent/views/skills.vue
|
|||
|
|
git commit -m "feat(skill): show skill classification type tag on cards"
|
|||
|
|
```
|