]> acesimba.cloud Git - codebuddy-web.git/commitdiff
feat: 支持在会话中管理自定义 skill(上传/删除/更新/热加载)
authorCodebuddy <codebuddy@localhost>
Fri, 14 Aug 2026 02:25:03 +0000 (10:25 +0800)
committerCodebuddy <codebuddy@localhost>
Fri, 14 Aug 2026 02:25:03 +0000 (10:25 +0800)
- 后端新增全局 skill 管理接口(~/.codebuddy/skills):
  GET/POST/PUT/DELETE /api/tasks/{id}/skills[/{name}]
  GET /api/tasks/{id}/skills/{name}/content
  POST /api/tasks/{id}/skills/{name}/notify(向运行中的会话注入 skill 路径实现热加载)
- zip 上传带 zip-slip 防护、单顶层目录校验、必需 SKILL.md 校验
- 前端新增「🧩 技能」按钮与弹窗:上传 zip、列出、删除、在线编辑 SKILL.md、通知加载
- 已实证 codebuddy 从 ~/.codebuddy/skills 自动发现 skill

backend/app.py
frontend/index.html

index 3314f997a5cdd2fb996935a7db4fb5296fc43f20..20d26583325655819f708de16a61b3bfb96809c0 100644 (file)
@@ -25,6 +25,8 @@ from pathlib import Path
 import re
 import time
 import threading
+import io
+import zipfile
 
 import uvicorn
 from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
@@ -758,6 +760,261 @@ async def upload_image(request: Request):
     return {"path": str(fpath)}
 
 
