From: Codebuddy Date: Sat, 15 Aug 2026 08:49:34 +0000 (+0800) Subject: feat: 首页支持全局 skill 管理(查看/新增/更新/删除)及运行中会话热加载选择器 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=23dc7201d9122cfb7d16d5cd48a0ef5996907d6b;p=codebuddy-web.git feat: 首页支持全局 skill 管理(查看/新增/更新/删除)及运行中会话热加载选择器 - 后端抽共享 helper,新增全局接口 GET/POST /api/skills、GET/PUT/DELETE /api/skills/{name} - 前端将技能弹窗移到 body 级,首页与终端页共用;首页新增「🧩 技能」入口 - 首页「加载」按运行中会话弹出选择器(可多选/全部),无运行会话则提示已全局就位 - 修复 _extract_skill_zip 根级文件+单子目录混合结构的裁前缀 bug,并优先用 SKILL.md frontmatter 命名 --- diff --git a/backend/app.py b/backend/app.py index 2488c58..2dc80ad 100644 --- a/backend/app.py +++ b/backend/app.py @@ -873,7 +873,12 @@ def _extract_skill_zip(zf: zipfile.ZipFile, dest: Path): strip = False dest.mkdir(parents=True, exist_ok=True) for info, name, p in entries: - rel = name[len(top) + 1:] if (strip and top) else name + # 仅对确实以顶层目录开头的条目去掉前缀;根级文件(如 SKILL.md)原样保留, + # 避免“根级文件 + 单个子目录”混合结构时被错误地裁掉前缀(SKILL.md → .md)。 + if strip and top and name.startswith(top + "/"): + rel = name[len(top) + 1:] + else: + rel = name rel = rel.rstrip("/") if rel == "": continue @@ -925,11 +930,87 @@ def _decode_zip_body(body: dict): raise ValueError("不是有效的 zip 文件") +# ---- 共享逻辑:任务级与全局 skill 管理都调用这些 helper ---- +def _do_list_skills(): + return JSONResponse({"ok": True, "skills": _list_skills(), "root": str(SKILLS_ROOT)}) + + +def _do_upload_skill(body: dict): + try: + zf = _decode_zip_body(body) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=400) + entries = _real_entries(zf) + tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0]) + provided = body.get("name") + if provided: + name = provided + else: + # 优先用根目录 SKILL.md frontmatter 的 name(最权威); + # 根级无 SKILL.md 时(如整目录打包 my-skill/SKILL.md)才退回用顶层目录名, + # 否则退回上传文件名。这样“根级 SKILL.md + 单个子目录”的混合结构也能正确命名。 + fm_name = _skill_name_from_zip(zf) + if fm_name: + name = fm_name + elif len(tops) == 1: + name = tops.pop() + else: + fallback = (body.get("filename") or "skill").rsplit(".", 1)[0] + name = fallback + 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)}) + + +def _do_update_skill(name: str, body: dict): + p = _safe_skill_path(name) + if p is None: + return JSONResponse({"error": "skill 名称非法"}, 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"}) + 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"}) + + +def _do_delete_skill(name: str): + 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.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)}) + return _do_list_skills() @app.get("/api/tasks/{task_id}/skills/{name}/content") @@ -953,82 +1034,75 @@ async def api_upload_skill(task_id: str, request: Request): 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) - entries = _real_entries(zf) - tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0]) - provided = body.get("name") - if provided: - name = provided - elif len(tops) == 1: - name = tops.pop() - else: - # 扁平结构(0 个或多个顶层目录,如技能内容直接压缩):优先用 SKILL.md frontmatter 的 name - fallback = (body.get("filename") or "skill").rsplit(".", 1)[0] - name = _skill_name_from_zip(zf) or fallback - 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)}) + return _do_upload_skill(body) @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) + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "invalid json body"}, status_code=400) + return _do_update_skill(name, body) @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) + return _do_delete_skill(name) + + +# ---- 全局接口(任务列表页使用,不依赖某个任务)---- +@app.get("/api/skills") +async def api_global_list_skills(request: Request): + if not _auth_ok(request): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return _do_list_skills() + + +@app.get("/api/skills/{name}/content") +async def api_global_skill_content(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}) + 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/skills") +async def api_global_upload_skill(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) + return _do_upload_skill(body) + + +@app.put("/api/skills/{name}") +async def api_global_update_skill(name: 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) + return _do_update_skill(name, body) + + +@app.delete("/api/skills/{name}") +async def api_global_delete_skill(name: str, request: Request): + if not _auth_ok(request): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return _do_delete_skill(name) @app.post("/api/tasks/{task_id}/skills/{name}/notify") diff --git a/frontend/index.html b/frontend/index.html index af10872..5da59ca 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -187,6 +187,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over

