56 KiB
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_manage),Controller 改为 @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.mdpackages/backend/src/modules/netaclaw/tools/builtin/skill_manage.ts— Agent 自主 CRUD skillpackages/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.vuepackages/frontend/src/modules/agent/components/skill-prompts.vuepackages/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 2(Skill 配置)部分,将其改造为:
- 使用
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,访问:
/agent/skills— 应显示 test-greeting 卡片,可启停/查看详情/agent/agents— 编辑 Agent,Skill Tab 应显示 test-greeting 可选
- Step 5: 验证 Agent 对话中的 skill 注入
在 /agent/chat 中与配置了 test-greeting skill 的 Agent 对话,检查:
- System prompt 中应包含
<available_skills>XML 索引 - Agent 应能调用
read_skill工具读取完整内容 - 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"