GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-13-skill-system-migration.md
2026-05-20 21:39:12 +08:00

1763 lines
56 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Skill 系统迁移实施计划
> **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:** 将 Neta skill 系统从文档占位状态迁移为完整可用的 prompt-based skill 系统,对齐 OpenClaw + Hermes 架构,支持 GitHub 安装、Node/Python 依赖、渐进式披露、条件激活、前后端完整实现。
**Architecture:** 后端三层服务SkillLoader 加载/解析 + SkillInstaller 安装/更新 + SkillRegistry 注册/追踪两个内置工具read_skill + skill_manageController 改为 @CoolController 混合模式。前端改用 Cool Admin service 代理,重写 skills.vue 和 agent-edit.vue Skill Tab。
**Tech Stack:** Midway.js + TypeORM + js-yaml + child_process(git/npm/uv) | Vue 3 + Element Plus + Cool Admin service 代理 + Socket.IO
**Spec:** `docs/superpowers/specs/2026-04-13-skill-system-migration-design.md`
---
## File Structure
### 后端 — 新增文件
- `packages/backend/src/modules/netaclaw/service/skill_installer.ts` — GitHub clone + 依赖安装
- `packages/backend/src/modules/netaclaw/service/skill_registry.ts` — lockfile + 指纹 + 来源追踪
- `packages/backend/src/modules/netaclaw/tools/builtin/read_skill.ts` — Agent 按需读取 SKILL.md
- `packages/backend/src/modules/netaclaw/tools/builtin/skill_manage.ts` — Agent 自主 CRUD skill
- `packages/backend/src/modules/netaclaw/service/skill_context.ts` — buildSkillContext 公共函数
### 后端 — 改造文件
- `packages/backend/src/modules/netaclaw/entity/skill.ts` — 移除 icon/category/config新增 6 字段
- `packages/backend/src/modules/netaclaw/service/skill_loader.ts` — 完全重写
- `packages/backend/src/modules/netaclaw/controller/skill.ts`@CoolController + 自定义接口
- `packages/backend/src/modules/netaclaw/gateway/server.ts` — 统一 skill 加载逻辑
- `packages/backend/src/modules/netaclaw/controller/chat.ts` — 统一 skill 加载逻辑
- `packages/backend/package.json` — 新增 js-yaml 依赖
### 前端 — 删除文件
- `packages/frontend/src/modules/agent/components/skill-config.vue`
- `packages/frontend/src/modules/agent/components/skill-prompts.vue`
- `packages/frontend/src/modules/agent/components/skill-model.vue`
### 前端 — 改造文件
- `packages/frontend/src/modules/agent/views/skills.vue` — 重写
- `packages/frontend/src/modules/agent/components/skill-detail.vue` — 重写为新详情抽屉
- `packages/frontend/src/modules/agent/views/agent-edit.vue` — Skill Tab 改造
### 数据 — 清理
- 数据库 `netaclaw_skill` 表清空老数据
- `skills/` 目录清空老 skill保留目录
---
## Phase 1: 清理与基础
### Task 1: 清理老 skill 数据和依赖准备
**Files:**
- Modify: `packages/backend/package.json`
- Clean: `skills/` 目录
- Clean: 数据库 `netaclaw_skill`
- [ ] **Step 1: 安装 js-yaml 依赖**
```bash
cd packages/backend && pnpm add js-yaml && pnpm add -D @types/js-yaml
```
- [ ] **Step 2: 清空 skills 目录下的老 skill**
```bash
cd packages/backend
rm -rf skills/hello-world
# 保留 skills/ 目录本身
mkdir -p skills
```
- [ ] **Step 3: 清空数据库老数据**
```sql
TRUNCATE TABLE netaclaw_skill;
```
- [ ] **Step 4: Commit**
```bash
git add packages/backend/package.json packages/backend/pnpm-lock.yaml
git commit -m "chore: add js-yaml dep, clean old skill data"
```
---
### Task 2: 改造 Skill Entity
**Files:**
- Modify: `packages/backend/src/modules/netaclaw/entity/skill.ts`
- [ ] **Step 1: 重写 skill.ts entity**
`packages/backend/src/modules/netaclaw/entity/skill.ts` 完整替换为:
```typescript
import { BaseEntity } from '../../base/entity/base.js';
import { Column, Entity, Index } from 'typeorm';
/**
* NetaClaw Skill — prompt-based skill 元数据与状态管理
*/
@Entity('netaclaw_skill')
export class NetaClawSkillEntity extends BaseEntity {
@Column({ comment: 'Skill名称', length: 100, unique: true })
name: string;
@Column({ comment: '显示名称', length: 200 })
label: string;
@Column({ comment: '描述', type: 'text', nullable: true })
description: string;
@Column({ comment: 'Skill类型: compute/llm/multimodal', length: 20, nullable: true })
skillType: string;
@Column({ type: 'json', comment: '标签', nullable: true })
tags: string[];
@Index()
@Column({ comment: '状态: 0=禁用 1=启用', default: 1 })
status: number;
@Column({ comment: '版本号', length: 20, nullable: true })
version: string;
@Column({ comment: '来源: local/github', length: 20, nullable: true })
source: string;
@Column({ comment: 'GitHub URL', length: 500, nullable: true })
sourceUrl: string;
@Column({ type: 'json', comment: '安装规格 (OpenClaw SkillInstallSpec 兼容)', nullable: true })
installSpec: Record<string, unknown>[];
@Column({ type: 'json', comment: '完整 frontmatter 元数据', nullable: true })
metadata: Record<string, unknown>;
@Column({ comment: '安装时间', type: 'datetime', nullable: true })
installedAt: Date;
@Column({ comment: 'SHA256 内容指纹', length: 64, nullable: true })
fingerprint: string;
}
```
- [ ] **Step 2: 验证 entity 注册**
确认 `packages/backend/src/entities.ts` 中已有 `NetaClawSkillEntity` 的 import 和注册。无需改动。
- [ ] **Step 3: 启动后端验证表结构自动同步**
```bash
cd packages/backend && pnpm dev
```
检查日志无报错,数据库 `netaclaw_skill` 表字段已更新(老字段 icon/category/config 被移除,新字段 source/sourceUrl/installSpec/metadata/installedAt/fingerprint 已添加)。
- [ ] **Step 4: Commit**
```bash
git add packages/backend/src/modules/netaclaw/entity/skill.ts
git commit -m "refactor: update skill entity - remove old fields, add source/metadata/fingerprint"
```
---
## Phase 2: 后端服务层
### Task 3: 重写 SkillLoaderService
**Files:**
- Modify: `packages/backend/src/modules/netaclaw/service/skill_loader.ts`
- [ ] **Step 1: 完整重写 skill_loader.ts**
`packages/backend/src/modules/netaclaw/service/skill_loader.ts` 完整替换为:
```typescript
import { Provide, Scope, ScopeEnum, Logger, Init } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { NetaClawSkillEntity } from '../entity/skill.js';
export interface SkillMeta {
name: string;
description: string;
version?: string;
metadata?: Record<string, unknown>;
content: string;
}
export interface SkillEntry {
meta: SkillMeta;
dbRecord?: NetaClawSkillEntity;
}
@Provide()
@Scope(ScopeEnum.Singleton)
export class SkillLoaderService {
@Logger()
logger: ILogger;
private skills: Map<string, SkillMeta> = new Map();
private skillsDir: string;
@InjectEntityModel(NetaClawSkillEntity)
skillRepo: Repository<NetaClawSkillEntity>;
@Init()
async init() {
this.skillsDir = path.resolve(process.cwd(), 'skills');
await this.scanSkills();
}
private static readonly MAX_SKILLS = 200;
private static readonly MAX_SKILL_FILE_BYTES = 256 * 1024; // 256KB
private static readonly MAX_SKILLS_PROMPT_CHARS = 30_000;
/** 扫描 skills/ 目录,解析所有 SKILL.md */
async scanSkills(): Promise<void> {
this.skills.clear();
try {
const entries = await fs.readdir(this.skillsDir, { withFileTypes: true });
let loaded = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (loaded >= SkillLoaderService.MAX_SKILLS) {
this.logger.warn('[SkillLoader] 已达 %d 个 Skill 上限,跳过剩余', SkillLoaderService.MAX_SKILLS);
break;
}
const skillMdPath = path.join(this.skillsDir, entry.name, 'SKILL.md');
try {
const raw = await fs.readFile(skillMdPath, 'utf-8');
if (Buffer.byteLength(raw) > SkillLoaderService.MAX_SKILL_FILE_BYTES) {
this.logger.warn('[SkillLoader] SKILL.md 超过 256KB 限制: %s', entry.name);
continue;
}
const skill = this.parseSkillMd(raw);
if (skill) {
this.skills.set(skill.name, skill);
loaded++;
this.logger.info('[SkillLoader] 已加载 Skill: %s', skill.name);
}
} catch {
// SKILL.md 不存在或解析失败,跳过
}
}
this.logger.info('[SkillLoader] 共加载 %d 个 Skill', this.skills.size);
} catch {
this.logger.warn('[SkillLoader] Skills 目录不存在: %s', this.skillsDir);
}
}
/** 解析 SKILL.md — 完整 YAML frontmatter 解析 */
parseSkillMd(raw: string): SkillMeta | null {
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!fmMatch) return null;
try {
const frontmatter = yaml.load(fmMatch[1]) as Record<string, unknown>;
const content = fmMatch[2].trim();
const name = frontmatter.name as string;
if (!name) return null;
return {
name,
description: (frontmatter.description as string) ?? '',
version: (frontmatter.version as string) ?? undefined,
metadata: (frontmatter.metadata as Record<string, unknown>) ?? undefined,
content,
};
} catch (e) {
this.logger.warn('[SkillLoader] YAML 解析失败: %s', e);
return null;
}
}
getSkill(name: string): SkillMeta | undefined {
return this.skills.get(name);
}
getAllSkills(): SkillMeta[] {
return Array.from(this.skills.values());
}
/** 按需获取完整 SKILL.md 内容(渐进式披露) */
getSkillContent(name: string): string | null {
const skill = this.skills.get(name);
return skill ? skill.content : null;
}
/** 条件过滤:根据 agent 可用工具过滤 skill */
filterByConditions(skillNames: string[], availableTools: string[]): SkillMeta[] {
const result: SkillMeta[] = [];
for (const name of skillNames) {
const skill = this.skills.get(name);
if (!skill) continue;
const conditions = (skill.metadata as any)?.conditions;
if (conditions) {
// requires_tools: 必须全部可用
if (conditions.requires_tools) {
const required = conditions.requires_tools as string[];
if (!required.every(t => availableTools.includes(t))) continue;
}
// fallback_for_tools: 当指定工具不可用时才显示
if (conditions.fallback_for_tools) {
const fallbackFor = conditions.fallback_for_tools as string[];
if (fallbackFor.every(t => availableTools.includes(t))) continue;
}
}
result.push(skill);
}
return result;
}
/** 构建 <available_skills> XML 索引(渐进式披露:只含 name + description */
buildSkillsPrompt(skillNames: string[], availableTools: string[]): string {
const filtered = this.filterByConditions(skillNames, availableTools);
if (filtered.length === 0) return '';
const lines: string[] = [];
let totalChars = 0;
for (const s of filtered) {
const line = ` <skill>\n <name>${s.name}</name>\n <description>${s.description}</description>\n </skill>`;
if (totalChars + line.length > SkillLoaderService.MAX_SKILLS_PROMPT_CHARS) {
this.logger.warn('[SkillLoader] Skill 索引超过 %d 字符限制,截断', SkillLoaderService.MAX_SKILLS_PROMPT_CHARS);
break;
}
lines.push(line);
totalChars += line.length;
}
if (lines.length === 0) return '';
const skillsXml = lines.join('\n');
return `\n\n<available_skills>\n${skillsXml}\n</available_skills>\n\n当你需要使用某个 skill 时,调用 read_skill 工具读取完整指令后再执行。`;
}
/** 获取所有 Skill 元数据(文件 + 数据库合并) */
async getSkillMetas(): Promise<any[]> {
const fileSkills = this.getAllSkills();
const dbSkills = await this.skillRepo.find();
const dbMap = new Map(dbSkills.map(s => [s.name, s]));
const result = [];
for (const fs of fileSkills) {
let dbRecord = dbMap.get(fs.name);
if (!dbRecord) {
dbRecord = await this.skillRepo.save({
name: fs.name,
label: fs.name,
description: fs.description,
skillType: (fs.metadata as any)?.skillType ?? null,
tags: (fs.metadata as any)?.tags ?? null,
version: fs.version ?? null,
source: 'local',
metadata: fs.metadata ?? null,
status: 1,
} as any);
}
result.push({
name: fs.name,
label: dbRecord.label || fs.name,
description: fs.description || dbRecord.description,
skillType: (fs.metadata as any)?.skillType ?? dbRecord.skillType,
tags: (fs.metadata as any)?.tags ?? dbRecord.tags,
emoji: (fs.metadata as any)?.emoji ?? null,
status: dbRecord.status,
version: fs.version ?? dbRecord.version,
source: dbRecord.source ?? 'local',
sourceUrl: dbRecord.sourceUrl,
installedAt: dbRecord.installedAt,
metadata: fs.metadata,
});
}
return result;
}
/** 设置 Skill 启用/禁用状态 */
async setSkillStatus(name: string, status: number): Promise<void> {
const existing = await this.skillRepo.findOneBy({ name });
if (existing) {
await this.skillRepo.update({ name }, { status });
} else {
await this.skillRepo.save({ name, label: name, status } as any);
}
}
}
```
- [ ] **Step 2: 验证编译通过**
```bash
cd packages/backend && npx tsc --noEmit
```
Expected: 无错误。
- [ ] **Step 3: Commit**
```bash
git add packages/backend/src/modules/netaclaw/service/skill_loader.ts
git commit -m "refactor: rewrite skill_loader - yaml parsing, progressive disclosure, condition filtering"
```
---
### Task 4: 创建 SkillRegistryService
**Files:**
- Create: `packages/backend/src/modules/netaclaw/service/skill_registry.ts`
- [ ] **Step 1: 创建 skill_registry.ts**
```typescript
import { Provide, Scope, ScopeEnum, Logger, Init } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
export interface SkillHubLockfile {
version: 1;
skills: Record<string, { version: string; fingerprint: string; installedAt: number }>;
}
export interface SkillOrigin {
version: 1;
source: string;
url: string;
branch?: string;
commitHash: string;
installedAt: string;
}
@Provide()
@Scope(ScopeEnum.Singleton)
export class SkillRegistryService {
@Logger()
logger: ILogger;
private hubDir: string;
private originsDir: string;
private lockfilePath: string;
@Init()
async init() {
this.hubDir = path.resolve(process.cwd(), '.skillhub');
this.originsDir = path.join(this.hubDir, 'origins');
this.lockfilePath = path.join(this.hubDir, 'lock.json');
await fs.mkdir(this.originsDir, { recursive: true });
}
async readLockfile(): Promise<SkillHubLockfile> {
try {
const raw = await fs.readFile(this.lockfilePath, 'utf-8');
return JSON.parse(raw);
} catch {
return { version: 1, skills: {} };
}
}
async writeLockfile(lockfile: SkillHubLockfile): Promise<void> {
await fs.writeFile(this.lockfilePath, JSON.stringify(lockfile, null, 2), 'utf-8');
}
async writeOrigin(name: string, origin: SkillOrigin): Promise<void> {
const filePath = path.join(this.originsDir, `${name}.json`);
await fs.writeFile(filePath, JSON.stringify(origin, null, 2), 'utf-8');
}
async readOrigin(name: string): Promise<SkillOrigin | null> {
try {
const filePath = path.join(this.originsDir, `${name}.json`);
const raw = await fs.readFile(filePath, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
async removeOrigin(name: string): Promise<void> {
try {
await fs.unlink(path.join(this.originsDir, `${name}.json`));
} catch { /* ignore */ }
}
/** 计算 skill 目录的 SHA256 指纹(基于 SKILL.md 内容) */
async computeFingerprint(skillDir: string): Promise<string> {
const skillMdPath = path.join(skillDir, 'SKILL.md');
const content = await fs.readFile(skillMdPath, 'utf-8');
return crypto.createHash('sha256').update(content).digest('hex');
}
}
```
- [ ] **Step 2: 验证编译**
```bash
cd packages/backend && npx tsc --noEmit
```
- [ ] **Step 3: Commit**
```bash
git add packages/backend/src/modules/netaclaw/service/skill_registry.ts
git commit -m "feat: add SkillRegistryService - lockfile, fingerprint, origin tracking"
```
---
### Task 5: 创建 SkillInstallerService
**Files:**
- Create: `packages/backend/src/modules/netaclaw/service/skill_installer.ts`
- [ ] **Step 1: 创建 skill_installer.ts**
```typescript
import { Provide, Inject, Scope, ScopeEnum, Logger, Init } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { NetaClawSkillEntity } from '../entity/skill.js';
import { SkillLoaderService, SkillMeta } from './skill_loader.js';
import { SkillRegistryService, SkillOrigin } from './skill_registry.js';
const execAsync = promisify(exec);
// 安全校验正则(对齐 OpenClaw
const SAFE_GITHUB_URL = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(\.git)?$/;
const SAFE_NODE_PACKAGE = /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+(@[a-z0-9^~>=<.*|-]+)?$/;
const SAFE_UV_PACKAGE = /^[a-z0-9][a-z0-9._-]*(\[[a-z0-9,._-]+\])?(([><=!~]=?|===?)[a-z0-9.*_-]+)?$/i;
@Provide()
@Scope(ScopeEnum.Singleton)
export class SkillInstallerService {
@Logger()
logger: ILogger;
@Inject()
skillLoader: SkillLoaderService;
@Inject()
registry: SkillRegistryService;
@InjectEntityModel(NetaClawSkillEntity)
skillRepo: Repository<NetaClawSkillEntity>;
private skillsDir: string;
@Init()
async init() {
this.skillsDir = path.resolve(process.cwd(), 'skills');
}
/** 从 GitHub 安装 skill */
async installFromGitHub(
url: string,
options?: { branch?: string; tag?: string },
onProgress?: (step: string, percent: number) => void,
): Promise<SkillMeta> {
// 1. 校验 URL
const cleanUrl = url.replace(/\/$/, '');
if (!SAFE_GITHUB_URL.test(cleanUrl)) {
throw new Error('无效的 GitHub URL仅支持 github.com 仓库');
}
onProgress?.('正在克隆仓库...', 10);
// 2. Clone 到临时目录
const tmpDir = path.join(this.skillsDir, `.tmp-${Date.now()}`);
const ref = options?.tag || options?.branch || 'main';
try {
await execAsync(`git clone --depth 1 --branch ${ref} ${cleanUrl} "${tmpDir}"`, { timeout: 60000 });
} catch (e: any) {
// 如果 branch 失败,尝试不指定 branch
try {
await execAsync(`git clone --depth 1 ${cleanUrl} "${tmpDir}"`, { timeout: 60000 });
} catch (e2: any) {
throw new Error(`Git clone 失败: ${e2.message}`);
}
}
onProgress?.('正在验证 SKILL.md...', 30);
// 3. 验证 SKILL.md 存在
const skillMdPath = path.join(tmpDir, 'SKILL.md');
try {
await fs.access(skillMdPath);
} catch {
await fs.rm(tmpDir, { recursive: true, force: true });
throw new Error('仓库中未找到 SKILL.md 文件');
}
// 4. 解析 SKILL.md
const raw = await fs.readFile(skillMdPath, 'utf-8');
const skillMeta = this.skillLoader.parseSkillMd(raw);
if (!skillMeta) {
await fs.rm(tmpDir, { recursive: true, force: true });
throw new Error('SKILL.md 解析失败:缺少 name 字段');
}
onProgress?.('正在安装...', 50);
// 5. 路径安全检查 + 移动到 skills/{name}/
const targetDir = path.join(this.skillsDir, skillMeta.name);
const realSkillsDir = await fs.realpath(this.skillsDir);
const realTarget = path.resolve(realSkillsDir, skillMeta.name);
if (!realTarget.startsWith(realSkillsDir)) {
await fs.rm(tmpDir, { recursive: true, force: true });
throw new Error('路径安全检查失败');
}
// 移除旧版本(如果存在)
await fs.rm(targetDir, { recursive: true, force: true });
await fs.rename(tmpDir, targetDir);
// 删除 .git 目录
await fs.rm(path.join(targetDir, '.git'), { recursive: true, force: true });
onProgress?.('正在获取 commit hash...', 60);
// 6. 获取 commit hash
let commitHash = '';
try {
const { stdout } = await execAsync(`git ls-remote ${cleanUrl} HEAD`, { timeout: 15000 });
commitHash = stdout.split('\t')[0] || '';
} catch { /* ignore */ }
// 7. 计算指纹 + 写入 origin + 更新 lockfile
const fingerprint = await this.registry.computeFingerprint(targetDir);
const origin: SkillOrigin = {
version: 1,
source: 'github',
url: cleanUrl,
branch: ref,
commitHash,
installedAt: new Date().toISOString(),
};
await this.registry.writeOrigin(skillMeta.name, origin);
const lockfile = await this.registry.readLockfile();
lockfile.skills[skillMeta.name] = {
version: skillMeta.version || '0.0.0',
fingerprint,
installedAt: Date.now(),
};
await this.registry.writeLockfile(lockfile);
onProgress?.('正在同步数据库...', 80);
// 8. 同步到数据库
const existing = await this.skillRepo.findOneBy({ name: skillMeta.name });
const entityData: Partial<NetaClawSkillEntity> = {
name: skillMeta.name,
label: skillMeta.name,
description: skillMeta.description,
skillType: (skillMeta.metadata as any)?.skillType ?? null,
tags: (skillMeta.metadata as any)?.tags ?? null,
version: skillMeta.version ?? null,
source: 'github',
sourceUrl: cleanUrl,
installSpec: (skillMeta.metadata as any)?.install ?? null,
metadata: skillMeta.metadata ?? null,
installedAt: new Date(),
fingerprint,
status: 1,
};
if (existing) {
await this.skillRepo.update({ name: skillMeta.name }, entityData);
} else {
await this.skillRepo.save(entityData as any);
}
// 9. 重新扫描
await this.skillLoader.scanSkills();
onProgress?.('安装完成', 100);
// 10. 安装依赖(如果有)
const installSpecs = (skillMeta.metadata as any)?.install;
if (installSpecs && Array.isArray(installSpecs)) {
await this.installDependencies(skillMeta.name, installSpecs);
}
return skillMeta;
}
/** 安装 skill 声明的依赖 */
async installDependencies(
skillName: string,
installSpecs?: Record<string, unknown>[],
): Promise<{ success: boolean; logs: string[] }> {
const logs: string[] = [];
if (!installSpecs) {
const skill = this.skillLoader.getSkill(skillName);
installSpecs = (skill?.metadata as any)?.install;
}
if (!installSpecs || !Array.isArray(installSpecs)) {
return { success: true, logs: ['无依赖需要安装'] };
}
for (const spec of installSpecs) {
const kind = spec.kind as string;
const pkg = spec.package as string;
try {
if (kind === 'node' && pkg) {
if (!SAFE_NODE_PACKAGE.test(pkg)) {
logs.push(`[node] 包名校验失败: ${pkg}`);
continue;
}
const { stdout } = await execAsync(`pnpm add ${pkg}`, {
cwd: path.resolve(process.cwd()),
timeout: 120000,
});
logs.push(`[node] 已安装 ${pkg}: ${stdout.trim()}`);
} else if (kind === 'uv' && pkg) {
if (!SAFE_UV_PACKAGE.test(pkg)) {
logs.push(`[uv] 包名校验失败: ${pkg}`);
continue;
}
const { stdout } = await execAsync(`uv tool install ${pkg}`, { timeout: 120000 });
logs.push(`[uv] 已安装 ${pkg}: ${stdout.trim()}`);
} else {
logs.push(`[${kind}] 不支持的安装类型或缺少 package 字段`);
}
} catch (e: any) {
logs.push(`[${kind}] 安装 ${pkg} 失败: ${e.message}`);
}
}
return { success: logs.every(l => !l.includes('失败')), logs };
}
/** 卸载 skill */
async uninstall(name: string): Promise<void> {
const targetDir = path.join(this.skillsDir, name);
await fs.rm(targetDir, { recursive: true, force: true });
await this.registry.removeOrigin(name);
const lockfile = await this.registry.readLockfile();
delete lockfile.skills[name];
await this.registry.writeLockfile(lockfile);
await this.skillRepo.delete({ name });
await this.skillLoader.scanSkills();
}
/** 更新 skill重新拉取 */
async update(
name: string,
onProgress?: (step: string, percent: number) => void,
): Promise<SkillMeta> {
const origin = await this.registry.readOrigin(name);
if (!origin || origin.source !== 'github') {
throw new Error(`Skill "${name}" 不是 GitHub 安装的,无法更新`);
}
return this.installFromGitHub(origin.url, { branch: origin.branch }, onProgress);
}
/** 检查所有 GitHub skill 是否有更新 */
async checkUpdates(): Promise<Array<{ name: string; hasUpdate: boolean; currentHash: string; latestHash: string }>> {
const results: Array<{ name: string; hasUpdate: boolean; currentHash: string; latestHash: string }> = [];
const dbSkills = await this.skillRepo.find({ where: { source: 'github' } });
for (const skill of dbSkills) {
const origin = await this.registry.readOrigin(skill.name);
if (!origin) continue;
try {
const { stdout } = await execAsync(`git ls-remote ${origin.url} HEAD`, { timeout: 15000 });
const latestHash = stdout.split('\t')[0] || '';
results.push({
name: skill.name,
hasUpdate: latestHash !== origin.commitHash,
currentHash: origin.commitHash,
latestHash,
});
} catch {
results.push({ name: skill.name, hasUpdate: false, currentHash: origin.commitHash, latestHash: '' });
}
}
return results;
}
}
```
- [ ] **Step 2: 验证编译**
```bash
cd packages/backend && npx tsc --noEmit
```
- [ ] **Step 3: Commit**
```bash
git add packages/backend/src/modules/netaclaw/service/skill_installer.ts
git commit -m "feat: add SkillInstallerService - GitHub install, deps, update, uninstall"
```
---
### Task 6: 创建内置工具 read_skill + skill_manage
**Files:**
- Create: `packages/backend/src/modules/netaclaw/tools/builtin/read_skill.ts`
- Create: `packages/backend/src/modules/netaclaw/tools/builtin/skill_manage.ts`
- [ ] **Step 1: 创建 read_skill.ts**
```typescript
import { Type } from '@sinclair/typebox';
import { AgentToolWithMeta, textResult } from '../common.js';
import { SkillLoaderService } from '../../service/skill_loader.js';
const ReadSkillParams = Type.Object({
name: Type.String({ description: '要读取的 skill 名称' }),
});
export function createReadSkillTool(skillLoader: SkillLoaderService): AgentToolWithMeta<typeof ReadSkillParams, unknown> {
return {
name: 'read_skill',
label: '读取 Skill',
description: '读取指定 skill 的完整 SKILL.md 内容。在 <available_skills> 索引中看到感兴趣的 skill 后,调用此工具获取完整指令。',
parameters: ReadSkillParams,
async execute(_id, params) {
const content = skillLoader.getSkillContent(params.name);
if (!content) {
return textResult(`未找到名为 "${params.name}" 的 skill`);
}
return textResult(content);
},
};
}
```
- [ ] **Step 2: 创建 skill_manage.ts**
```typescript
import { Type } from '@sinclair/typebox';
import * as fs from 'fs/promises';
import * as path from 'path';
import { AgentToolWithMeta, textResult } from '../common.js';
import { SkillLoaderService } from '../../service/skill_loader.js';
const SkillManageParams = Type.Object({
action: Type.Union([
Type.Literal('create'),
Type.Literal('edit'),
Type.Literal('delete'),
], { description: '操作类型' }),
name: Type.String({ description: 'skill 名称' }),
content: Type.Optional(Type.String({ description: 'SKILL.md 完整内容create/edit 时必填)' })),
});
export function createSkillManageTool(skillLoader: SkillLoaderService): AgentToolWithMeta<typeof SkillManageParams, unknown> {
const skillsDir = path.resolve(process.cwd(), 'skills');
return {
name: 'skill_manage',
label: '管理 Skill',
description: '创建、编辑或删除 skill。创建/编辑时需提供完整的 SKILL.md 内容(含 YAML frontmatter。',
parameters: SkillManageParams,
async execute(_id, params) {
// 路径安全检查
const targetDir = path.join(skillsDir, params.name);
const realSkillsDir = await fs.realpath(skillsDir).catch(() => skillsDir);
const realTarget = path.resolve(realSkillsDir, params.name);
if (!realTarget.startsWith(realSkillsDir)) {
return textResult('错误: 路径安全检查失败');
}
if (params.action === 'create' || params.action === 'edit') {
if (!params.content) {
return textResult('错误: create/edit 操作需要提供 content');
}
// 验证 content 是合法的 SKILL.md
const parsed = skillLoader.parseSkillMd(params.content);
if (!parsed) {
return textResult('错误: content 不是合法的 SKILL.md 格式(需要 YAML frontmatter 含 name 字段)');
}
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(path.join(targetDir, 'SKILL.md'), params.content, 'utf-8');
await skillLoader.scanSkills();
return textResult(`Skill "${params.name}" 已${params.action === 'create' ? '创建' : '更新'}`);
}
if (params.action === 'delete') {
await fs.rm(targetDir, { recursive: true, force: true });
await skillLoader.scanSkills();
return textResult(`Skill "${params.name}" 已删除`);
}
return textResult('错误: 未知操作');
},
};
}
```
- [ ] **Step 3: 验证编译**
```bash
cd packages/backend && npx tsc --noEmit
```
- [ ] **Step 4: Commit**
```bash
git add packages/backend/src/modules/netaclaw/tools/builtin/read_skill.ts packages/backend/src/modules/netaclaw/tools/builtin/skill_manage.ts
git commit -m "feat: add read_skill and skill_manage builtin tools"
```
---
### Task 7: 创建 buildSkillContext 公共函数
**Files:**
- Create: `packages/backend/src/modules/netaclaw/service/skill_context.ts`
- [ ] **Step 1: 创建 skill_context.ts**
```typescript
import { SkillLoaderService } from './skill_loader.js';
import { AnyAgentTool } from '../tools/common.js';
import { createReadSkillTool } from '../tools/builtin/read_skill.js';
import { createSkillManageTool } from '../tools/builtin/skill_manage.js';
export interface SkillContext {
skillPrompt: string;
skillTools: AnyAgentTool[];
}
/**
* 构建 skill 上下文Gateway 和 Chat Controller 共用)
* @param skillLoader - SkillLoaderService 实例
* @param agentSkills - Agent 配置的 skill 名称列表
* @param builtinToolNames - 当前请求中已有的内置工具名称列表(用于条件过滤)
*/
export function buildSkillContext(
skillLoader: SkillLoaderService,
agentSkills: string[] | undefined,
builtinToolNames: string[],
): SkillContext {
const skillNames = agentSkills || [];
const skillPrompt = skillLoader.buildSkillsPrompt(skillNames, builtinToolNames);
const skillTools: AnyAgentTool[] = [
createReadSkillTool(skillLoader),
createSkillManageTool(skillLoader),
];
return { skillPrompt, skillTools };
}
```
- [ ] **Step 2: Commit**
```bash
git add packages/backend/src/modules/netaclaw/service/skill_context.ts
git commit -m "feat: add buildSkillContext shared helper for gateway and chat"
```
---
### Task 8: 改造 Skill Controller
**Files:**
- Modify: `packages/backend/src/modules/netaclaw/controller/skill.ts`
- [ ] **Step 1: 重写 skill controller**
`packages/backend/src/modules/netaclaw/controller/skill.ts` 完整替换为:
```typescript
import { Provide, Inject, Get, Post, Body, Query } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { NetaClawSkillEntity } from '../entity/skill.js';
import { SkillLoaderService } from '../service/skill_loader.js';
import { SkillInstallerService } from '../service/skill_installer.js';
@Provide()
@CoolController({
api: ['info', 'list', 'page'],
entity: NetaClawSkillEntity,
pageQueryOp: {
keyWordLikeFields: ['name', 'label', 'description'],
fieldEq: ['status', 'source', 'skillType'],
addOrderBy: { createTime: 'DESC' },
},
})
export class AdminNetaClawSkillController extends BaseController {
@Inject()
skillLoader: SkillLoaderService;
@Inject()
skillInstaller: SkillInstallerService;
@Get('/metas', { summary: '获取已启用 skill 元数据快照' })
async metas() {
return this.ok(await this.skillLoader.getSkillMetas());
}
@Post('/setStatus', { summary: '启用/禁用 skill' })
async setStatus(@Body() body: { name: string; status: number }) {
await this.skillLoader.setSkillStatus(body.name, body.status);
return this.ok();
}
@Post('/install', { summary: '安装 skill (GitHub)' })
async install(@Body() body: { url: string; branch?: string; version?: string }) {
try {
const meta = await this.skillInstaller.installFromGitHub(
body.url,
{ branch: body.branch, tag: body.version },
);
return this.ok(meta);
} catch (e: any) {
return this.fail(e.message);
}
}
@Post('/uninstall', { summary: '卸载 skill' })
async uninstall(@Body() body: { name: string }) {
try {
await this.skillInstaller.uninstall(body.name);
return this.ok();
} catch (e: any) {
return this.fail(e.message);
}
}
@Post('/update', { summary: '更新 skill (重新拉取)' })
async update(@Body() body: { name: string }) {
try {
const meta = await this.skillInstaller.update(body.name);
return this.ok(meta);
} catch (e: any) {
return this.fail(e.message);
}
}
@Get('/content', { summary: '按需获取完整 SKILL.md 内容' })
async content(@Query('name') name: string) {
const content = this.skillLoader.getSkillContent(name);
if (!content) return this.fail('Skill 不存在');
return this.ok({ name, content });
}
@Post('/checkUpdates', { summary: '检查所有 skill 是否有更新' })
async checkUpdates() {
const results = await this.skillInstaller.checkUpdates();
return this.ok(results);
}
@Post('/installDeps', { summary: '手动触发依赖安装' })
async installDeps(@Body() body: { name: string }) {
try {
const result = await this.skillInstaller.installDependencies(body.name);
return this.ok(result);
} catch (e: any) {
return this.fail(e.message);
}
}
@Post('/create', { summary: 'Agent 自主创建 skill' })
async create(@Body() body: { name: string; content: string }) {
try {
const skillsDir = path.resolve(process.cwd(), 'skills');
const targetDir = path.join(skillsDir, body.name);
// 路径安全检查
const realSkillsDir = await fs.realpath(skillsDir).catch(() => skillsDir);
const realTarget = path.resolve(realSkillsDir, body.name);
if (!realTarget.startsWith(realSkillsDir)) {
return this.fail('路径安全检查失败');
}
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(path.join(targetDir, 'SKILL.md'), body.content, 'utf-8');
await this.skillLoader.scanSkills();
return this.ok();
} catch (e: any) {
return this.fail(e.message);
}
}
@Post('/edit', { summary: 'Agent 自主编辑 skill' })
async edit(@Body() body: { name: string; content: string }) {
return this.create(body); // 逻辑相同:覆盖写入
}
}
```
注意:需要在文件顶部补充 import
```typescript
import * as fs from 'fs/promises';
import * as path from 'path';
```
- [ ] **Step 2: 验证编译**
```bash
cd packages/backend && npx tsc --noEmit
```
- [ ] **Step 3: Commit**
```bash
git add packages/backend/src/modules/netaclaw/controller/skill.ts
git commit -m "refactor: rewrite skill controller - @CoolController + install/uninstall/update APIs"
```
---
### Task 9: 统一 Gateway + Chat Controller 的 skill 加载
**Files:**
- Modify: `packages/backend/src/modules/netaclaw/gateway/server.ts`
- Modify: `packages/backend/src/modules/netaclaw/controller/chat.ts`
- [ ] **Step 1: 改造 gateway/server.ts**
`server.ts` 顶部添加 import
```typescript
import { buildSkillContext } from '../service/skill_context.js';
```
找到构建 agentConfig 的代码块(约第 113-146 行),将 skill 加载逻辑替换为:
```typescript
// 替换原来的:
// const skillNames = agentInfo.skills || [];
// const skillPrompt = this.skillLoader.getSkillPrompt(skillNames);
// 改为builtinToolNames 从当前已有的工具中提取):
const builtinToolNames = ['bash', 'read_file', 'write_file', 'list_dir'];
const { skillPrompt, skillTools } = buildSkillContext(
this.skillLoader,
agentInfo.skills,
builtinToolNames,
);
```
在 systemPrompt 拼接处保持不变(已经用 skillPrompt
`runAgent` 调用处(约第 188 行),将 tools 改为:
```typescript
tools: [bashTool, readFileTool, writeFileTool, listDirTool, ...memoryTools, ...skillTools],
```
- [ ] **Step 2: 改造 chat.ts**
`chat.ts` 顶部添加 import
```typescript
import { buildSkillContext } from '../service/skill_context.js';
```
添加 SkillLoaderService 注入(如果还没有):
```typescript
@Inject()
skillLoader: SkillLoaderService;
```
找到第 72 行的硬编码空数组:
```typescript
// 替换原来的:
// const skillPrompt = this.skillLoader.getSkillPrompt([]);
// 改为:
const builtinToolNames = ['bash', 'read_file', 'write_file', 'list_dir'];
const { skillPrompt, skillTools } = buildSkillContext(
this.skillLoader,
agentEntity?.skills,
builtinToolNames,
);
```
`runAgent` 调用处(约第 106 行),将 tools 改为包含 skillTools
```typescript
const tools = [bashTool, readFileTool, writeFileTool, listDirTool, ...memoryTools, ...skillTools];
```
- [ ] **Step 3: 验证编译**
```bash
cd packages/backend && npx tsc --noEmit
```
- [ ] **Step 4: 启动后端验证无报错**
```bash
cd packages/backend && pnpm dev
```
- [ ] **Step 5: Commit**
```bash
git add packages/backend/src/modules/netaclaw/gateway/server.ts packages/backend/src/modules/netaclaw/controller/chat.ts
git commit -m "fix: unify skill loading in gateway and chat controller via buildSkillContext"
```
---
## Phase 3: 前端改造
### Task 10: 删除老组件 + 重写 skills.vue
**Files:**
- Delete: `packages/frontend/src/modules/agent/components/skill-config.vue`
- Delete: `packages/frontend/src/modules/agent/components/skill-prompts.vue`
- Delete: `packages/frontend/src/modules/agent/components/skill-model.vue`
- Modify: `packages/frontend/src/modules/agent/views/skills.vue`
- [ ] **Step 1: 删除老组件**
```bash
rm packages/frontend/src/modules/agent/components/skill-config.vue
rm packages/frontend/src/modules/agent/components/skill-prompts.vue
rm packages/frontend/src/modules/agent/components/skill-model.vue
```
- [ ] **Step 2: 重写 skills.vue**
`packages/frontend/src/modules/agent/views/skills.vue` 完整替换。核心改动:
- 所有 `fetch()` 调用改为 `useCool()` 的 service 代理
- 新增安装对话框GitHub URL 输入)
- 新增卸载/更新操作
- 卡片显示 emoji + source 标签
关键代码结构:
```vue
<template>
<div class="skill-page">
<!-- 顶部操作栏 -->
<div class="skill-header">
<el-input v-model="searchKeyword" placeholder="搜索 Skill..." clearable style="width: 240px" />
<el-select v-model="filterType" style="width: 140px; margin-left: 12px">
<el-option label="全部类型" value="all" />
<el-option label="compute" value="compute" />
<el-option label="llm" value="llm" />
<el-option label="multimodal" value="multimodal" />
</el-select>
<el-select v-model="filterSource" style="width: 140px; margin-left: 12px">
<el-option label="全部来源" value="all" />
<el-option label="本地" value="local" />
<el-option label="GitHub" value="github" />
</el-select>
<el-button type="primary" style="margin-left: auto" @click="installDialogVisible = true">
安装 Skill
</el-button>
</div>
<!-- Skill 卡片列表 -->
<div class="skill-grid">
<div v-for="skill in filteredList" :key="skill.name" class="skill-card">
<div class="skill-card-header">
<span class="skill-emoji">{{ skill.emoji || '🔧' }}</span>
<span class="skill-name">{{ skill.label || skill.name }}</span>
<el-switch
:model-value="skill.status === 1"
@change="handleToggleStatus(skill)"
style="margin-left: auto"
/>
</div>
<div class="skill-desc">{{ skill.description }}</div>
<div class="skill-tags">
<el-tag size="small" type="info">{{ skill.skillType || 'unknown' }}</el-tag>
<el-tag size="small" :type="skill.source === 'github' ? 'warning' : 'success'">
{{ skill.source || 'local' }}
</el-tag>
</div>
<div class="skill-actions">
<el-button size="small" @click="handleViewDetail(skill)">详情</el-button>
<el-button v-if="skill.source === 'github'" size="small" @click="handleUpdate(skill)">更新</el-button>
<el-button size="small" type="danger" @click="handleUninstall(skill)">卸载</el-button>
</div>
</div>
</div>
<!-- 安装对话框 -->
<el-dialog v-model="installDialogVisible" title="安装 Skill" width="500px">
<el-form label-width="100px">
<el-form-item label="GitHub URL">
<el-input v-model="installForm.url" placeholder="https://github.com/user/my-skill" />
</el-form-item>
<el-form-item label="分支/Tag">
<el-input v-model="installForm.branch" placeholder="main (可选)" />
</el-form-item>
</el-form>
<!-- 注意Spec 6.4 定义了 Socket.IO 实时进度事件skill_install_progress
当前实现简化为 HTTP 同步等待 + loading 状态
GitHub clone 通常几秒完成HTTP 等待足够
Socket.IO 进度可作为后续优化单独实现 -->
<div v-if="installing" class="install-progress">
<el-progress :percentage="50" :indeterminate="true" />
<span>正在安装请稍候...</span>
</div>
<template #footer>
<el-button @click="installDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="installing" @click="handleInstall">安装</el-button>
</template>
</el-dialog>
<!-- 详情抽屉 -->
<skill-detail v-model="detailVisible" :skill="currentSkill" @updated="refresh" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useCool } from '/@/cool';
import SkillDetail from '../components/skill-detail.vue';
const { service } = useCool();
// 状态
const list = ref<any[]>([]);
const searchKeyword = ref('');
const filterType = ref('all');
const filterSource = ref('all');
const installDialogVisible = ref(false);
const installForm = ref({ url: '', branch: '' });
const installing = ref(false);
const detailVisible = ref(false);
const currentSkill = ref<any>(null);
// 筛选
const filteredList = computed(() => {
let result = list.value;
if (filterType.value !== 'all') {
result = result.filter(item => item.skillType === filterType.value);
}
if (filterSource.value !== 'all') {
result = result.filter(item => item.source === filterSource.value);
}
if (searchKeyword.value) {
const kw = searchKeyword.value.toLowerCase();
result = result.filter(item =>
item.name.toLowerCase().includes(kw) ||
(item.label || '').toLowerCase().includes(kw) ||
(item.description || '').toLowerCase().includes(kw)
);
}
return result;
});
// 加载列表
async function refresh() {
try {
const res = await service.request({ url: '/admin/netaclaw/skill/metas' });
list.value = res || [];
} catch {
ElMessage.error('加载 Skill 列表失败');
}
}
// 启用/禁用
async function handleToggleStatus(skill: any) {
const newStatus = skill.status === 1 ? 0 : 1;
await service.request({
url: '/admin/netaclaw/skill/setStatus',
method: 'POST',
data: { name: skill.name, status: newStatus },
});
skill.status = newStatus;
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用');
}
// 安装
async function handleInstall() {
if (!installForm.value.url) {
ElMessage.warning('请输入 GitHub URL');
return;
}
installing.value = true;
try {
await service.request({
url: '/admin/netaclaw/skill/install',
method: 'POST',
data: {
url: installForm.value.url,
branch: installForm.value.branch || undefined,
},
});
ElMessage.success('Skill 安装成功');
installDialogVisible.value = false;
installForm.value = { url: '', branch: '' };
await refresh();
} catch (e: any) {
ElMessage.error(e.message || '安装失败');
} finally {
installing.value = false;
}
}
// 卸载
async function handleUninstall(skill: any) {
await ElMessageBox.confirm(`确定卸载 "${skill.name}" 吗?`, '确认');
await service.request({
url: '/admin/netaclaw/skill/uninstall',
method: 'POST',
data: { name: skill.name },
});
ElMessage.success('已卸载');
await refresh();
}
// 更新
async function handleUpdate(skill: any) {
try {
await service.request({
url: '/admin/netaclaw/skill/update',
method: 'POST',
data: { name: skill.name },
});
ElMessage.success('更新成功');
await refresh();
} catch (e: any) {
ElMessage.error(e.message || '更新失败');
}
}
// 详情
function handleViewDetail(skill: any) {
currentSkill.value = skill;
detailVisible.value = true;
}
onMounted(() => refresh());
</script>
<style scoped>
.skill-page { padding: 20px; }
.skill-header { display: flex; align-items: center; margin-bottom: 20px; }
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.skill-card { border: 1px solid #e4e7ed; border-radius: 8px; padding: 16px; }
.skill-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.skill-emoji { font-size: 20px; }
.skill-name { font-weight: 600; font-size: 15px; }
.skill-desc { color: #606266; font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.skill-tags { display: flex; gap: 6px; margin-bottom: 12px; }
.skill-actions { display: flex; gap: 8px; }
.install-progress { margin-top: 16px; text-align: center; }
</style>
```
- [ ] **Step 3: Commit**
```bash
git add -A packages/frontend/src/modules/agent/
git commit -m "refactor: rewrite skills.vue, delete old skill-config/prompts/model components"
```
---
### Task 11: 重写 skill-detail.vue
**Files:**
- Modify: `packages/frontend/src/modules/agent/components/skill-detail.vue`
- [ ] **Step 1: 重写 skill-detail.vue**
替换为新的详情抽屉,显示 SKILL.md 渲染、来源信息、依赖状态:
```vue
<template>
<el-drawer v-model="visible" :title="skill?.label || skill?.name || 'Skill 详情'" size="500px">
<template v-if="skill">
<div class="detail-section">
<div class="detail-label">基本信息</div>
<div class="detail-row">
<span>名称</span><span>{{ skill.name }}</span>
</div>
<div class="detail-row">
<span>类型</span><el-tag size="small">{{ skill.skillType || '-' }}</el-tag>
</div>
<div class="detail-row">
<span>来源</span><el-tag size="small" :type="skill.source === 'github' ? 'warning' : 'success'">{{ skill.source || 'local' }}</el-tag>
</div>
<div v-if="skill.sourceUrl" class="detail-row">
<span>URL</span><a :href="skill.sourceUrl" target="_blank">{{ skill.sourceUrl }}</a>
</div>
<div v-if="skill.version" class="detail-row">
<span>版本</span><span>{{ skill.version }}</span>
</div>
<div v-if="skill.installedAt" class="detail-row">
<span>安装时间</span><span>{{ skill.installedAt }}</span>
</div>
</div>
<div class="detail-section">
<div class="detail-label">SKILL.md 内容</div>
<div v-if="skillContent" class="skill-content" v-html="renderedContent" />
<el-button v-else size="small" @click="loadContent">加载内容</el-button>
</div>
<div v-if="skill.metadata?.install" class="detail-section">
<div class="detail-label">依赖</div>
<div v-for="dep in (skill.metadata.install as any[])" :key="dep.id" class="detail-row">
<el-tag size="small" type="info">{{ dep.kind }}</el-tag>
<span style="margin-left: 8px">{{ dep.package || dep.label }}</span>
</div>
<el-button size="small" style="margin-top: 8px" @click="handleInstallDeps">安装依赖</el-button>
</div>
<div v-if="skill.metadata?.conditions" class="detail-section">
<div class="detail-label">条件激活</div>
<div v-if="skill.metadata.conditions.requires_tools" class="detail-row">
<span>需要工具</span>
<el-tag v-for="t in skill.metadata.conditions.requires_tools" :key="t" size="small" style="margin: 2px">{{ t }}</el-tag>
</div>
<div v-if="skill.metadata.conditions.fallback_for_tools" class="detail-row">
<span>替代工具</span>
<el-tag v-for="t in skill.metadata.conditions.fallback_for_tools" :key="t" size="small" style="margin: 2px">{{ t }}</el-tag>
</div>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { useCool } from '/@/cool';
const props = defineProps<{ skill: any }>();
const emit = defineEmits(['updated']);
const visible = defineModel<boolean>();
const { service } = useCool();
const skillContent = ref('');
// 简单 markdown 渲染(可后续替换为 markdown-it
const renderedContent = computed(() => {
return skillContent.value
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
.replace(/\n/g, '<br/>');
});
watch(() => props.skill, () => { skillContent.value = ''; });
async function loadContent() {
try {
const res = await service.request({
url: '/admin/netaclaw/skill/content',
params: { name: props.skill.name },
});
skillContent.value = res?.content || '(无内容)';
} catch {
ElMessage.error('加载失败');
}
}
async function handleInstallDeps() {
try {
const res = await service.request({
url: '/admin/netaclaw/skill/installDeps',
method: 'POST',
data: { name: props.skill.name },
});
ElMessage.success('依赖安装完成');
} catch (e: any) {
ElMessage.error(e.message || '安装失败');
}
}
</script>
<style scoped>
.detail-section { margin-bottom: 24px; }
.detail-label { font-weight: 600; font-size: 14px; margin-bottom: 8px; color: #303133; }
.detail-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 13px; color: #606266; }
.skill-content { background: #f5f7fa; border-radius: 6px; padding: 16px; font-size: 13px; line-height: 1.6; max-height: 400px; overflow-y: auto; }
</style>
```
- [ ] **Step 2: Commit**
```bash
git add packages/frontend/src/modules/agent/components/skill-detail.vue
git commit -m "refactor: rewrite skill-detail.vue - markdown content, deps, conditions"
```
---
### Task 12: 改造 agent-edit.vue Skill Tab
**Files:**
- Modify: `packages/frontend/src/modules/agent/views/agent-edit.vue`
- [ ] **Step 1: 改造 Skill 配置 Tab**
`agent-edit.vue` 中找到 Tab 2Skill 配置)部分,将其改造为:
- 使用 `service.request` 替代 `fetch`
- 左右布局:可用 skill 列表 + 已选 skill 列表
找到加载 skill 列表的 `fetch` 调用(约第 306-315 行),替换为:
```typescript
async function loadSkillMetas() {
const { service } = useCool();
const res = await service.request({ url: '/admin/netaclaw/skill/metas' });
skillMetas.value = (res || []).filter((s: any) => s.status === 1);
}
```
找到保存 agent 的 `fetch` 调用(约第 410-425 行),替换为:
```typescript
async function saveAgent() {
const { service } = useCool();
if (form.value.id) {
await service.netaclaw.agent.update(form.value);
} else {
await service.netaclaw.agent.add(form.value);
}
}
```
找到加载 agent 详情的 `fetch` 调用(约第 334-378 行),替换为:
```typescript
async function loadAgentInfo(id: number) {
const { service } = useCool();
const res = await service.netaclaw.agent.info({ id });
if (res) {
Object.assign(form.value, res);
}
}
```
Skill 选择区域改为带搜索的双列布局:
```vue
<!-- Skill 配置 Tab -->
<div class="skill-select-panel">
<div class="skill-available">
<div class="panel-title">可用 Skill</div>
<el-input v-model="skillSearch" placeholder="搜索..." size="small" clearable style="margin-bottom: 8px" />
<div v-for="skill in availableSkills" :key="skill.name" class="skill-item" @click="addSkill(skill.name)">
<span>{{ skill.emoji || '🔧' }} {{ skill.label || skill.name }}</span>
<el-tag size="small" type="info">{{ skill.skillType || '-' }}</el-tag>
</div>
</div>
<div class="skill-selected">
<div class="panel-title">已选 Skill ({{ form.skills?.length || 0 }})</div>
<div v-for="name in (form.skills || [])" :key="name" class="skill-item">
<span>{{ getSkillLabel(name) }}</span>
<el-button size="small" type="danger" text @click="removeSkill(name)">移除</el-button>
</div>
</div>
</div>
```
- [ ] **Step 2: 验证前端编译**
```bash
cd packages/frontend && pnpm dev
```
在浏览器中访问 `/agent/agents`,点击编辑 Agent确认 Skill Tab 正常显示。
- [ ] **Step 3: Commit**
```bash
git add packages/frontend/src/modules/agent/views/agent-edit.vue
git commit -m "refactor: agent-edit skill tab - cool admin service proxy, dual-column layout"
```
---
## Phase 4: 集成验证
### Task 13: 端到端验证
- [ ] **Step 1: 创建测试 skill**
`packages/backend/skills/` 下创建一个测试 skill
```bash
mkdir -p packages/backend/skills/test-greeting
```
创建 `packages/backend/skills/test-greeting/SKILL.md`
```markdown
---
name: test-greeting
description: 测试 Skill - 根据用户姓名生成个性化问候语
version: 1.0.0
metadata:
skillType: llm
emoji: "👋"
tags: [测试, 问候]
conditions:
requires_tools: ["bash"]
---
# 问候语生成 Skill
## 何时使用
当用户要求生成问候语或打招呼时使用此 skill。
## 执行流程
1. 获取用户提供的姓名
2. 根据当前时间选择合适的问候方式(早上好/下午好/晚上好)
3. 生成个性化的问候语
## 输出格式
直接返回问候语文本。
```
- [ ] **Step 2: 启动后端,验证 skill 加载**
```bash
cd packages/backend && pnpm dev
```
检查日志应包含:`[SkillLoader] 已加载 Skill: test-greeting`
- [ ] **Step 3: 验证 API 接口**
```bash
# 获取 skill 列表
curl http://localhost:8003/admin/netaclaw/skill/metas -H "Authorization: <token>"
# 获取 skill 内容
curl "http://localhost:8003/admin/netaclaw/skill/content?name=test-greeting" -H "Authorization: <token>"
# 禁用 skill
curl -X POST http://localhost:8003/admin/netaclaw/skill/setStatus \
-H "Content-Type: application/json" \
-H "Authorization: <token>" \
-d '{"name":"test-greeting","status":0}'
```
- [ ] **Step 4: 验证前端页面**
启动前端 `pnpm dev`,访问:
1. `/agent/skills` — 应显示 test-greeting 卡片,可启停/查看详情
2. `/agent/agents` — 编辑 AgentSkill Tab 应显示 test-greeting 可选
- [ ] **Step 5: 验证 Agent 对话中的 skill 注入**
`/agent/chat` 中与配置了 test-greeting skill 的 Agent 对话,检查:
1. System prompt 中应包含 `<available_skills>` XML 索引
2. Agent 应能调用 `read_skill` 工具读取完整内容
3. Agent 应能调用 `skill_manage` 工具创建新 skill
- [ ] **Step 6: 最终 Commit**
```bash
git add packages/backend/skills/test-greeting/
git commit -m "test: add test-greeting skill for e2e verification"
```