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

56 KiB
Raw Blame History

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 依赖

cd packages/backend && pnpm add js-yaml && pnpm add -D @types/js-yaml
  • Step 2: 清空 skills 目录下的老 skill
cd packages/backend
rm -rf skills/hello-world
# 保留 skills/ 目录本身
mkdir -p skills
  • Step 3: 清空数据库老数据
TRUNCATE TABLE netaclaw_skill;
  • Step 4: Commit
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 完整替换为:

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: 启动后端验证表结构自动同步
cd packages/backend && pnpm dev

检查日志无报错,数据库 netaclaw_skill 表字段已更新(老字段 icon/category/config 被移除,新字段 source/sourceUrl/installSpec/metadata/installedAt/fingerprint 已添加)。

  • Step 4: Commit
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 完整替换为:

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: 验证编译通过
cd packages/backend && npx tsc --noEmit

Expected: 无错误。

  • Step 3: Commit
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

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: 验证编译
cd packages/backend && npx tsc --noEmit
  • Step 3: Commit
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

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: 验证编译
cd packages/backend && npx tsc --noEmit
  • Step 3: Commit
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

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
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: 验证编译
cd packages/backend && npx tsc --noEmit
  • Step 4: Commit
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

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
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 完整替换为:

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

import * as fs from 'fs/promises';
import * as path from 'path';
  • Step 2: 验证编译
cd packages/backend && npx tsc --noEmit
  • Step 3: Commit
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

import { buildSkillContext } from '../service/skill_context.js';

找到构建 agentConfig 的代码块(约第 113-146 行),将 skill 加载逻辑替换为:

// 替换原来的:
// 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 改为:

tools: [bashTool, readFileTool, writeFileTool, listDirTool, ...memoryTools, ...skillTools],
  • Step 2: 改造 chat.ts

chat.ts 顶部添加 import

import { buildSkillContext } from '../service/skill_context.js';

添加 SkillLoaderService 注入(如果还没有):

@Inject()
skillLoader: SkillLoaderService;

找到第 72 行的硬编码空数组:

// 替换原来的:
// 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

const tools = [bashTool, readFileTool, writeFileTool, listDirTool, ...memoryTools, ...skillTools];
  • Step 3: 验证编译
cd packages/backend && npx tsc --noEmit
  • Step 4: 启动后端验证无报错
cd packages/backend && pnpm dev
  • Step 5: Commit
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: 删除老组件

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 标签

关键代码结构:

<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
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 渲染、来源信息、依赖状态:

<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
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 行),替换为:

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 行),替换为:

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 行),替换为:

async function loadAgentInfo(id: number) {
  const { service } = useCool();
  const res = await service.netaclaw.agent.info({ id });
  if (res) {
    Object.assign(form.value, res);
  }
}

Skill 选择区域改为带搜索的双列布局:

<!-- 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: 验证前端编译
cd packages/frontend && pnpm dev

在浏览器中访问 /agent/agents,点击编辑 Agent确认 Skill Tab 正常显示。

  • Step 3: Commit
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

mkdir -p packages/backend/skills/test-greeting

创建 packages/backend/skills/test-greeting/SKILL.md

---
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 加载
cd packages/backend && pnpm dev

检查日志应包含:[SkillLoader] 已加载 Skill: test-greeting

  • Step 3: 验证 API 接口
# 获取 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
git add packages/backend/skills/test-greeting/
git commit -m "test: add test-greeting skill for e2e verification"