From: Codebuddy Date: Fri, 14 Aug 2026 02:25:03 +0000 (+0800) Subject: feat: 支持在会话中管理自定义 skill(上传/删除/更新/热加载) X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=0e072f3597246c586e2127c8bb0b359955a55edb;p=codebuddy-web.git feat: 支持在会话中管理自定义 skill(上传/删除/更新/热加载) - 后端新增全局 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 --- diff --git a/backend/app.py b/backend/app.py index 3314f99..20d2658 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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//(临时目录交换,避免损坏已有 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): diff --git a/frontend/index.html b/frontend/index.html index 9e6f0c5..88465b3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -229,6 +229,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over + @@ -250,6 +251,29 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over + + @@ -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, '&').replace(//g, '>'); } + + 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 = '
暂无自定义 skill。上传一个 zip 开始。
'; + return; + } + skillsList.innerHTML = skills.map(s => + '
' + + '
' + escHtml(s.name) + + (s.has_skill_md ? '' : ' (缺 SKILL.md)') + '
' + + (s.description ? '
' + escHtml(s.description) + '
' : '
') + + '
' + + '' + + '' + + '' + + '
' + ).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 = '';