+# ==================== 自定义 Skill 管理(全局 ~/.codebuddy/skills) ====================
+SKILLS_ROOT = Path.home() / ".codebuddy" / "skills"
+SKILLS_ROOT.mkdir(parents=True, exist_ok=True)
+_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
+MAX_SKILL_ZIP = 5 * 1024 * 1024  # 5MB
+
+
+def _safe_skill_path(name: str):
+    """校验 skill 名称并返回其绝对目录;非法或越界返回 None。"""
+    if not _NAME_RE.match(name or ""):
+        return None
+    root = SKILLS_ROOT.resolve()
+    p = (root / name).resolve()
+    if p == root or root not in p.parents:
+        return None
+    return p
+
+
+def _parse_skill_frontmatter(text: str):
+    """极简解析 SKILL.md frontmatter,提取 name / description。"""
+    name = description = None
+    if not text.startswith("---"):
+        return name, description
+    lines = text.splitlines()
+    end = None
+    for i in range(1, len(lines)):
+        if lines[i].strip() == "---":
+            end = i
+            break
+    if end is None:
+        return name, description
+    for line in lines[1:end]:
+        s = line.strip()
+        if s.startswith("name:"):
+            name = s[len("name:"):].strip().strip('"').strip("'")
+        elif s.startswith("description:"):
+            description = s[len("description:"):].strip().strip('"').strip("'")
+    return name, description
+
+
+def _list_skills():
+    out = []
+    root = SKILLS_ROOT.resolve()
+    if not root.exists():
+        return out
+    for d in sorted(root.iterdir()):
+        if not d.is_dir():
+            continue
+        skill_md = d / "SKILL.md"
+        name = description = None
+        if skill_md.is_file():
+            try:
+                name, description = _parse_skill_frontmatter(
+                    skill_md.read_text(encoding="utf-8", errors="replace"))
+            except OSError:
+                pass
+        out.append({
+            "name": name or d.name,
+            "dir_name": d.name,
+            "description": description or "",
+            "has_skill_md": skill_md.is_file(),
+            "mtime": int(d.stat().st_mtime),
+        })
+    return out
+
+
+def _extract_skill_zip(zf: zipfile.ZipFile, dest: Path):
+    """把 zip 内容(去掉单个顶层目录)解压到 dest,防 zip-slip。"""
+    dest.mkdir(parents=True, exist_ok=True)
+    tops = set()
+    for n in zf.namelist():
+        p = n.split("/")[0]
+        if p:
+            tops.add(p)
+    if len(tops) != 1:
+        raise ValueError("zip 须以单个 skill 目录为顶层(如 my-skill/...)")
+    top = tops.pop()
+    for info in zf.infolist():
+        rel = info.filename
+        if rel == top or rel.startswith(top + "/"):
+            rel = rel[len(top) + 1:]
+        if not rel:
+            continue
+        target = (dest / rel).resolve()
+        if dest.resolve() not in target.parents and target != dest.resolve():
+            raise ValueError("zip 含非法路径(zip-slip): " + info.filename)
+        if info.is_dir():
+            target.mkdir(parents=True, exist_ok=True)
+        else:
+            target.parent.mkdir(parents=True, exist_ok=True)
+            with zf.open(info) as src, open(target, "wb") as fdst:
+                shutil.copyfileobj(src, fdst, 8192)
+
+
+def _install_skill_zip(zf: zipfile.ZipFile, name: str):
+    """校验名称,解压 zip 到 SKILLS_ROOT/<name>/(临时目录交换,避免损坏已有 skill)。"""
+    if not _NAME_RE.match(name):
+        raise ValueError("skill 名称非法(仅允许字母数字 - _,长度 1-64)")
+    root = SKILLS_ROOT.resolve()
+    dest = root / name
+    tmp = root / (".tmp_" + name + "_" + secrets.token_hex(4))
+    try:
+        _extract_skill_zip(zf, tmp)
+        if not (tmp / "SKILL.md").is_file():
+            raise ValueError("zip 内未找到 SKILL.md")
+        if dest.exists():
+            shutil.rmtree(dest)
+        tmp.rename(dest)
+    finally:
+        if tmp.exists():
+            shutil.rmtree(tmp, ignore_errors=True)
+    return dest
+
+
+def _decode_zip_body(body: dict):
+    import base64
+    b64 = body.get("data") or ""
+    try:
+        raw = base64.b64decode(b64)
+    except Exception:
+        raise ValueError("无效的 base64 数据")
+    if len(raw) == 0:
+        raise ValueError("zip 内容为空")
+    if len(raw) > MAX_SKILL_ZIP:
+        raise ValueError(f"zip 过大(上限 {MAX_SKILL_ZIP // 1024 // 1024}MB)")
+    try:
+        return zipfile.ZipFile(io.BytesIO(raw))
+    except Exception:
+        raise ValueError("不是有效的 zip 文件")
+
+
+@app.get("/api/tasks/{task_id}/skills")
+async def api_list_skills(task_id: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    return JSONResponse({"ok": True, "skills": _list_skills(), "root": str(SKILLS_ROOT)})
+
+
+@app.get("/api/tasks/{task_id}/skills/{name}/content")
+async def api_skill_content(task_id: str, name: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    p = _safe_skill_path(name)
+    if p is None:
+        return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+    skill_md = p / "SKILL.md"
+    if not skill_md.is_file():
+        return JSONResponse({"error": "该 skill 不存在 SKILL.md"}, status_code=404)
+    return JSONResponse({"ok": True, "content": skill_md.read_text(encoding="utf-8", errors="replace")})
+
+
+@app.post("/api/tasks/{task_id}/skills")
+async def api_upload_skill(task_id: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    try:
+        body = await request.json()
+    except Exception:
+        return JSONResponse({"error": "invalid json body"}, status_code=400)
+    try:
+        zf = _decode_zip_body(body)
+    except ValueError as e:
+        return JSONResponse({"error": str(e)}, status_code=400)
+    tops = set(n.split("/")[0] for n in zf.namelist() if n.split("/")[0])
+    name = body.get("name") or (tops.pop() if len(tops) == 1 else None)
+    if not name:
+        zf.close()
+        return JSONResponse({"error": "无法确定 skill 名称:zip 需以单个目录为顶层,或提供 name 字段"}, status_code=400)
+    if not _NAME_RE.match(name):
+        zf.close()
+        return JSONResponse({"error": f"skill 名称非法: {name}(仅允许字母数字 - _,长度 1-64)"}, status_code=400)
+    try:
+        with zf:
+            dest = _install_skill_zip(zf, name)
+    except ValueError as e:
+        return JSONResponse({"error": f"解压失败: {e}"}, status_code=400)
+    logger.info(f"上传 skill: {name} -> {dest}")
+    return JSONResponse({"ok": True, "name": name, "path": str(dest)})
+
+
+@app.put("/api/tasks/{task_id}/skills/{name}")
+async def api_update_skill(task_id: str, name: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    p = _safe_skill_path(name)
+    if p is None:
+        return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+    ctype = request.headers.get("content-type", "")
+    # 模式1:zip 覆盖
+    if "multipart/form-data" in ctype or "application/json" in ctype:
+        try:
+            body = await request.json()
+        except Exception:
+            return JSONResponse({"error": "invalid json body"}, status_code=400)
+        if body.get("data"):
+            try:
+                zf = _decode_zip_body(body)
+            except ValueError as e:
+                return JSONResponse({"error": str(e)}, status_code=400)
+            try:
+                with zf:
+                    dest = _install_skill_zip(zf, name)
+            except ValueError as e:
+                return JSONResponse({"error": f"解压失败: {e}"}, status_code=400)
+            return JSONResponse({"ok": True, "name": name, "path": str(dest), "mode": "zip"})
+        # 模式2:直接更新 SKILL.md 内容
+        content = body.get("content")
+        if content is None:
+            return JSONResponse({"error": "需提供 data(zip) 或 content 字段"}, status_code=400)
+        skill_md = p / "SKILL.md"
+        if not skill_md.is_file():
+            return JSONResponse({"error": "该 skill 不存在 SKILL.md,无法更新内容"}, status_code=404)
+        skill_md.write_text(content, encoding="utf-8")
+        return JSONResponse({"ok": True, "name": name, "mode": "content"})
+    return JSONResponse({"error": "不支持的 content-type"}, status_code=400)
+
+
+@app.delete("/api/tasks/{task_id}/skills/{name}")
+async def api_delete_skill(task_id: str, name: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    p = _safe_skill_path(name)
+    if p is None:
+        return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+    if not p.exists():
+        return JSONResponse({"error": "skill 不存在"}, status_code=404)
+    shutil.rmtree(p)
+    logger.info(f"删除 skill: {name}")
+    return JSONResponse({"ok": True, "name": name})
+
+
+@app.post("/api/tasks/{task_id}/skills/{name}/notify")
+async def api_notify_skill(task_id: str, name: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    p = _safe_skill_path(name)
+    if p is None:
+        return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+    if not p.exists():
+        return JSONResponse({"error": "skill 不存在"}, status_code=404)
+    entry = RUNNING.get(task_id)
+    if (not entry or entry.get("proc") is None or entry["proc"].poll() is not None
+            or entry.get("master_fd") is None):
+        return JSONResponse({"ok": False, "notified": False,
+                              "error": "该任务当前未在运行,无法热加载;请重启/重连任务后由 codebuddy 自动发现该 skill"})
+    msg = ("\n【系统通知】用户刚上传/更新了 skill:" + name +
+           ",路径 " + str(p) + "/SKILL.md。" +
+           "如适用,请用 Read 工具载入该 SKILL.md 并按其指引执行。\n")
+    try:
+        os.write(entry["master_fd"], msg.encode("utf-8"))
+    except Exception as e:
+        return JSONResponse({"ok": False, "notified": False, "error": f"注入失败: {e}"})
+    return JSONResponse({"ok": True, "notified": True})
+
+
 # ==================== WebSocket 终端 ====================
 @app.websocket("/ws")
 async def websocket_terminal(websocket: WebSocket):
index 9e6f0c580f6567e966e1963e6c69fbd4ce6c05e2..88465b3d15c92f7ad7f6c303872cb73753403af1 100644 (file)
@@ -229,6 +229,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
       <button class="action-btn" id="btn-restart" title="重启会话(续上次历史)">↻ 重启会话</button>
       <button class="action-btn" id="btn-list" title="打开任务列表">≡ 列表</button>
       <button class="action-btn" id="btn-paste" title="粘贴文字或图片">📋 粘贴</button>
+      <button class="action-btn" id="btn-skills" title="管理自定义 skill">🧩 技能</button>
       <button class="action-btn" id="btn-close" title="关闭窗口">✕ 关闭</button>
     </div>
   </div>
@@ -250,6 +251,29 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
       <input type="file" id="paste-file-input" accept="image/*" style="display:none;">
     </div>
   </div>
+  <!-- 技能管理 modal -->
+  <div id="skills-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:200; align-items:center; justify-content:center;">
+    <div style="background:#1a1a2e; border:1px solid #74c0fc; border-radius:12px; padding:18px; width:640px; max-width:92vw; max-height:86vh; overflow:auto;">
+      <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
+        <div style="color:#74c0fc; font-size:16px;">🧩 会话技能(全局 ~/.codebuddy/skills)</div>
+        <button class="icon-btn" id="skills-close" title="关闭">✕</button>
+      </div>
+      <div style="font-size:12px; color:#999; margin-bottom:10px; line-height:1.5;">上传整目录 zip(须以单个 skill 目录为顶层,内含 <code style="color:#4ecca3;">SKILL.md</code>)。新技能对所有会话可见;当前运行中的会话可点「通知加载」热加载,否则重启/重连任务后由 codebuddy 自动发现。</div>
+      <div style="display:flex; gap:8px; margin-bottom:12px;">
+        <input type="file" id="skills-file" accept=".zip" style="flex:1; color:#ccc; font-size:13px;">
+        <button class="send-btn" id="skills-upload">上传</button>
+      </div>
+      <div id="skills-list" style="display:flex; flex-direction:column; gap:8px;"></div>
+      <div id="skills-edit" style="display:none; margin-top:14px; border-top:1px solid rgba(255,255,255,0.1); padding-top:12px;">
+        <div style="color:#e0e0e0; font-size:13px; margin-bottom:6px;">编辑 <code id="skills-edit-name" style="color:#4ecca3;"></code> 的 SKILL.md</div>
+        <textarea id="skills-edit-area" rows="14" style="width:100%; box-sizing:border-box; background:#0f0f1e; color:#e0e0e0; border:1px solid rgba(255,255,255,0.15); border-radius:8px; padding:10px; font-family:'Cascadia Code',Menlo,monospace; font-size:13px; line-height:1.5; resize:vertical;"></textarea>
+        <div style="display:flex; gap:8px; justify-content:flex-end; margin-top:8px;">
+          <button class="del-btn del-cancel" id="skills-edit-cancel">取消</button>
+          <button class="send-btn" id="skills-edit-save">保存</button>
+        </div>
+      </div>
+    </div>
+  </div>
 </div>
 
 <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
@@ -764,6 +788,147 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     renderPasteImgChips();
   };
 
+  // ========== 技能管理 ==========
+  const skillsModal = document.getElementById('skills-modal');
+  const skillsList = document.getElementById('skills-list');
+  const skillsFile = document.getElementById('skills-file');
+  const skillsEdit = document.getElementById('skills-edit');
+  const skillsEditArea = document.getElementById('skills-edit-area');
+  const skillsEditName = document.getElementById('skills-edit-name');
+  let skillsEditTarget = null;
+
+  function openSkillsModal() {
+    skillsEdit.style.display = 'none';
+    skillsModal.style.display = 'flex';
+    loadSkills();
+  }
+  function closeSkillsModal() {
+    skillsModal.style.display = 'none';
+    if (term && term.focus) term.focus();
+  }
+  document.getElementById('btn-skills').onclick = openSkillsModal;
+  document.getElementById('skills-close').onclick = closeSkillsModal;
+  skillsModal.addEventListener('click', (e) => { if (e.target === skillsModal) closeSkillsModal(); });
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape' && skillsModal.style.display === 'flex') { e.preventDefault(); closeSkillsModal(); }
+  });
+
+  function escHtml(x) { return (x || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
+
+  async function loadSkills() {
+    try {
+      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() });
+      if (!res.ok) return;
+      const data = await res.json();
+      const skills = (data.skills || []);
+      if (!skills.length) {
+        skillsList.innerHTML = '<div style="color:#777; font-size:13px;">暂无自定义 skill。上传一个 zip 开始。</div>';
+        return;
+      }
+      skillsList.innerHTML = skills.map(s =>
+        '<div style="background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); border-radius:8px; padding:10px 12px;">'
+        + '<div style="color:#e0e0e0; font-size:14px; font-weight:600;">' + escHtml(s.name)
+        + (s.has_skill_md ? '' : ' <span style="color:#ff6b6b; font-size:12px;">(缺 SKILL.md)</span>') + '</div>'
+        + (s.description ? '<div style="color:#aaa; font-size:12px; margin:4px 0 8px; line-height:1.4;">' + escHtml(s.description) + '</div>' : '<div style="margin:4px 0 8px;"></div>')
+        + '<div style="display:flex; gap:8px;">'
+        + '<button class="del-btn del-cancel" data-act="edit" data-name="' + escHtml(s.dir_name) + '">编辑</button>'
+        + '<button class="del-btn del-cancel" data-act="notify" data-name="' + escHtml(s.dir_name) + '">通知加载</button>'
+        + '<button class="del-btn del-confirm" data-act="del" data-name="' + escHtml(s.dir_name) + '">删除</button>'
+        + '</div></div>'
+      ).join('');
+      skillsList.querySelectorAll('button[data-act]').forEach(btn => {
+        btn.onclick = () => {
+          const act = btn.dataset.act, n = btn.dataset.name;
+          if (act === 'edit') editSkill(n);
+          else if (act === 'del') deleteSkill(n);
+          else if (act === 'notify') notifySkill(n);
+        };
+      });
+    } catch (e) { console.warn('loadSkills failed', e); }
+  }
+
+  function readFileAsBase64(file) {
+    return new Promise((resolve, reject) => {
+      const r = new FileReader();
+      r.onload = () => { const i = r.result.indexOf(','); resolve(i >= 0 ? r.result.slice(i + 1) : ''); };
+      r.onerror = () => reject(r.error);
+      r.readAsDataURL(file);
+    });
+  }
+
+  document.getElementById('skills-upload').onclick = async () => {
+    const f = skillsFile.files && skillsFile.files[0];
+    if (!f) { alert('请先选择一个 .zip 文件'); return; }
+    try {
+      const b64 = await readFileAsBase64(f);
+      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', {
+        method: 'POST',
+        headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+        body: JSON.stringify({ data: b64 })
+      });
+      const d = await res.json();
+      if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+      term && term.writeln('\x1b[32m✓ 已上传 skill: ' + d.name + '\x1b[0m');
+      skillsFile.value = '';
+      loadSkills();
+    } catch (e) { alert('上传失败: ' + e.message); }
+  };
+
+  async function editSkill(name) {
+    try {
+      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() });
+      const data = await res.json();
+      const s = (data.skills || []).find(x => x.dir_name === name);
+      if (!s) throw new Error('未找到 skill');
+      const res2 = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name) + '/content', { headers: authHeaders() });
+      const d2 = await res2.json();
+      if (!res2.ok || !d2.ok) throw new Error(d2.error || '读取失败');
+      skillsEditName.textContent = s.name || name;
+      skillsEditTarget = name;
+      skillsEditArea.value = d2.content;
+      skillsEdit.style.display = 'block';
+    } catch (e) { alert('打开编辑失败: ' + e.message); }
+  }
+
+  document.getElementById('skills-edit-cancel').onclick = () => { skillsEdit.style.display = 'none'; skillsEditTarget = null; };
+  document.getElementById('skills-edit-save').onclick = async () => {
+    if (!skillsEditTarget) return;
+    const name = skillsEditTarget;
+    try {
+      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name), {
+        method: 'PUT',
+        headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+        body: JSON.stringify({ content: skillsEditArea.value })
+      });
+      const d = await res.json();
+      if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+      term && term.writeln('\x1b[32m✓ 已更新 skill: ' + name + '\x1b[0m');
+      skillsEdit.style.display = 'none'; skillsEditTarget = null;
+      loadSkills();
+    } catch (e) { alert('保存失败: ' + e.message); }
+  };
+
+  async function deleteSkill(name) {
+    if (!confirm('确认删除 skill: ' + name + '?该目录将被永久移除。')) return;
+    try {
+      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name), { method: 'DELETE', headers: authHeaders() });
+      const d = await res.json();
+      if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+      term && term.writeln('\x1b[33m🗑 已删除 skill: ' + name + '\x1b[0m');
+      loadSkills();
+    } catch (e) { alert('删除失败: ' + e.message); }
+  }
+
+  async function notifySkill(name) {
+    try {
+      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name) + '/notify', { method: 'POST', headers: authHeaders() });
+      const d = await res.json();
+      if (!res.ok || !d.ok || !d.notified) throw new Error(d.error || '通知失败');
+      term && term.writeln('\x1b[32m✓ 已向当前会话注入 skill 通知: ' + name + '\x1b[0m');
+      closeSkillsModal();
+    } catch (e) { alert('热加载失败: ' + e.message); }
+  }
+
   // ✕ 清空
   pasteClear.onclick = () => {
     pasteInput.value = '';