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