码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/routes/skill.js
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;