任务清单

+
@@ -251,14 +252,14 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over - + @@ -814,9 +823,14 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over const skillsEditArea = document.getElementById('skills-edit-area'); const skillsEditName = document.getElementById('skills-edit-name'); let skillsEditTarget = null; + const skillsLoad = document.getElementById('skills-load'); + const skillsLoadList = document.getElementById('skills-load-list'); + const skillsLoadName = document.getElementById('skills-load-name'); + let skillsLoadTarget = null; function openSkillsModal() { skillsEdit.style.display = 'none'; + skillsLoad.style.display = 'none'; skillsModal.style.display = 'flex'; loadSkills(); } @@ -825,6 +839,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over if (term && term.focus) term.focus(); } document.getElementById('btn-skills').onclick = openSkillsModal; + document.getElementById('btn-skills-picker').onclick = openSkillsModal; document.getElementById('skills-close').onclick = closeSkillsModal; skillsModal.addEventListener('click', (e) => { if (e.target === skillsModal) closeSkillsModal(); }); document.addEventListener('keydown', (e) => { @@ -835,7 +850,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over async function loadSkills() { try { - const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() }); + const res = await fetch(API + '/api/skills', { headers: authHeaders() }); if (!res.ok) return; const data = await res.json(); const skills = (data.skills || []); @@ -850,7 +865,9 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over + (s.description ? '
' + escHtml(s.description) + '
' : '
') + '
' + '' - + '' + + (MODE === 'terminal' + ? '' + : '') + '' + '
' ).join(''); @@ -860,6 +877,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over if (act === 'edit') editSkill(n); else if (act === 'del') deleteSkill(n); else if (act === 'notify') notifySkill(n); + else if (act === 'load') loadSkillToSession(n); }; }); } catch (e) { console.warn('loadSkills failed', e); } @@ -879,7 +897,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over if (!f) { alert('请先选择一个 .zip 文件'); return; } try { const b64 = await readFileAsBase64(f); - const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { + const res = await fetch(API + '/api/skills', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ data: b64 }) @@ -894,11 +912,11 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over async function editSkill(name) { try { - const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() }); + const res = await fetch(API + '/api/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 res2 = await fetch(API + '/api/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; @@ -913,7 +931,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over if (!skillsEditTarget) return; const name = skillsEditTarget; try { - const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name), { + const res = await fetch(API + '/api/skills/' + encodeURIComponent(name), { method: 'PUT', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ content: skillsEditArea.value }) @@ -929,7 +947,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over 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 res = await fetch(API + '/api/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'); @@ -947,6 +965,51 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over } catch (e) { alert('热加载失败: ' + e.message); } } + // 首页“加载”:选择运行中的会话热加载 + async function loadSkillToSession(name) { + try { + const res = await fetch(API + '/api/tasks', { headers: authHeaders() }); + const data = await res.json(); + const tasks = data.tasks || []; + const living = tasks.filter(t => ['connected', 'background', 'background_idle'].includes(t.live_status)); + if (!living.length) { + alert('当前没有运行中的会话,技能已全局就位,所有会话下次打开时将自动发现此技能。'); + return; + } + skillsLoadTarget = name; + skillsLoadName.textContent = name; + skillsLoadList.innerHTML = living.map(t => + '' + ).join('') + + ''; + skillsEdit.style.display = 'none'; + skillsLoad.style.display = 'block'; + } catch (e) { alert('加载失败: ' + e.message); } + } + + document.getElementById('skills-load-cancel').onclick = () => { skillsLoad.style.display = 'none'; skillsLoadTarget = null; }; + document.getElementById('skills-load-confirm').onclick = async () => { + if (!skillsLoadTarget) return; + const name = skillsLoadTarget; + const all = document.getElementById('skills-load-all').checked; + const cbs = Array.from(skillsLoadList.querySelectorAll('.skills-load-cb')); + const ids = all ? cbs.map(c => c.value) : cbs.filter(c => c.checked).map(c => c.value); + if (!ids.length) { alert('请至少选择一个会话'); return; } + let ok = 0, fail = 0; + for (const tid of ids) { + try { + const res = await fetch(API + '/api/tasks/' + tid + '/skills/' + encodeURIComponent(name) + '/notify', { method: 'POST', headers: authHeaders() }); + const d = await res.json(); + if (res.ok && d.ok && d.notified) ok++; else fail++; + } catch (e) { fail++; } + } + alert('已向 ' + ok + ' 个会话注入热加载通知' + (fail ? (',' + fail + ' 个失败') : '') + '。'); + skillsLoad.style.display = 'none'; skillsLoadTarget = null; + loadSkills(); + }; + // ✕ 清空 pasteClear.onclick = () => { pasteInput.value = '';