码桶
发现社区成员的开源项目
skill.js23.9 KB
/**
* 技能系统路由
* 提供:技能列表 / 安装卸载 / 自定义技能 CRUD / ZIP 批量上传
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const os = require('os');
const multer = require('multer');
const AdmZip = require('adm-zip');
const db = require('../lib/db');
const Auth = require('../lib/auth');
const skillPlugin = require('../plugins/skill');
// multer 临时存储(内存中,避免磁盘残留)
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024, files: 20 }, // 单文件 20MB,最多 20 个
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const mime = file.mimetype;
if (ext === '.zip' || mime === 'application/zip' || mime === 'application/x-zip-compressed') {
cb(null, true);
} else {
cb(new Error('仅支持 ZIP 文件'));
}
},
});
/** 当前用户(开源单用户版:始终为本地管理员,与 /api/me 一致) */
async function getSessionUser(req) {
return Auth.currentUser(req);
}
/** 验证 bot 属于当前用户 */
async function checkBotOwner(userId, botId) {
const bot = await db.row('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]);
return !!bot;
}
// ==================== SkillHub 市场集成 ====================
const SKILLHUB_API = process.env.SKILLHUB_API_BASE || 'https://clawhub.ai';
const SKILLHUB_TIMEOUT = 12000; // 12s 超时
/** 解析 SKILL.md 的 YAML frontmatter */
function parseSkillMd(raw) {
const fmMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
const meta = { name: '', description: '', license: '', author: '', version: '', tags: [] };
let prompt = raw;
if (fmMatch) {
prompt = (fmMatch[2] || '').trim();
const yaml = fmMatch[1];
// 简单 YAML 解析(不用库,只解析顶层字段)
const nameM = yaml.match(/^name:\s*(.+)$/m);
const descM = yaml.match(/^description:\s*(.+)$/m);
const licM = yaml.match(/^license:\s*(.+)$/m);
if (nameM) meta.name = nameM[1].trim().replace(/^["']|["']$/g, '');
if (descM) meta.description = descM[1].trim().replace(/^["']|["']$/g, '');
if (licM) meta.license = licM[1].trim().replace(/^["']|["']$/g, '');
// metadata 子字段
const mdBlock = yaml.match(/^metadata:\s*\n([\s\S]*?)(?=\n\S|\n?$)/m);
if (mdBlock) {
const authM = mdBlock[1].match(/^\s+author:\s*(.+)$/m);
const verM = mdBlock[1].match(/^\s+version:\s*(.+)$/m);
if (authM) meta.author = authM[1].trim().replace(/^["']|["']$/g, '');
if (verM) meta.version = verM[1].trim().replace(/^["']|["']$/g, '');
const tagsM = mdBlock[1].match(/^\s+tags:\s*\n([\s\S]*?)(?=\n\S|\n?$)/m);
if (tagsM) {
meta.tags = tagsM[1].split('\n').map(l => l.replace(/^\s*-\s*/, '').trim()).filter(Boolean);
}
}
}
// 无 frontmatter 时用首行 # 标题作 name
if (!meta.name) {
const h1 = raw.match(/^#\s+(.+)$/m);
if (h1) meta.name = h1[1].trim();
}
if (!meta.description) {
meta.description = prompt.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('>'))[0]?.trim().slice(0, 100) || '';
}
return { meta, prompt };
}
/** 带超时的 fetch */
async function fetchWithTimeout(url, opts = {}, timeout = SKILLHUB_TIMEOUT) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeout);
try {
const res = await fetch(url, { ...opts, signal: ctrl.signal });
return res;
} finally {
clearTimeout(t);
}
}
/** 根据关键词返回技能标签 */
function guessLabel(name, description) {
const text = (name + ' ' + (description || '')).toLowerCase();
const map = [
[/\b(pdf|doc|document|word|excel|ppt|office|file|文件|文档|表格)\b/, 'DOC'],
[/\b(web|browser|网页|爬虫|scrap|puppeteer|playwright|selenium)\b/, 'WEB'],
[/\b(image|图片|photo|照片|画|图|生成.*图)\b/, 'IMG'],
[/\b(video|视频|mp4)\b/, 'VID'],
[/\b(audio|music|音乐|音频|sound|voice)\b/, 'AUD'],
[/\b(git|github|repo|代码|code|dev|开发|编程|program)\b/, 'DEV'],
[/\b(translate|翻译|i18n)\b/, 'TR'],
[/\b(search|搜索|查找|检索)\b/, 'SR'],
[/\b(ai|chatgpt|claude|gpt|llm|模型|openai|anthropic)\b/, 'AI'],
[/\b(data|数据|分析|analytics|统计|chart|图表)\b/, 'DT'],
[/\b(email|邮件|mail|gmail)\b/, 'ML'],
[/\b(calendar|日历|日程|schedule|remind|提醒)\b/, 'CL'],
[/\b(twitter|x\.com|微博|social|社交)\b/, 'SC'],
[/\b(weather|天气)\b/, 'WT'],
[/\b(docker|容器|k8s|kubernetes)\b/, 'DK'],
[/\b(security|安全|scan|扫描|漏洞)\b/, 'SE'],
[/\b(database|数据库|sql|db|mysql|postgres)\b/, 'DB'],
[/\b(api|rest|graphql)\b/, 'API'],
[/\b(test|测试|unit|jest)\b/, 'TS'],
[/\b(deploy|部署|ci|cd|devops)\b/, 'OP'],
[/\b(markdown|md|笔记|note|blog|写作|write)\b/, 'MD'],
[/\b(shell|bash|terminal|cli|命令行)\b/, 'SH'],
[/\b(linux|ubuntu|debian|server)\b/, 'LX'],
[/\b(notion|slack|jira|trello)\b/, 'IN'],
];
for (const [re, label] of map) {
if (re.test(text)) return label;
}
return '';
}
// ==================== 技能列表 ====================
/** POST /api/skill/list — 获取 bot 的所有可用技能(含安装状态) */
router.post('/list', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { bot_id } = req.body || {};
if (!bot_id) return res.json({ ok: false, msg: '缺少 bot_id' });
if (!await checkBotOwner(user.id, bot_id)) {
return res.json({ ok: false, msg: '无权操作此机器人' });
}
const skillList = await skillPlugin.getUserSkillList(bot_id);
const customs = await skillPlugin.listCustomSkills(user.id);
res.json({
ok: true,
skills: skillList,
custom_skills: customs.map(s => ({
id: s.id,
name: s.name,
icon: s.icon,
description: s.description,
prompt: s.prompt,
created_at: s.created_at,
updated_at: s.updated_at,
})),
is_member: true,
});
} catch (e) {
console.error('[skill/list]', e.message);
res.json({ ok: false, msg: e.message });
}
});
// ==================== 安装 / 卸载 ====================
/** POST /api/skill/install — 安装技能到 bot */
router.post('/install', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { bot_id, skill_id } = req.body || {};
if (!bot_id || !skill_id) return res.json({ ok: false, msg: '缺少参数' });
if (!await checkBotOwner(user.id, bot_id)) {
return res.json({ ok: false, msg: '无权操作此机器人' });
}
const installed = await skillPlugin.getInstalledSkills(bot_id);
if (installed.includes(skill_id)) {
return res.json({ ok: false, msg: '技能已安装' });
}
installed.push(skill_id);
await skillPlugin.saveInstalledSkills(bot_id, installed);
res.json({ ok: true, msg: '安装成功' });
} catch (e) {
console.error('[skill/install]', e.message);
res.json({ ok: false, msg: e.message });
}
});
/** POST /api/skill/uninstall — 从 bot 卸载技能 */
router.post('/uninstall', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { bot_id, skill_id } = req.body || {};
if (!bot_id || !skill_id) return res.json({ ok: false, msg: '缺少参数' });
if (!await checkBotOwner(user.id, bot_id)) {
return res.json({ ok: false, msg: '无权操作此机器人' });
}
const installed = await skillPlugin.getInstalledSkills(bot_id);
if (!installed.includes(skill_id)) {
return res.json({ ok: false, msg: '技能未安装' });
}
const newList = installed.filter(s => s !== skill_id);
await skillPlugin.saveInstalledSkills(bot_id, newList);
res.json({ ok: true, msg: '卸载成功' });
} catch (e) {
console.error('[skill/uninstall]', e.message);
res.json({ ok: false, msg: e.message });
}
});
// ==================== 自定义技能 CRUD ====================
/** POST /api/skill/create — 创建自定义技能 */
router.post('/create', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { name, icon, description, prompt } = req.body || {};
const result = await skillPlugin.createCustomSkill(user.id, { name, icon, description, prompt });
res.json(result);
} catch (e) {
console.error('[skill/create]', e.message);
res.json({ ok: false, msg: e.message });
}
});
/** POST /api/skill/update — 更新自定义技能 */
router.post('/update', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { id, name, icon, description, prompt } = req.body || {};
if (!id) return res.json({ ok: false, msg: '缺少技能 ID' });
const result = await skillPlugin.updateCustomSkill(user.id, id, { name, icon, description, prompt });
res.json(result);
} catch (e) {
console.error('[skill/update]', e.message);
res.json({ ok: false, msg: e.message });
}
});
/** POST /api/skill/delete — 删除自定义技能 */
router.post('/delete', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { id } = req.body || {};
if (!id) return res.json({ ok: false, msg: '缺少技能 ID' });
const result = await skillPlugin.deleteCustomSkill(user.id, id);
res.json(result);
} catch (e) {
console.error('[skill/delete]', e.message);
res.json({ ok: false, msg: e.message });
}
});
// ==================== SkillHub 市场 ====================
/** POST /api/skill/search-market — 搜索 SkillHub 技能市场 */
router.post('/search-market', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { q, limit = 20, sort = 'downloads', page = 1 } = req.body || {};
let url;
if (q && q.trim()) {
url = `${SKILLHUB_API}/api/v1/search?q=${encodeURIComponent(q.trim())}&limit=${limit}&nonSuspiciousOnly=true`;
} else {
url = `${SKILLHUB_API}/api/v1/skills?sort=${sort}&limit=${limit}&nonSuspiciousOnly=true`;
if (page > 1) {
// ClawHub 用 cursor 分页,简单起见只取第一页
}
}
console.log('[skill/search-market] fetching', url);
const resp = await fetchWithTimeout(url, {
headers: { 'Accept': 'application/json', 'User-Agent': 'ngbot-skill-importer/1.0' },
});
if (!resp.ok) {
console.error('[skill/search-market] HTTP', resp.status, resp.statusText);
return res.json({ ok: false, msg: `SkillHub 查询失败 (${resp.status})`, results: [] });
}
const data = await resp.json();
const items = Array.isArray(data) ? data : (data.items || data.results || []);
const results = items.map(it => ({
slug: it.slug || it.name || '',
name: it.name || it.title || it.slug || '',
description: (it.description || it.summary || it.shortDescription || '').slice(0, 200),
version: it.version || (it.latestVersion || ''),
downloads: it.downloads || it.downloadCount || 0,
stars: it.stars || it.starCount || 0,
icon: it.icon || it.emoji || guessLabel(it.name || it.slug, it.description || ''),
owner: it.owner || it.author || '',
highlighted: !!it.highlighted,
score: it.score || 0,
// 来源 URL
url: `https://clawhub.ai/skills/${it.slug || it.name}`,
}));
res.json({ ok: true, results, total: results.length, rawLength: items.length });
} catch (e) {
console.error('[skill/search-market]', e.message);
res.json({ ok: false, msg: `SkillHub 搜索异常: ${e.message}`, results: [] });
}
});
/** POST /api/skill/preview-market — 预览 SkillHub 技能的 SKILL.md */
router.post('/preview-market', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { slug } = req.body || {};
if (!slug) return res.json({ ok: false, msg: '缺少技能 slug' });
const url = `${SKILLHUB_API}/api/v1/skills/${encodeURIComponent(slug)}/file?path=SKILL.md`;
console.log('[skill/preview-market] fetching', url);
const resp = await fetchWithTimeout(url, {
headers: { 'Accept': 'text/plain, text/markdown, */*', 'User-Agent': 'ngbot-skill-importer/1.0' },
});
if (!resp.ok) {
console.error('[skill/preview-market] HTTP', resp.status);
return res.json({ ok: false, msg: `获取技能内容失败 (${resp.status})` });
}
const raw = await resp.text();
if (!raw || raw.length < 10) {
return res.json({ ok: false, msg: '技能内容为空' });
}
const { meta, prompt } = parseSkillMd(raw);
res.json({
ok: true,
slug,
name: meta.name,
description: meta.description,
prompt,
icon: guessLabel(meta.name, meta.description),
license: meta.license,
author: meta.author,
version: meta.version,
tags: meta.tags,
rawLength: raw.length,
// 是否包含 function/tool 定义(需要运行环境,提示用户可能不完全兼容)
hasTools: /function\s*\(|async\s+function|def\s+\w+|```(python|javascript|bash|sh)/i.test(prompt),
});
} catch (e) {
console.error('[skill/preview-market]', e.message);
res.json({ ok: false, msg: `获取技能内容异常: ${e.message}` });
}
});
/** POST /api/skill/import-market — 从 SkillHub 导入技能 */
router.post('/import-market', async (req, res) => {
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const { slug } = req.body || {};
if (!slug) return res.json({ ok: false, msg: '缺少技能 slug' });
// 1. 获取 SKILL.md
const url = `${SKILLHUB_API}/api/v1/skills/${encodeURIComponent(slug)}/file?path=SKILL.md`;
console.log('[skill/import-market] fetching', url);
const resp = await fetchWithTimeout(url, {
headers: { 'Accept': 'text/plain, text/markdown, */*', 'User-Agent': 'ngbot-skill-importer/1.0' },
});
if (!resp.ok) {
return res.json({ ok: false, msg: `获取技能内容失败 (${resp.status})` });
}
const raw = await resp.text();
if (!raw || raw.length < 10) {
return res.json({ ok: false, msg: '技能内容为空' });
}
const { meta, prompt } = parseSkillMd(raw);
if (!meta.name) {
return res.json({ ok: false, msg: '无法解析技能名称' });
}
// 2. 检查是否已导入过(按名称去重)
const existing = await db.rows(
"SELECT id FROM custom_skills WHERE user_id = ? AND name = ?",
[user.id, meta.name]
);
if (existing.length > 0) {
return res.json({ ok: false, msg: `技能「${meta.name}」已存在(ID: ${existing[0].id}),请勿重复导入`, exists: true });
}
// 3. 创建自定义技能
const result = await skillPlugin.createCustomSkill(user.id, {
name: meta.name,
icon: guessLabel(meta.name, meta.description),
description: meta.description || `${meta.name} — 从 SkillHub 导入(${slug})`,
prompt: prompt,
});
if (result.ok) {
console.log(`[skill/import-market] imported "${meta.name}" (slug=${slug}) → id=${result.id}`);
}
res.json({ ...result, skillName: meta.name, slug });
} catch (e) {
console.error('[skill/import-market]', e.message);
res.json({ ok: false, msg: `导入异常: ${e.message}` });
}
});
// ==================== ZIP 批量上传 ====================
/**
* POST /api/skill/upload-zip — 批量上传 ZIP 技能包
* 支持:一个 ZIP 含多个技能文件夹、一次上传多个 ZIP
* 自动校验 SKILL.md、跳过重复、返回逐文件/逐技能结果
*/
router.post('/upload-zip', upload.array('files', 20), async (req, res) => {
const t0 = Date.now();
try {
const user = await getSessionUser(req);
if (!user) return res.status(401).json({ ok: false, msg: '请先登录' });
const files = req.files;
if (!files || files.length === 0) {
return res.json({ ok: false, msg: '请选择 ZIP 文件' });
}
const results = []; // 逐文件结果
for (const file of files) {
const fileName = file.originalname;
const fileResult = { file: fileName, status: 'processing', skills: [], errors: [] };
try {
// ZIP 炸弹防护:文件大小上限 50MB,条目数上限 1000
const MAX_ZIP_SIZE = 50 * 1024 * 1024;
const MAX_ENTRIES = 1000;
if (file.size > MAX_ZIP_SIZE) {
fileResult.status = 'error';
fileResult.errors.push(`ZIP 文件过大(${(file.size / 1024 / 1024).toFixed(1)}MB),上限 50MB`);
results.push(fileResult);
continue;
}
// 1. 尝试解压
let zip;
try {
zip = new AdmZip(file.buffer);
} catch (zipErr) {
fileResult.status = 'error';
fileResult.errors.push(`ZIP 文件损坏或无法解压: ${zipErr.message}`);
results.push(fileResult);
continue;
}
const entries = zip.getEntries();
if (entries.length > MAX_ENTRIES) {
fileResult.status = 'error';
fileResult.errors.push(`ZIP 条目过多(${entries.length}个),上限 ${MAX_ENTRIES} 个`);
results.push(fileResult);
continue;
}
if (entries.length === 0) {
fileResult.status = 'error';
fileResult.errors.push('ZIP 文件为空');
results.push(fileResult);
continue;
}
// 2. 建立文件夹 → 文件映射
// key: 相对路径的文件夹(如 "skill-name/" 或 "pack/skill-name/")
const dirFiles = new Map(); // folderPath -> [{ entryName, fileName, isDir }]
for (const entry of entries) {
if (entry.isDirectory) continue;
const entryName = entry.entryName.replace(/\\/g, '/');
const dir = path.posix.dirname(entryName);
const base = path.posix.basename(entryName);
if (!dirFiles.has(dir)) dirFiles.set(dir, []);
dirFiles.get(dir).push({ entryName, fileName: base, entry });
}
// 3. 找到包含 SKILL.md 的文件夹
const skillFolders = [];
for (const [dir, items] of dirFiles) {
const skillMd = items.find(f => f.fileName.toUpperCase() === 'SKILL.MD');
if (skillMd) {
// 用最内层有 SKILL.md 的目录名作为默认技能名
const folderName = path.posix.basename(dir) || dir.split('/').filter(Boolean).pop() || 'unknown';
skillFolders.push({ dir, folderName, skillMdEntry: skillMd.entry, items });
}
}
if (skillFolders.length === 0) {
fileResult.status = 'error';
fileResult.errors.push('未找到任何包含 SKILL.md 的文件夹');
results.push(fileResult);
continue;
}
// 4. 处理每个技能文件夹
for (const sf of skillFolders) {
const skillResult = { name: sf.folderName, status: 'pending', msg: '' };
try {
// 读取 SKILL.md 内容
const raw = sf.skillMdEntry.getData().toString('utf-8');
if (!raw || raw.trim().length < 10) {
skillResult.status = 'skipped';
skillResult.msg = 'SKILL.md 内容为空或过短';
fileResult.skills.push(skillResult);
continue;
}
const { meta, prompt } = parseSkillMd(raw);
if (!meta.name) {
skillResult.status = 'skipped';
skillResult.msg = 'SKILL.md 中未找到技能名称';
fileResult.skills.push(skillResult);
continue;
}
skillResult.name = meta.name;
// 检查重复
const existing = await db.rows(
"SELECT id FROM custom_skills WHERE user_id = ? AND name = ?",
[user.id, meta.name]
);
if (existing.length > 0) {
skillResult.status = 'skipped';
skillResult.msg = `技能「${meta.name}」已存在,自动跳过`;
fileResult.skills.push(skillResult);
continue;
}
// 创建自定义技能
const createResult = await skillPlugin.createCustomSkill(user.id, {
name: meta.name,
icon: guessLabel(meta.name, meta.description),
description: meta.description || `${meta.name} — 从 ZIP 技能包导入`,
prompt: prompt,
});
if (createResult.ok) {
skillResult.status = 'imported';
skillResult.msg = `导入成功 (ID: ${createResult.id})`;
} else {
skillResult.status = 'failed';
skillResult.msg = createResult.msg || '导入失败';
}
} catch (e) {
skillResult.status = 'failed';
skillResult.msg = e.message;
}
fileResult.skills.push(skillResult);
}
// 汇总文件状态
const imported = fileResult.skills.filter(s => s.status === 'imported').length;
const skipped = fileResult.skills.filter(s => s.status === 'skipped').length;
const failed = fileResult.skills.filter(s => s.status === 'failed').length;
const errors = fileResult.errors.length;
if (errors > 0 || failed === fileResult.skills.length) {
fileResult.status = 'error';
} else if (imported === 0 && skipped > 0) {
fileResult.status = 'skipped';
} else if (failed > 0) {
fileResult.status = 'partial';
} else {
fileResult.status = 'success';
}
fileResult.summary = { total: fileResult.skills.length, imported, skipped, failed };
} catch (e) {
fileResult.status = 'error';
fileResult.errors.push(e.message);
}
results.push(fileResult);
} // end for each file
const elapsed = Date.now() - t0;
console.log(`[skill/upload-zip] ${files.length} files → ${results.length} results (${elapsed}ms)`);
// 汇总
const totalFiles = results.length;
const totalSkills = results.reduce((sum, r) => sum + (r.skills?.length || 0), 0);
const totalImported = results.reduce((sum, r) => sum + (r.skills?.filter(s => s.status === 'imported').length || 0), 0);
const totalSkipped = results.reduce((sum, r) => sum + (r.skills?.filter(s => s.status === 'skipped').length || 0), 0);
const totalFailed = results.reduce((sum, r) => sum + (r.skills?.filter(s => s.status === 'failed').length || 0), 0);
res.json({
ok: true,
results,
summary: {
files: totalFiles,
skills: totalSkills,
imported: totalImported,
skipped: totalSkipped,
failed: totalFailed,
elapsed_ms: elapsed,
},
});
} catch (e) {
console.error('[skill/upload-zip]', e.message);
res.json({ ok: false, msg: `上传处理异常: ${e.message}` });
}
});
module.exports = router;