From 958678aa120097770371555dd1ae1cd94d1e2836 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 11 Jun 2026 09:46:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E7=94=A8=E6=88=B7=E7=A7=81?= =?UTF-8?q?=E6=9C=89=20skill(.skills)+=20=E5=88=9B=E4=BD=9C=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20+=20skill-creator=20+=20Web=20=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每用户可在私有 .skills/ 下造/改 skill,只对自己生效。 - SkillRegistry 改多来源(SkillSource 列表:内置 + 用户 .skills),后扫同名 覆盖先扫 → user wins;user_overrides 记覆盖关系、discovery 显式标注; Skill 加 source;from_dir 区分"非 skill 目录(静默)"与"格式错(SkillLoadError)", 坏的用户 skill 收进 load_errors 注入 prompt,不崩整次扫描。容器路径改写下沉 到 registry.container_dir(按 source 给 /sandbox/skills 或 /workspace/.skills), LoadSkillTool 去掉 container_skills_dir 参数。 - 新增 host-side 工具 save_skill / fork_skill(tools/skill_authoring.py): fs 的 base_dir 锚 cwd/容器 wd 够不到 user_root/.skills,故用 host-side typed tool(与 seedream/document_* 同范式)。save_skill 写时校验 frontmatter; fork_skill copytree 整目录(带脚本)+ 自动对齐 frontmatter name。 - 新增 skill-creator 引导 skill(重点教写好 description + fork 语义)。 - Web:左侧 rail 底部「技能」按钮 → modal 分平台/我的两组,点开看完整 SKILL.md,我的可删;后端加 GET /v1/skills/{name}(正文)+ DELETE /v1/skills/{name}(只删 user 源 + 防穿越);/v1/skills 带 source/overrides/ load_errors;新 web/static/js/skills.js。创建/改/fork 仍走对话。 - .skills 是 dotfile(文件面板隐藏,与 .memory 一致;validate_task_name 已禁 . 起头 working_dir,天然不撞)。 - 测试:test_user_skills.py(20 例)+ 改写 test_load_skill.py;全 121 过。 - 文档:DESIGN §3.5 / PROGRESS / RUN(布局+端点)/ SKILL_LIST 同步。 Co-Authored-By: Claude Opus 4.8 (1M context) --- DESIGN.md | 9 +- PROGRESS.md | 17 ++- RUN.md | 4 + SKILL_LIST.md | 33 +++++- core/agent_builder.py | 58 +++++++--- core/skills.py | 131 ++++++++++++++++++---- skills/skill-creator/SKILL.md | 108 ++++++++++++++++++ tests/test_load_skill.py | 35 +++--- tests/test_user_skills.py | 205 ++++++++++++++++++++++++++++++++++ tools/skill_authoring.py | 178 +++++++++++++++++++++++++++++ tools/skill_tool.py | 16 +-- web/app.py | 76 +++++++++++-- web/static/dev.html | 75 +++++++++++++ web/static/js/main.js | 2 + web/static/js/skills.js | 112 +++++++++++++++++++ 15 files changed, 968 insertions(+), 91 deletions(-) create mode 100644 skills/skill-creator/SKILL.md create mode 100644 tests/test_user_skills.py create mode 100644 tools/skill_authoring.py create mode 100644 web/static/js/skills.js diff --git a/DESIGN.md b/DESIGN.md index edaf570..67a8802 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -39,7 +39,8 @@ zcbot/ │ ├── fs.py # read / write / edit (唯一匹配) / glob / grep │ ├── shell.py # subprocess + 黑名单 │ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤 -│ └── skill_tool.py # load_skill +│ ├── skill_tool.py # load_skill +│ └── skill_authoring.py # save_skill / fork_skill(host-side 写用户 .skills) ├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets ├── prompts/system/general_v1.md ├── config/{agent.yaml, models/*.yaml} @@ -76,7 +77,7 @@ ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。 ### 3.4 工具系统(Hybrid 范式) -**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill — 离散操作。 +**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill / save_skill / fork_skill — 离散操作。 **Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。 关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。 @@ -84,6 +85,8 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / 对齐 Anthropic 2025-12 开放标准。三层加载:Discovery(`name + description`,几百 token)→ Activation(`load_skill(name)` 加载完整 SKILL.md,1-5K)→ Execution(SKILL.md 指 `references/xxx` 按需拉)。 原则:写 WHY+WHAT,不写 Step 1/2/3。description 决定模型能否触发。 +**用户私有 skill(多来源 registry,2026-06-11)**:`SkillRegistry` 收**有序来源列表**——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写,per-user)。用户来源排后,**同名覆盖内置(user wins)**;覆盖在 discovery 显式标注,不静默。取舍:① **user wins** 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② **创作走 host-side typed tool**(`save_skill`/`fork_skill`)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 `fork_skill` copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 `load_errors` 注入 prompt 提示用户修,不静默丢、不崩整次扫描。 + ### 3.6 Session 与 Task **Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。 @@ -398,7 +401,7 @@ create index on usage_events (model_profile, created_at); 6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正:原"host 工具走 `resolve_user_path` 校验"是假命题无此函数;dogfood 发现 glob 仍列 host repo,改物理边界替代代码护栏): - **Container exec backend**:`shell`/`run_python`/`read`/`write`/`edit`/`glob`/`grep` 全走 docker exec。shell/run_python 是任意代码;fs 工具以前 host 跑 `base_dir=Path.cwd()` 无 user_root 校验能读 `/etc/passwd`/源码/`~/.ssh`,进容器后 `user_root=/workspace` 是物理边界。调用形态:`docker exec --user zcbot --workdir /workspace/ -i python /sandbox/tool_runner.py ` + stdin 喂 JSON args(CJK/引号透明传);`tool_runner.py` 复用 `tools/fs.py`,skill references 走 `skills:/sandbox/skills:ro` mount。 - - **Host in-process backend**:`load_skill`/`web_*`/`seedream`/`seedance`/`document_*`/`mp_*` — 持 key 不能进容器 env;`load_skill` 是内存查找无越界。 + - **Host in-process backend**:`load_skill`/`save_skill`/`fork_skill`/`web_*`/`seedream`/`seedance`/`document_*`/`mp_*` — 持 key 不能进容器 env;`load_skill` 是内存查找无越界;`save_skill`/`fork_skill` host-side 写 `user_root/.skills`(沙箱 fs 的 base_dir 够不到)。 - Dispatcher(`DockerExecutor`)内部分流,`AgentLoop` 零感知;接口形状按"未来全进容器 + tool-runner unix socket RPC"留好(升级信号见下表)。**代价**:每 fs tool call 多 ~200ms,对话级 N≤15 → 1-3s,LLM 推理 5-30s 下噪声。 7. **Secret-bearing domain tools 不进 sandbox,不做 key 下发**(2026-06-01):凡需 `*_API_KEY`/OAuth/DB credential 的能力**不能**让容器读 env,也不做"credential broker 发短期 key"(sandbox 内任意代码可 `print(os.environ)`/monkeypatch SDK,短期 token 只缩有效期不改根因)。正确形态=**host-side JSON tool**:LLM 传非敏感业务参数 → host tool 取 key 调远端 API → 裁剪/限大小/计量/审计 → 只返业务结果或落盘文件路径,容器最多读到落盘产物。已落地:`documents`/Materials Project 改 host tool(详 PROGRESS 06-01)。注册规则:仅对应 env 存在时注册,否则 schema 不暴露 + skill 文档提示降级。 diff --git a/PROGRESS.md b/PROGRESS.md index 690b945..0490900 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-10(上下文压缩加压力门槛 + 停机判据从步数解耦为是否在推进) +最后更新:2026-06-11(用户私有 skill:多来源 registry + save_skill/fork_skill + skill-creator) --- @@ -21,6 +21,11 @@ ## 已完成关键能力 +### 2026-06-11 + +- **dev SPA「技能」查看 modal(左侧 rail 底部入口)**:因 `.skills` 在文件面板隐藏,加左侧 rail 底部「我的资源」分组(`#rail-resources`,留位给后续「记忆」)+「技能」按钮 → 弹 modal 分「平台 skill / 我的 skill」两组列表,点任一项展开**完整 SKILL.md**(`GET /v1/skills/{name}` + 现有 markdown 渲染),「我的」每项带删除(二次确认 → `DELETE /v1/skills/{name}`,只删 user 源 + 防穿越);覆盖标 `已覆盖平台同名`,`load_errors` 提示未加载的。创建/改/fork 仍走对话。新 `web/static/js/skills.js`(零构建 ES module,main.js import + Esc 栈接入);`/v1/skills` 已带 source/overrides/load_errors。**纯查看 + 删除,不在 UI 做创建/编辑**(编辑天然对话式)。 +- **用户私有 skill(每用户 `.skills/`,可从零写或 fork 内置再改)**:`SkillRegistry` 从单目录改**多来源**(`SkillSource` 列表:内置 `ROOT/skills` + 用户 `user_root/.skills`),后扫同名覆盖先扫 → **user wins**;覆盖关系记进 `user_overrides`,discovery 显式标 `[你的·已覆盖内置]`(不静默)。`Skill` 加 `source` 字段;`from_dir` 区分"无 SKILL.md(静默跳过)"与"有但格式错(抛 `SkillLoadError`)",`_scan` 捕获用户来源的错收进 `load_errors`、注入 system prompt 提示用户修(一个坏 skill 不再崩整次扫描)。容器路径改写从 LoadSkillTool 下沉到 registry(`container_dir` 按 `source` 给 `/sandbox/skills` 或 `/workspace/.skills`),LoadSkillTool 去掉 `container_skills_dir` 参数。**关键判断**:写 skill 用 host-side typed tool(`save_skill`/`fork_skill`,`tools/skill_authoring.py`)而非 fs/shell —— 因 fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),都够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知道 user_root 一个落点两模式通吃(与 seedream/DocumentDownload 一致范式)。`save_skill` 写时校验 frontmatter(名合法 / YAML 合法 / 有 description / name 一致),`fork_skill` copytree 整目录(带脚本)+ 自动把 frontmatter name 对齐新名(否则 fork ppt 仍叫 ppt 会反覆盖内置)。`.skills` 是 dotfile(文件面板隐藏,与 `.memory` 一致;`validate_task_name` 已禁 `.` 起头 working_dir,天然不撞)。`/v1/skills` 带上用户 skill + `source`/`overrides_builtin`/`load_errors`。新增 `skill-creator` 引导 skill。+`test_user_skills.py`(20 例)+ 改写 `test_load_skill.py`。性能:多扫一目录,没 `.skills` 的用户一次 `exists()` 跳过;有 skill 仅每 run +1-3ms,不在热路径。 + ### 2026-06-10 - **system prompt 精简(瘦身 ~40 行 + 媒体段按需注入)**:`general_v1.md` + `_build_system_prompt` 去冗余:① 「宪法」文件命名约定从 ~25 行压到 ~6 行(只留格式定义 + 注入值 + 一行 current/重定调,操作细节本就由 proposal/ppt skill 各自讲,引用仍成立);② run_python「先 write script 再 script_path」指引去重(原模板 + agent_builder 两处 → 合并进模板 1 处,顺带把 `scripts/` 子目录约定收进去);③ 媒体工具段(seedream/seedance 红线)从常驻模板抽成 `_MEDIA_TOOLS_BLOCK`,仅 `ArkConfig.load() is not None`(有 ARK_API_KEY)时由 agent_builder 追加——无 key 用户不再背 7 行永远报错工具的说明,且 ark_cfg 提前 load 一次复用给下方 tool 注册;④ 「路径 echo 全形式」段 8 行压到 4 行。通用任务每轮 system prompt 净瘦 ~40-50 行,领域 task 加载 skill 后信息不丢。`test_system_prompt_paths` 仍过。 @@ -234,20 +239,20 @@ core/paths.py 50 ← task_dir db form 归一 core/probe.py 243 core/session.py 153 ← ORM core/task.py 82 ← PG-backed TaskState -core/skills.py 81 +core/skills.py 180 ← 多来源 registry(SkillSource)+ source 标记 + 覆盖感知(user wins)+ load_errors + container_dir core/memory.py 81 ← per-user `.memory/` dotfile core/export_docx.py 383 core/storage/{__init__,engine,models,usage,utils}.py ← 4 表(0004-0007 演进);record_chat/image_usage core/ark_client.py 105 ← 火山方舟 HTTP 客户端 -core/agent_builder.py 325 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamTool) +core/agent_builder.py 340 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamTool);build_skill_registry 装两来源 core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池 -tools/{base,fs,shell,run_python,skill_tool,seedream,seedance,web_search,web_fetch,documents,materials_project}.py +tools/{base,fs,shell,run_python,skill_tool,skill_authoring,seedream,seedance,web_search,web_fetch,documents,materials_project}.py ← skill_authoring=save_skill/fork_skill(host-side 写 user .skills) main.py ~210 ← 入口:web / db / probe / user / sandbox check db/migrations/versions/ 0001-0008 -web/app.py ~1320 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files + pptx 预览 +web/app.py ~1360 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files + pptx 预览 + skills(列表/正文/删) web/auth.py ~190 ← 邮箱密码 + platform_key → JWT web/broker.py / sinks.py / pptx_render.py -web/static/dev.html + js/*.js ← dev SPA 拆 14 个零构建 ES module(main.js 75 行入口) +web/static/dev.html + js/*.js ← dev SPA 拆 15 个零构建 ES module(main.js 入口;skills.js=技能查看 modal) web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx ───────────────────────────────── Python 合计 ~3400 行(+ dev SPA + vendor 1MB);加 skills 脚本 + 配置,总仓库约 3800 行 diff --git a/RUN.md b/RUN.md index 3cc9768..5fd693e 100644 --- a/RUN.md +++ b/RUN.md @@ -151,6 +151,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 | | `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 | | `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 | +| `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 | +| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 | +| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills//` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 | | `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 | | `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 | | `GET /v1/tasks/{id}/events` | SSE 流(`event: ` + `data: `);订阅 task 当前活动 | 必填 | @@ -735,6 +738,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Workspace**(per-user 子树,user_id 来自 JWT `sub`): - `workspace/users//.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离 + - `workspace/users//.skills//SKILL.md` — 用户私有 skill,dotfile 隐藏;只对该用户生效,与内置同名则覆盖内置(user wins)。由 agent 工具 `save_skill` / `fork_skill` 写(host-side,不走沙箱 fs);docker 下随 user_root bind 到 `/workspace/.skills` - `workspace/users///` — 工作目录,用户起名,同 working_dir 多 task 共享 --- diff --git a/SKILL_LIST.md b/SKILL_LIST.md index bbf1854..c26e064 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,11 +1,13 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-06-08 -Skill 总数:14 +最后更新:2026-06-11 +Skill 总数:15 zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 +> **用户私有 skill**:除内置 skill 外,每个用户可在自己私有的 `.skills/` 下创建 / 改造 skill(只对自己生效,不影响他人)。用 `skill-creator` 引导即可——从零写或 fork 某个内置 skill 再改。用户 skill 与内置**同名则覆盖内置**(列表里标 `[你的·已覆盖内置]`),改名则并存。 + --- ## 速览 @@ -26,6 +28,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + | 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) | | 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) | | 通用 | [coding](#coding) | 修代码 / 调试 / 重构 | +| 元能力 | [skill-creator](#skill-creator) | 引导用户创建 / 改造自己的私有 skill(从零写或 fork 内置) | --- @@ -430,6 +433,31 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S --- +### skill-creator +**引导用户创建 / 改造自己的私有 skill。** + +把"每次都要重复交代的一套做法"沉淀成用户私有 skill,存在自己的 `.skills/` 下,只对自己生效。两种来源:**从零写**(`save_skill` 写一份 SKILL.md)或 **fork 内置再改**(`fork_skill` 整目录拷过来、连脚本一起带,再编辑)。 + +**何时用**: +- ✅ 用户说"我想要个自己的 skill / 自定义 skill / 把这套流程固定下来" +- ✅ 用户说"zcbot 的 X skill 挺好但我想改成 Y"(→ fork 再改) +- ✅ 用户每次任务都重复交代同一套约束(术语表 / 模板 / 禁忌),值得固化 + +**何时不用**: +- ⛔ 用户只是要完成一个具体任务 → 走对应内置 skill,别绕到造 skill +- ⛔ 要改的是所有任务都该遵守的全局行为 → 那是偏好 / system prompt,不是 skill +- ⛔ 一次性的事 → 直接做 + +**关键机制**: +- 用户 skill 存私有 `.skills//`(文件面板隐藏),用 `save_skill` / `fork_skill` 落盘(**不走 fs/shell**——沙箱 fs 根够不到那里) +- 造好 / 改好后**下一条消息**才生效(registry 每轮重建) +- 同名内置 → 覆盖(user wins,列表显式标注);改名 → 并存 +- `save_skill` 写时校验 frontmatter(缺 description / YAML 坏直接拒),挡住"加载失败"黑洞 + +**典型产物**:`.skills//SKILL.md`(+ fork 带来的 scripts / references / templates)。 + +--- + ## 跨 skill 协作 实际任务往往跨多个 skill,典型组合: @@ -439,6 +467,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S - **写标准全流程**:analyze(定标准化对象) → stats_ml(配方-性能 / 精密度试验数据定指标) → research / documents(查国内外现有标准与现状) → standard(起草标准 + 编制说明) → plot_pub(出图) → review(送审前终审) - **PPT 汇报**:analyze(提炼论点) → research / documents(找数据 + 引文) → plot_pub(出图) → ppt(组装 deck) → imagegen(可选,做封面 / 引子页) - **晶体计算**:pymatgen(算 XRD / 相图) → plot_pub(出图) → proposal / patent(写到本子 / 交底书里) +- **定制能力**:skill-creator(fork 某内置 skill,如 ppt / proposal) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版 --- diff --git a/core/agent_builder.py b/core/agent_builder.py index a8b16a5..92bdf75 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -49,6 +49,7 @@ from tools.run_python import RunPythonTool from tools.seedance import SeedanceTool from tools.seedream import SeedreamTool from tools.shell import ShellTool +from tools.skill_authoring import ForkSkillTool, SaveSkillTool from tools.skill_tool import LoadSkillTool from tools.task_progress import TaskProgressTool from tools.web_fetch import WebFetchTool @@ -124,12 +125,36 @@ def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> P def user_root(workspace_dir: Path, user_id: UUID) -> Path: - """per-user 子树根:`/users//`。working_dir / `.memory/` 都在下面。""" + """per-user 子树根:`/users//`。working_dir / `.memory/` / `.skills/` 都在下面。""" d = workspace_dir / "users" / str(user_id) d.mkdir(parents=True, exist_ok=True) return d +def build_skill_registry( + cfg: dict, workspace_dir: Path, user_id: UUID, *, docker: bool +) -> "SkillRegistry": + """装两来源 registry:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`)。 + + 用户来源排在内置之后 → 同名时 user wins(详 core/skills.py)。container_root 仅 docker + 用:内置 bind 到 `/sandbox/skills`,用户 `.skills` 在 user_root 内、随 user_root bind 到 + `/workspace`,故为 `/workspace/.skills`。host backend 传 None。 + """ + from core.skills import SkillSource + + builtin = SkillSource( + ROOT / cfg.get("skills_dir", "skills"), + "builtin", + "/sandbox/skills" if docker else None, + ) + user = SkillSource( + user_root(workspace_dir, user_id) / ".skills", + "user", + "/workspace/.skills" if docker else None, + ) + return SkillRegistry([builtin, user]) + + class InvalidTaskName(ValueError): """task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。""" @@ -368,7 +393,8 @@ def build_agent( tool_base = Path(tool_base) if tool_base else Path.cwd() - skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills")) + is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker" + skills = build_skill_registry(cfg, workspace_dir, uid, docker=is_docker) # 媒体配置提前 load 一次:既决定 system prompt 要不要追加媒体段(media_enabled), # 也复用给下方 seedream/seedance 注册(避免重复读 doubao.yaml)。无 ARK_API_KEY → None。 @@ -443,8 +469,6 @@ def build_agent( wf = WebFetchTool(base_dir=tool_base, user_root=ur_path) tools[wf.name] = wf - import os - # Secret-bearing domain tools stay host-side. Never expose DOCUMENT_SEARCH_API_KEY # / MP_API_KEY to run_python or the sandbox; only register typed tools when the # corresponding host env exists. @@ -477,22 +501,20 @@ def build_agent( tools[t.name] = t if skills.skills: - # docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到 - # /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM - # 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。 - container_skills_dir = ( - "/sandbox/skills" - if os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker" - else None - ) - ls = LoadSkillTool( - registry=skills, - base_dir=tool_base, - user_root=ur_path, - container_skills_dir=container_skills_dir, - ) + # LoadSkillTool 返回头里的 dir 由 registry 按 skill.source 给容器内路径 + # (内置 → /sandbox/skills,用户 → /workspace/.skills);host backend → host 绝对路径。 + ls = LoadSkillTool(registry=skills, base_dir=tool_base, user_root=ur_path) tools[ls.name] = ls + # 用户 skill 创作工具:恒挂(每个用户都能造自己的 skill)。host-side 直接写 + # user_root/.skills —— 不走沙箱 fs(其 base_dir 锚 cwd / 容器 wd,够不到 .skills)。 + user_skills_dir = ur_path / ".skills" + for t in ( + SaveSkillTool(user_skills_dir, skills, base_dir=tool_base, user_root=ur_path), + ForkSkillTool(user_skills_dir, skills, base_dir=tool_base, user_root=ur_path), + ): + tools[t.name] = t + if caps.enable_run_python: rp = RunPythonTool(base_dir=tool_base, user_root=ur_path) tools[rp.name] = rp diff --git a/core/skills.py b/core/skills.py index a0fcec7..0baadb8 100644 --- a/core/skills.py +++ b/core/skills.py @@ -1,15 +1,19 @@ """Skill 注册表 (Anthropic 标准格式)。 -每个 skill 是 skills// 目录,内含 SKILL.md(带 frontmatter)+ 可选的 +每个 skill 是 // 目录,内含 SKILL.md(带 frontmatter)+ 可选的 references/、scripts/、assets/。启动时只读 frontmatter 做 discovery,完整 SKILL.md 和 references 由 agent 按需加载(渐进披露)。 + +多来源:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`,可写)。 +来源按顺序扫,**后扫的同名覆盖先扫的** —— 用户 skill 排在内置之后,故"用户覆盖 +内置"(user wins);覆盖关系记进 `user_overrides` 供 discovery 显式标注,不静默。 """ from __future__ import annotations import re from dataclasses import dataclass from pathlib import Path -from typing import Dict, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Union import yaml @@ -18,7 +22,11 @@ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL) def parse_frontmatter(text: str) -> Tuple[dict, str]: - """解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。""" + """解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。 + + frontmatter YAML 非法时抛 `yaml.YAMLError`(由 `SkillRegistry._scan` 捕获记进 + load_errors —— 用户手写 skill 易踩,不能让一个坏 skill 崩掉整次扫描)。 + """ m = _FRONTMATTER_RE.match(text) if not m: return {}, text @@ -28,11 +36,20 @@ def parse_frontmatter(text: str) -> Tuple[dict, str]: return meta, text[m.end():] +class SkillLoadError(Exception): + """skill 目录有 SKILL.md 但加载失败(YAML 坏 / 缺 description 等)。 + + 与"没有 SKILL.md(根本不是 skill 目录,静默跳过)"区分:前者要面向用户报, + 后者是正常的非 skill 子目录。 + """ + + @dataclass class Skill: name: str description: str skill_dir: Path + source: str = "builtin" # 'builtin' | 'user' @property def skill_md(self) -> Path: @@ -42,40 +59,110 @@ class Skill: return self.skill_md.read_text(encoding="utf-8") @classmethod - def from_dir(cls, skill_dir: Path) -> Optional["Skill"]: + def from_dir(cls, skill_dir: Path, source: str = "builtin") -> Optional["Skill"]: + """加载一个 skill 目录。 + + 无 SKILL.md → 返回 None(静默跳过,不是 skill 目录); + 有 SKILL.md 但格式错(YAML 坏 / 缺 description) → 抛 SkillLoadError。 + """ md = skill_dir / "SKILL.md" if not md.exists(): - return None - meta, _ = parse_frontmatter(md.read_text(encoding="utf-8")) + return None # 不是 skill 目录,静默跳过 + try: + text = md.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as e: + raise SkillLoadError(f"读不出 SKILL.md: {e}") + try: + meta, _ = parse_frontmatter(text) + except yaml.YAMLError as e: + raise SkillLoadError(f"frontmatter YAML 非法: {e}") name = meta.get("name") or skill_dir.name desc = meta.get("description") or "" if not desc: - return None # description 是 discovery 的关键,缺了不收 - return cls(name=name, description=desc, skill_dir=skill_dir) + raise SkillLoadError("缺 description(frontmatter 必须有 name + description)") + return cls(name=name, description=desc, skill_dir=skill_dir, source=source) + + +@dataclass +class SkillSource: + """一个 skill 搜索来源。 + + container_root: docker backend 下该来源在容器内的挂载前缀 + (内置 → `/sandbox/skills`,用户 → `/workspace/.skills`);None = host backend, + LoadSkillTool 退回 host 绝对路径。 + """ + root: Path + source: str = "builtin" + container_root: Optional[str] = None + + +SourcesArg = Union[Path, str, SkillSource, List[SkillSource]] class SkillRegistry: - def __init__(self, skills_dir: Path) -> None: - self.skills_dir = Path(skills_dir) + def __init__(self, sources: SourcesArg) -> None: + # 单个 Path/str → 包成单一 builtin 来源(向后兼容直接传目录的调用 / 测试) + if isinstance(sources, (str, Path)): + sources = [SkillSource(Path(sources), "builtin")] + elif isinstance(sources, SkillSource): + sources = [sources] + self.sources: List[SkillSource] = list(sources) self.skills: Dict[str, Skill] = {} + # 用户 skill 覆盖了内置 skill 的 name 集合 —— discovery 显式标注,覆盖不静默 + self.user_overrides: set[str] = set() + # 加载失败的用户 skill:(目录名, 原因)。内置 skill 失败是 dev bug,不进此列 + # (不面向终端用户报),由测试 / 启动日志兜底 + self.load_errors: List[Tuple[str, str]] = [] + self._container_roots: Dict[str, Optional[str]] = {} self._scan() def _scan(self) -> None: - if not self.skills_dir.exists(): - return - for child in sorted(self.skills_dir.iterdir()): - if not child.is_dir(): - continue - skill = Skill.from_dir(child) - if skill is not None: - self.skills[skill.name] = skill + for src in self.sources: + self._container_roots[src.source] = src.container_root + if not src.root.exists(): + continue # 用户没有 .skills 目录 → 一次 exists() 跳过,零成本 + for child in sorted(src.root.iterdir()): + if not child.is_dir(): + continue + try: + skill = Skill.from_dir(child, source=src.source) + except SkillLoadError as e: + if src.source == "user": + self.load_errors.append((child.name, str(e))) + continue + if skill is None: + continue + prev = self.skills.get(skill.name) + if prev is not None and prev.source != skill.source and skill.source == "user": + self.user_overrides.add(skill.name) # 用户覆盖了内置 + self.skills[skill.name] = skill # 后扫覆盖先扫 → user wins def discovery_block(self) -> str: - """启动时注入 system prompt 的 skill 列表(name + description)。""" - if not self.skills: + """注入 system prompt 的 skill 列表(name + description + 来源标注)。""" + if not self.skills and not self.load_errors: return "" - lines = [f"- **{s.name}**: {s.description}" for s in self.skills.values()] - return "\n".join(lines) + lines = [] + for s in self.skills.values(): + if s.source == "user": + tag = " [你的·已覆盖内置]" if s.name in self.user_overrides else " [你的]" + else: + tag = "" + lines.append(f"- **{s.name}**{tag}: {s.description}") + block = "\n".join(lines) + if self.load_errors: + errs = "; ".join(f"`{n}`({why})" for n, why in self.load_errors) + block += ( + "\n\n> ⚠️ 你有用户 skill 因格式问题未加载,需要时提醒用户修好 frontmatter" + f"(修好后下条消息生效):{errs}" + ) + return block + + def container_dir(self, skill: Skill) -> Optional[str]: + """docker 下该 skill 在容器内的目录;host backend → None(调用方退回 host 绝对路径)。""" + root = self._container_roots.get(skill.source) + if not root: + return None + return f"{root.rstrip('/')}/{skill.name}" def get(self, name: str) -> Optional[Skill]: return self.skills.get(name) diff --git a/skills/skill-creator/SKILL.md b/skills/skill-creator/SKILL.md new file mode 100644 index 0000000..fc4c193 --- /dev/null +++ b/skills/skill-creator/SKILL.md @@ -0,0 +1,108 @@ +--- +name: skill-creator +description: 引导用户创建 / 改造自己的 skill(存进用户私有 `.skills/`,只对自己生效)。当用户说"我想做个自己的 skill / 把某个能力固化下来 / 把 zcbot 的某 skill 改成我要的样子 / 每次都要重复交代同一套规矩"时使用。本 skill 教怎么写 SKILL.md、怎么 fork 内置 skill 再改、怎么写好路由用的 description。用户只是要完成一个具体任务(写本子 / 画图 / 查文献)时不用 —— 那直接走对应 skill。 +--- + +# Skill Creator - 造你自己的 skill + +帮用户把"反复要交代的一套做法"沉淀成一个**私有 skill**,存在他自己的 `.skills/` 下,只对他生效,不影响别人也不动内置 skill。 + +两种来源: +- **从零写**:全新能力,用 `save_skill` 写一份 SKILL.md。 +- **fork 内置再改**(最常见):看中某个内置 skill(如 ppt / proposal)但想调规矩,用 `fork_skill` 整目录拷过来(**带它的脚本**),再编辑 SKILL.md。 + +## 何时用 + +- 用户说"我想要个自己的 skill / 自定义 skill / 把这套流程固定下来" +- 用户说"zcbot 的 X skill 挺好但我想改成 Y" → fork 再改 +- 用户每次任务都要重复交代同一套约束(术语表 / 模板 / 禁忌) → 建议固化成 skill +- 用户问"skill 怎么写 / SKILL.md 什么格式" + +## 何时不用 + +- 用户只是要完成一个具体任务 → 走对应内置 skill,别绕到造 skill +- 用户要改的是**全局行为**(所有任务都该这样) → 那是 system prompt / 偏好,不是 skill +- 一次性的事 → 直接做,不值得固化 + +## 关键机制(先讲清楚再动手) + +**存哪**:用户 skill 在私有 `.skills//SKILL.md`(与 `.memory/` 同级,文件面板里隐藏)。**你不用、也不该用 write/shell 去手写这个目录** —— 沙箱 fs 的根不指向那里,跨 host/docker 不可靠。一律用下面两个工具,它们 host 侧直接落到正确位置。 + +**两个工具**: +- `save_skill(name, content)` — 新建 / 覆盖 `.skills//SKILL.md`。`content` 是完整 SKILL.md(含 frontmatter)。写时校验 frontmatter 合法且有 description,不合格直接拒。 +- `fork_skill(src, new_name)` — 把内置(或用户已有)skill **整目录**拷到 `.skills//`,脚本 / references 一起带过来,frontmatter 的 name 自动改成 `new_name`。 + +**生效时机**:造好 / 改好后,**下一条消息**才生效(registry 每轮重建)。告诉用户造完这条结束,下次发消息就能用了。 + +**覆盖语义(user wins)**:用户 skill 与内置**同名 → 覆盖内置**(只对该用户)。想替换内置就用同名,想**两个都留**就改名(如 `ppt-mine`)。覆盖会在 skill 列表里显式标 `[你的·已覆盖内置]`,不静默。 + +## 工作流 + +### 1. 先问清楚要造什么(BLOCKING) + +- 是**从零**还是**基于某内置 skill 改**?基于改 → 是哪个、想改什么? +- 这个 skill 解决什么任务?**什么时候该触发、什么时候不该**?(这决定 description,见下) +- 要不要带脚本 / 模板? + +### 2a. fork 路径(基于内置改) + +1. `fork_skill(src=<内置名>, new_name=<用户起的名>)` —— 默认建议改名(如 `ppt` → `ppt-mine`),除非用户明确要覆盖内置。 +2. fork 回包会给出 `.skills//` 路径。用 `read` 看 SKILL.md,用 `edit` 改用户想改的部分(规矩 / 模板 / 默认值)。 +3. 改完告诉用户:下条消息起,`` 就在 skill 列表里了。 + +### 2b. 从零路径 + +1. 跟用户敲定 name + description + 正文骨架。 +2. `save_skill(name, content)`,`content` 是完整 SKILL.md。 +3. 若校验报错(缺 description / YAML 坏),按提示修了重存。 + +### 3. 写好 description —— 这是最关键的一环 + +description 是**唯一进每轮 skill 列表、决定路由**的字段。写糊了的代价不是"skill 不好用",而是**误触发 + 稀释列表**。规则: + +- 一句话讲清**做什么** + **何时用** + **何时别用** +- 给**触发词**(用户会怎么开口),必要时给**反例**(像不该触发的近义场景) +- 别写成功能罗列,要写成"路由信号" + +照抄内置的结构,比如: +> `description: 生成 PowerPoint(.pptx)。✅ 触发:PPT / 幻灯片 / slide / deck / .pptx。⛔ 不触发:报告 / 文档 / 纪要(走 documents/proposal)。...` + +### 4. SKILL.md 正文骨架(从零时给用户的模板) + +```markdown +--- +name: <小写、[a-z0-9_-]、≤64> +description: <路由说明:做什么 + 何时用 + 何时别用 + 触发词> +--- + +# <标题> + +<一句话定位:这个 skill 帮用户做什么> + +## 何时用 / 何时不用 +- ... + +## 工作流 +1. ... + +## 反模式 +- ... +``` + +正文要不要分阶段 / 卡 BLOCKING / 带 quality_check,看任务复杂度 —— 简单 skill 几行就够,别硬套。带脚本的:脚本放 `.skills//scripts/`,SKILL.md 里用 `/scripts/xxx.py` 引(`` 是 `load_skill` 返回头给的路径,host/docker 都对)。 + +## 反模式 + +- 用 `write` / `shell` 手动往 `.skills` 写 —— 用 `save_skill` / `fork_skill`(沙箱 fs 根够不到 `.skills`,会写错地方) +- fork 带脚本的内置 skill(ppt/proposal)却只 `save_skill` 拷了 SKILL.md —— 脚本没带过来,`import` 必崩;带脚本一律 `fork_skill` +- description 写成"很厉害的 PPT skill" —— 没有触发信号,路由抓瞎 +- 默认就覆盖同名内置 —— 除非用户明说要替换,否则改名并存,别悄悄遮掉内置 +- 造完不告诉用户"下条消息才生效",用户当场没看到以为失败 +- 把"所有任务都该遵守的全局规矩"做成 skill —— 那该进偏好 / system prompt + +## 输出 + +完成后给用户: +- skill 名 + 路径(`.skills//`) +- 是覆盖内置还是新增 / 并存 +- 一句话:下条消息发什么就能触发它(或让 LLM 自动按 description 路由) diff --git a/tests/test_load_skill.py b/tests/test_load_skill.py index 987bc65..d1ab156 100644 --- a/tests/test_load_skill.py +++ b/tests/test_load_skill.py @@ -1,8 +1,9 @@ """LoadSkillTool 路径改写测试。 -docker backend 下 fs/shell/run_python 在容器里跑,skills/ bind mount 到 -`/sandbox/skills:ro`。LoadSkillTool 返回头里的 `dir` 必须是容器路径而不是 host -绝对路径,否则 LLM 拿 host 路径调 read references 时容器 namespace 不通。 +docker backend 下 fs/shell/run_python 在容器里跑,skill 目录按来源 bind 到不同挂载点 +(内置 → `/sandbox/skills:ro`,用户 → `/workspace/.skills`)。LoadSkillTool 返回头里的 +`dir` 必须是容器路径而不是 host 绝对路径,否则 LLM 拿 host 路径调 read references 时 +容器 namespace 不通。容器路径由 `SkillSource.container_root` 按 `skill.source` 决定。 """ from __future__ import annotations @@ -10,7 +11,7 @@ import tempfile import unittest from pathlib import Path -from core.skills import SkillRegistry +from core.skills import SkillRegistry, SkillSource from tools.skill_tool import LoadSkillTool @@ -24,45 +25,45 @@ class TestLoadSkillToolPathRewrite(unittest.TestCase): "---\nname: demo\ndescription: 测试用\n---\n\n# Demo body\n", encoding="utf-8", ) - self.registry = SkillRegistry(self.skills_dir) def tearDown(self): self.tmpdir.cleanup() def test_host_backend_returns_host_path(self): - """没传 container_skills_dir → header 用 host 绝对路径(原行为)。""" - tool = LoadSkillTool(registry=self.registry) + """container_root=None → header 用 host 绝对路径(原行为)。""" + registry = SkillRegistry(self.skills_dir) # 单 Path → builtin, container_root=None + tool = LoadSkillTool(registry=registry) out = tool.execute(name="demo") host_path = str((self.skills_dir / "demo")) self.assertIn(f"dir={host_path}", out) self.assertIn("# Demo body", out) def test_docker_backend_rewrites_to_sandbox_path(self): - """传 container_skills_dir=/sandbox/skills → header 用容器路径,且不漏 host 路径。""" - tool = LoadSkillTool( - registry=self.registry, - container_skills_dir="/sandbox/skills", + """container_root=/sandbox/skills → header 用容器路径,且不漏 host 路径。""" + registry = SkillRegistry( + SkillSource(self.skills_dir, "builtin", "/sandbox/skills") ) + tool = LoadSkillTool(registry=registry) out = tool.execute(name="demo") self.assertIn("dir=/sandbox/skills/demo", out) # host 临时目录路径不应出现在 header(防止改写不彻底) host_path = str((self.skills_dir / "demo")) self.assertNotIn(host_path, out) - # body 不变 self.assertIn("# Demo body", out) def test_docker_backend_strips_trailing_slash(self): - """container_skills_dir 带末尾斜杠 → 拼接路径不应出现双斜杠。""" - tool = LoadSkillTool( - registry=self.registry, - container_skills_dir="/sandbox/skills/", + """container_root 带末尾斜杠 → 拼接路径不应出现双斜杠。""" + registry = SkillRegistry( + SkillSource(self.skills_dir, "builtin", "/sandbox/skills/") ) + tool = LoadSkillTool(registry=registry) out = tool.execute(name="demo") self.assertIn("dir=/sandbox/skills/demo", out) self.assertNotIn("//demo", out) def test_unknown_skill_returns_error(self): - tool = LoadSkillTool(registry=self.registry) + registry = SkillRegistry(self.skills_dir) + tool = LoadSkillTool(registry=registry) out = tool.execute(name="nonexistent") self.assertIn("not found", out) self.assertIn("demo", out) # available list diff --git a/tests/test_user_skills.py b/tests/test_user_skills.py new file mode 100644 index 0000000..67097e6 --- /dev/null +++ b/tests/test_user_skills.py @@ -0,0 +1,205 @@ +"""用户 skill: 多来源覆盖(user wins)、加载失败收集、save_skill / fork_skill。""" +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from core.skills import SkillRegistry, SkillSource +from tools.skill_authoring import ForkSkillTool, SaveSkillTool + + +def _write_skill(root: Path, name: str, desc: str, body: str = "body") -> Path: + d = root / name + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {desc}\n---\n\n# {name}\n\n{body}\n", + encoding="utf-8", + ) + return d + + +class TestMultiSourceRegistry(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.builtin = self.root / "builtin" + self.user = self.root / "user" + self.builtin.mkdir() + self.user.mkdir() + + def tearDown(self): + self.tmp.cleanup() + + def _registry(self): + return SkillRegistry([ + SkillSource(self.builtin, "builtin"), + SkillSource(self.user, "user"), + ]) + + def test_user_overrides_builtin(self): + _write_skill(self.builtin, "ppt", "内置 ppt") + _write_skill(self.user, "ppt", "我的 ppt") + reg = self._registry() + self.assertEqual(reg.get("ppt").source, "user") + self.assertEqual(reg.get("ppt").description, "我的 ppt") + self.assertIn("ppt", reg.user_overrides) + + def test_distinct_names_coexist(self): + _write_skill(self.builtin, "ppt", "内置 ppt") + _write_skill(self.user, "ppt-mine", "我的 ppt") + reg = self._registry() + self.assertEqual(reg.get("ppt").source, "builtin") + self.assertEqual(reg.get("ppt-mine").source, "user") + self.assertNotIn("ppt-mine", reg.user_overrides) + + def test_discovery_block_tags_user_skills(self): + _write_skill(self.builtin, "coding", "内置 coding") + _write_skill(self.user, "ppt", "我的") # 不撞内置 + _write_skill(self.builtin, "ppt", "内置 ppt") + reg = self._registry() + block = reg.discovery_block() + self.assertIn("[你的·已覆盖内置]", block) # user ppt 覆盖 builtin ppt + + def test_bad_user_skill_collected_not_crash(self): + _write_skill(self.builtin, "coding", "内置") + # 用户 skill: 缺 description + bad = self.user / "broken" + bad.mkdir() + (bad / "SKILL.md").write_text("---\nname: broken\n---\nbody", encoding="utf-8") + reg = self._registry() + self.assertIn("coding", reg.skills) # 没崩,内置正常 + self.assertNotIn("broken", reg.skills) # 坏的没收 + self.assertTrue(any(n == "broken" for n, _ in reg.load_errors)) + self.assertIn("未加载", reg.discovery_block()) + + def test_bad_yaml_user_skill_collected(self): + bad = self.user / "badyaml" + bad.mkdir() + (bad / "SKILL.md").write_text("---\nname: [unclosed\n---\nbody", encoding="utf-8") + reg = self._registry() + self.assertTrue(any(n == "badyaml" for n, _ in reg.load_errors)) + + def test_missing_user_dir_is_noop(self): + _write_skill(self.builtin, "coding", "内置") + reg = SkillRegistry([ + SkillSource(self.builtin, "builtin"), + SkillSource(self.root / "does-not-exist", "user"), + ]) + self.assertIn("coding", reg.skills) + self.assertEqual(reg.load_errors, []) + + +class TestSaveSkillTool(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.user_skills = self.root / ".skills" + self.builtin = self.root / "builtin" + self.builtin.mkdir() + + def tearDown(self): + self.tmp.cleanup() + + def _tool(self): + reg = SkillRegistry([ + SkillSource(self.builtin, "builtin"), + SkillSource(self.user_skills, "user"), + ]) + return SaveSkillTool(self.user_skills, reg) + + def test_save_writes_file(self): + out = self._tool().execute( + name="mine", + content="---\nname: mine\ndescription: 我的 skill\n---\n\n# Mine\n", + ) + self.assertIn("saved", out) + self.assertTrue((self.user_skills / "mine" / "SKILL.md").exists()) + + def test_reject_bad_name(self): + out = self._tool().execute(name="../evil", content="---\ndescription: x\n---\n") + self.assertIn("[Error]", out) + self.assertFalse((self.user_skills / "../evil").exists()) + + def test_reject_missing_description(self): + out = self._tool().execute(name="mine", content="---\nname: mine\n---\nbody") + self.assertIn("[Error]", out) + self.assertIn("description", out) + + def test_reject_bad_yaml(self): + out = self._tool().execute(name="mine", content="---\nname: [bad\n---\nbody") + self.assertIn("[Error]", out) + + def test_reject_name_mismatch(self): + out = self._tool().execute( + name="mine", + content="---\nname: other\ndescription: x\n---\n", + ) + self.assertIn("[Error]", out) + self.assertIn("不一致", out) + + def test_warns_on_builtin_override(self): + _write_skill(self.builtin, "ppt", "内置 ppt") + out = self._tool().execute( + name="ppt", + content="---\nname: ppt\ndescription: 我的 ppt\n---\n", + ) + self.assertIn("saved", out) + self.assertIn("覆盖内置", out) + + +class TestForkSkillTool(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.user_skills = self.root / ".skills" + self.builtin = self.root / "builtin" + self.builtin.mkdir() + # 带脚本的内置 skill + d = _write_skill(self.builtin, "ppt", "内置 ppt") + (d / "scripts").mkdir() + (d / "scripts" / "helper.py").write_text("X = 1\n", encoding="utf-8") + + def tearDown(self): + self.tmp.cleanup() + + def _tool(self): + reg = SkillRegistry([ + SkillSource(self.builtin, "builtin"), + SkillSource(self.user_skills, "user"), + ]) + return ForkSkillTool(self.user_skills, reg) + + def test_fork_copies_scripts_and_renames(self): + out = self._tool().execute(src="ppt", new_name="ppt-mine") + self.assertIn("forked", out) + dest = self.user_skills / "ppt-mine" + self.assertTrue((dest / "scripts" / "helper.py").exists()) # 脚本带过来 + md = (dest / "SKILL.md").read_text(encoding="utf-8") + self.assertIn("name: ppt-mine", md) # frontmatter name 对齐新名 + self.assertNotIn("name: ppt\n", md) + + def test_forked_renamed_skill_does_not_shadow_builtin(self): + self._tool().execute(src="ppt", new_name="ppt-mine") + reg = SkillRegistry([ + SkillSource(self.builtin, "builtin"), + SkillSource(self.user_skills, "user"), + ]) + # 改了名,内置 ppt 不被遮,新名独立存在 + self.assertEqual(reg.get("ppt").source, "builtin") + self.assertEqual(reg.get("ppt-mine").source, "user") + + def test_reject_existing_dest(self): + self._tool().execute(src="ppt", new_name="ppt-mine") + out = self._tool().execute(src="ppt", new_name="ppt-mine") + self.assertIn("[Error]", out) + self.assertIn("已存在", out) + + def test_reject_unknown_src(self): + out = self._tool().execute(src="nope", new_name="x") + self.assertIn("[Error]", out) + self.assertIn("不存在", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/skill_authoring.py b/tools/skill_authoring.py new file mode 100644 index 0000000..13fcca1 --- /dev/null +++ b/tools/skill_authoring.py @@ -0,0 +1,178 @@ +"""用户 skill 创作工具: save_skill / fork_skill。 + +为什么是 host-side typed tool(而非让 agent 用 fs/shell 写): + - fs/shell 的 base_dir 锚在进程 cwd(host)或容器 workdir(docker),**都不指向** + `user_root/.skills` —— 用相对路径写 `.skills` 只在 docker 下碰巧成立,host 下会 + 写错地方,跨 backend 不可靠。 + - host-side 工具直接知道 `user_root/.skills`,一个落点两种 backend 通吃(与 + seedream / DocumentDownload 直接 host 侧写 working_dir 完全一致的范式)。 + - docker 下 user_root 整个 bind 到 /workspace,host 侧写进 .skills 的文件在容器内 + `/workspace/.skills/...` 自动可见(fork 带过来的脚本随之可跑)。 + +这两个工具不在 `executor_docker.CONTAINER_TOOLS` 里,故恒在 host 侧执行。 +""" +from __future__ import annotations + +import re +import shutil +from pathlib import Path +from typing import Optional + +from core.skills import SkillRegistry, parse_frontmatter + +from .base import Tool + +# skill 名:小写字母 / 数字开头,可含 _ -,≤64。挡住路径穿越(`../`、`/`)、空格、大写。 +_SKILL_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL) + + +def _validate_skill_name(name: str) -> Optional[str]: + """返回错误串(不合法)或 None(合法)。""" + if not name or not _SKILL_NAME_RE.match(name): + return ( + f"skill 名 '{name}' 不合法:须小写字母/数字开头,只含小写字母、数字、_ 、-," + "长度 ≤64(用于目录名,故挡空格 / 大写 / 路径分隔符)" + ) + return None + + +def _set_frontmatter_name(text: str, new_name: str) -> str: + """把 markdown frontmatter 里的 name 改成 new_name(只动 name 行,不重排其余字段)。 + + fork 后复制来的 SKILL.md 仍带原 skill 的 `name:`,不改的话注册时会用旧名 → + 与被 fork 的内置同名、反而覆盖了内置。故 fork 落盘后必须把 name 对齐到 new_name。 + """ + m = _FRONTMATTER_RE.match(text) + if not m: + return f"---\nname: {new_name}\n---\n\n" + text + fm = m.group(1) + if re.search(r"(?m)^name:", fm): + fm = re.sub(r"(?m)^name:.*$", f"name: {new_name}", fm, count=1) + else: + fm = f"name: {new_name}\n" + fm + return f"---\n{fm}\n---\n" + text[m.end():] + + +class SaveSkillTool(Tool): + name = "save_skill" + description = ( + "Create or overwrite one of the USER's own skills at .skills//SKILL.md. " + "Use to author a skill from scratch or save an edited copy. The content must be a " + "full SKILL.md with YAML frontmatter containing `name` and `description` " + "(description is the routing blurb shown in the skill list — make it specific: " + "trigger words + when NOT to use). Takes effect from the user's NEXT message. " + "To copy a built-in skill that bundles scripts (e.g. ppt), use fork_skill instead " + "so the scripts come along." + ) + parameters = { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Skill name = directory name (lowercase, [a-z0-9_-], <=64). " + "Same name as a built-in => overrides it for this user; pick a new name to keep both.", + }, + "content": { + "type": "string", + "description": "Full SKILL.md text including --- frontmatter --- (name + description).", + }, + }, + "required": ["name", "content"], + } + + def __init__( + self, + user_skills_dir: Path, + registry: SkillRegistry, + base_dir: Optional[Path] = None, + user_root: Optional[Path] = None, + ) -> None: + super().__init__(base_dir, user_root=user_root) + self.user_skills_dir = Path(user_skills_dir) + self.registry = registry + + def execute(self, name: str, content: str) -> str: + err = _validate_skill_name(name) + if err is not None: + return f"[Error] {err}" + # frontmatter 必须合法且有 description —— 写时就挡住"加载失败"黑洞 + try: + meta, _ = parse_frontmatter(content) + except Exception as e: # yaml.YAMLError 等 + return f"[Error] frontmatter YAML 非法,无法保存:{e}" + if not (meta.get("description") or "").strip(): + return "[Error] frontmatter 缺 description —— 这是 skill 列表里的路由说明,必填(写清触发词 + 何时别用)" + fm_name = (meta.get("name") or "").strip() + if fm_name and fm_name != name: + return ( + f"[Error] frontmatter 里 name='{fm_name}' 与目标名 '{name}' 不一致 —— " + "请改成一致(或省略 frontmatter 的 name,默认用目录名)" + ) + skill_dir = self.user_skills_dir / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") + builtin = self.registry.get(name) + note = "" + if builtin is not None and builtin.source == "builtin": + note = f" (注意:与内置 skill '{name}' 同名,你这条将覆盖内置;想并存请改名)" + return f"[saved skill '{name}' to .skills/{name}/SKILL.md,下条消息生效]{note}" + + +class ForkSkillTool(Tool): + name = "fork_skill" + description = ( + "Copy an existing skill (built-in or one of the user's own) — INCLUDING its bundled " + "scripts/assets — into the user's .skills//, so they can customize it. " + "This is the right way to 'copy zcbot's ppt skill and tweak it': fork first, then " + "edit the copied SKILL.md (with the edit tool or save_skill). The new copy's " + "frontmatter name is auto-set to new_name. Takes effect from the user's NEXT message." + ) + parameters = { + "type": "object", + "properties": { + "src": {"type": "string", "description": "Name of the skill to copy (as listed in the skill discovery block)."}, + "new_name": { + "type": "string", + "description": "New skill name (lowercase, [a-z0-9_-], <=64). Must not already exist under .skills/.", + }, + }, + "required": ["src", "new_name"], + } + + def __init__( + self, + user_skills_dir: Path, + registry: SkillRegistry, + base_dir: Optional[Path] = None, + user_root: Optional[Path] = None, + ) -> None: + super().__init__(base_dir, user_root=user_root) + self.user_skills_dir = Path(user_skills_dir) + self.registry = registry + + def execute(self, src: str, new_name: str) -> str: + err = _validate_skill_name(new_name) + if err is not None: + return f"[Error] {err}" + skill = self.registry.get(src) + if skill is None: + available = ", ".join(self.registry.skills.keys()) or "(none)" + return f"[Error] 源 skill '{src}' 不存在。可选:{available}" + dest = self.user_skills_dir / new_name + if dest.exists(): + return f"[Error] .skills/{new_name}/ 已存在 —— 换个名字,或先删旧的" + self.user_skills_dir.mkdir(parents=True, exist_ok=True) + # copytree 整目录(SKILL.md + scripts/ + references/ + assets/ 一并带过来) + shutil.copytree(skill.skill_dir, dest) + md = dest / "SKILL.md" + n_files = sum(1 for _ in dest.rglob("*") if _.is_file()) + if md.exists(): + md.write_text( + _set_frontmatter_name(md.read_text(encoding="utf-8"), new_name), + encoding="utf-8", + ) + return ( + f"[forked '{src}' → .skills/{new_name}/ ({n_files} 个文件,frontmatter name 已设为 " + f"'{new_name}'),下条消息生效。现在可以编辑 .skills/{new_name}/SKILL.md 改造它]" + ) diff --git a/tools/skill_tool.py b/tools/skill_tool.py index 1b558fe..5be39c6 100644 --- a/tools/skill_tool.py +++ b/tools/skill_tool.py @@ -36,16 +36,9 @@ class LoadSkillTool(Tool): registry: SkillRegistry, base_dir: Optional[Path] = None, user_root: Optional[Path] = None, - container_skills_dir: Optional[str] = None, ) -> None: super().__init__(base_dir, user_root=user_root) self.registry = registry - # docker backend 下,fs / shell / run_python 都在容器里跑,host skills/ bind - # mount 到 /sandbox/skills:ro(pool.py)。header 里的 dir 要给容器内可用路径 - # —— 否则 LLM 拿 host 绝对路径(`/home/.../skills/`)去 read references - # 时容器看不见,抓瞎报 file not found。POSIX 串(容器恒为 Linux),与 host OS 无关。 - # None = host backend,保持 skill.skill_dir 原 host 绝对路径。 - self.container_skills_dir = container_skills_dir def execute(self, name: str) -> str: skill = self.registry.get(name) @@ -53,9 +46,10 @@ class LoadSkillTool(Tool): available = ", ".join(self.registry.skills.keys()) or "(none)" return f"[Error] skill '{name}' not found. Available: {available}" body = skill.full_content() - if self.container_skills_dir is not None: - dir_str = f"{self.container_skills_dir.rstrip('/')}/{skill.name}" - else: - dir_str = str(skill.skill_dir) + # docker backend 下 fs/shell/run_python 都在容器里跑,skill 目录按来源 bind 到 + # 不同挂载点(内置 → /sandbox/skills:ro,用户 → /workspace/.skills);registry + # 据 skill.source 给容器内路径,否则 LLM 拿 host 绝对路径在沙盒里 read 不到 + # references。host backend → None,退回 skill_dir 原 host 绝对路径。 + dir_str = self.registry.container_dir(skill) or str(skill.skill_dir) header = f"[skill={skill.name}, dir={dir_str}]\n" return header + body diff --git a/web/app.py b/web/app.py index 9882297..6b67984 100644 --- a/web/app.py +++ b/web/app.py @@ -1129,26 +1129,78 @@ def create_app() -> FastAPI: @app.get("/v1/skills", tags=["skills"]) def list_skills(user_id: UUID = Depends(require_user)): - """列出当前可用的 skill(智能体类型),供新建 task 时下拉选择。 + """列出当前用户可用的 skill(内置 + 自己的),供新建 task 时下拉选择。 - 每次请求现扫 `skills//SKILL.md` frontmatter(~9 个文件,稳态 ~3ms), - 以便加 / 改 / 删 skill 目录后无需重启 web 即可在前端下拉看到。 - `core/agent_builder.py::build_agent` 同样每次新建 SkillRegistry, - 所以 agent 内部 `load_skill` 工具与 system prompt discovery 也是热的。 - 排序按 name 升序(registry 内部 iterdir + sorted)。 + 每次请求现扫(内置 `skills//SKILL.md` + 用户 `.skills//SKILL.md`, + 稳态 ~3ms),加 / 改 / 删 skill 目录后无需重启即可在前端看到。 + `core/agent_builder.py::build_agent` 同样每次新建 SkillRegistry,所以 agent 内部 + `load_skill` 与 system prompt discovery 也是热的。源标 `source`(builtin/user)+ + `overrides_builtin`(用户 skill 覆盖了同名内置)。`load_errors` 列出用户 skill + 因 frontmatter 问题未加载的,供前端提示。 """ - from core.agent_builder import load_config - from core.paths import ROOT - from core.skills import SkillRegistry + from core.agent_builder import build_skill_registry, load_config, resolve_workspace cfg = load_config() - reg = SkillRegistry(ROOT / cfg.get("skills_dir", "skills")) + ws = resolve_workspace(None, cfg) + reg = build_skill_registry(cfg, ws, user_id, docker=False) return { "skills": [ - {"name": s.name, "description": s.description} + { + "name": s.name, + "description": s.description, + "source": s.source, + "overrides_builtin": s.name in reg.user_overrides, + } for s in reg.skills.values() - ] + ], + "load_errors": [{"name": n, "reason": r} for n, r in reg.load_errors], } + @app.get("/v1/skills/{name}", tags=["skills"]) + def get_skill(name: str, user_id: UUID = Depends(require_user)): + """返回某 skill 的完整 SKILL.md 正文(供前端 modal 展开查看)。 + + 内置 + 用户两来源都可查;同名时按 user wins 取用户那份(与 agent 看到的一致)。 + """ + from core.agent_builder import build_skill_registry, load_config, resolve_workspace + cfg = load_config() + ws = resolve_workspace(None, cfg) + reg = build_skill_registry(cfg, ws, user_id, docker=False) + skill = reg.get(name) + if skill is None: + raise HTTPException(404, f"skill not found: {name!r}") + try: + content = skill.full_content() + except OSError as e: + raise HTTPException(500, f"读取 SKILL.md 失败: {e}") + return { + "name": skill.name, + "source": skill.source, + "overrides_builtin": skill.name in reg.user_overrides, + "content": content, + } + + @app.delete("/v1/skills/{name}", status_code=204, tags=["skills"]) + def delete_skill(name: str, user_id: UUID = Depends(require_user)): + """删除当前用户的私有 skill(`.skills//` 整目录)。 + + 只能删 user 源 —— 内置 skill 不可删(404,等同"用户那里没有这个可删的")。 + `.skills` 在文件面板隐藏,这是 UI 上删除自己 skill 的唯一入口。 + """ + import shutil + from core.agent_builder import build_skill_registry, load_config, resolve_workspace, user_root + cfg = load_config() + ws = resolve_workspace(None, cfg) + reg = build_skill_registry(cfg, ws, user_id, docker=False) + skill = reg.get(name) + if skill is None or skill.source != "user": + raise HTTPException(404, f"no user skill to delete: {name!r}") + # 防穿越:目标必须落在该用户的 .skills 子树内(skill_dir 来自扫描,理应如此,仍兜一层) + user_skills_dir = (user_root(ws, user_id) / ".skills").resolve() + target = skill.skill_dir.resolve() + if user_skills_dir not in target.parents: + raise HTTPException(400, "拒绝删除 .skills 之外的路径") + shutil.rmtree(target) + @app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"]) def delete_task(task_id: str, user_id: UUID = Depends(require_user)): """硬删除:DELETE DB 行(messages / usage_events CASCADE)。 diff --git a/web/static/dev.html b/web/static/dev.html index 426b303..48983e1 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -200,6 +200,66 @@ display: flex; gap: 8px; justify-content: flex-end; } + /* ───── 左侧 rail 底部「我的资源」入口(技能,后续可加记忆)───── */ + #rail-resources { + flex-shrink: 0; border-top: 1px solid var(--border); + padding: 8px; display: flex; gap: 6px; + } + #rail-resources > button { flex: 1; font-size: 13px; } + + /* ───── 技能查看 modal ───── */ + #skills-modal { z-index: 112; } + #skills-modal .card { + width: 720px; max-width: 92vw; max-height: 84vh; + display: flex; flex-direction: column; + } + #skills-modal h3 { + margin: 0; padding: 12px 16px; font-size: 16px; + border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 8px; + } + #skills-modal h3 .spacer { flex: 1; } + #skills-modal .sk-x { + border: none; background: transparent; font-size: 16px; + cursor: pointer; color: var(--muted); padding: 2px 6px; + } + #skills-modal #sk-back { margin-right: 4px; } + #skills-modal .sk-detail-name { font-weight: 600; } + #skills-modal .body { padding: 12px 16px; overflow: auto; } + .sk-group-title { font-weight: 600; font-size: 12px; color: var(--muted); margin: 0 0 8px; } + .sk-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border: 1px solid var(--border); + border-radius: var(--r-md); margin-bottom: 6px; cursor: pointer; + } + .sk-item:hover { border-color: var(--accent); background: #fafafa; } + .sk-item-main { flex: 1; min-width: 0; } + .sk-item .sk-name { font-weight: 600; font-size: 13px; } + .sk-item .sk-desc { + font-size: 12px; color: var(--muted); margin-top: 2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .sk-badge { + font-size: 10px; font-weight: 500; color: var(--accent); + border: 1px solid var(--accent); border-radius: 8px; padding: 0 5px; + margin-left: 4px; white-space: nowrap; + } + .sk-del { flex-shrink: 0; } + .sk-loaderr { + margin-top: 14px; padding: 8px 10px; font-size: 12px; + border: 1px solid var(--accent); border-radius: var(--r-md); + color: var(--accent); background: rgba(220,80,80,0.05); + } + .sk-detail { font-size: 13px; line-height: 1.6; } + .sk-detail pre { + white-space: pre-wrap; word-break: break-word; + background: #f5f5f5; padding: 10px; border-radius: var(--r-md); overflow: auto; + } + .sk-detail code { word-break: break-word; } + .sk-detail h1, .sk-detail h2, .sk-detail h3 { margin: 14px 0 6px; } + .sk-detail table { border-collapse: collapse; } + .sk-detail th, .sk-detail td { border: 1px solid var(--border); padding: 4px 8px; } + /* ───── 3-pane layout ───── */ #app { display: none; height: 100vh; } #app.ready { @@ -990,6 +1050,18 @@ + + +
@@ -1056,6 +1128,9 @@
加载中…
+
+ +
diff --git a/web/static/js/main.js b/web/static/js/main.js index 0fe67e2..000d878 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -6,6 +6,7 @@ import { humanSize, fmtTime } from "./format.js"; import { $ } from "./dom.js"; import { api } from "./api.js"; import { closeChpwModal } from "./auth.js"; +import { closeSkillsModal } from "./skills.js"; import { closeFilePreview, closeMiniPreview } from "./preview.js"; import { closeSrcPicker, loadFiles } from "./files.js"; import { loadFolderSuggestions } from "./newtask.js"; @@ -58,6 +59,7 @@ document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; // 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80) if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; } + if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; } if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; } if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } diff --git a/web/static/js/skills.js b/web/static/js/skills.js new file mode 100644 index 0000000..11766d0 --- /dev/null +++ b/web/static/js/skills.js @@ -0,0 +1,112 @@ +// 技能 modal:查看「平台 skill」/「我的 skill」两组列表,点任一项展开完整 SKILL.md, +// 「我的」每项可删除(平台 skill 只读)。左侧 rail 底部「技能」按钮触发。 +// 后端:GET /v1/skills(列表)、GET /v1/skills/{name}(正文)、DELETE /v1/skills/{name}(只删 user 源)。 +// 创建 / 改 / fork 仍走对话(save_skill / fork_skill / skill-creator)。 +import { $ } from "./dom.js"; +import { state } from "./state.js"; +import { api } from "./api.js"; +import { escapeHtml } from "./format.js"; +import { renderMd, highlightIn } from "./markdown.js"; + +function openSkillsModal() { + $("skills-modal").classList.add("show"); + renderList(); +} +export function closeSkillsModal() { + $("skills-modal").classList.remove("show"); +} + +function itemHtml(s) { + const badge = s.overrides_builtin + ? ' 已覆盖平台同名' + : ""; + const del = + s.source === "user" + ? `` + : ""; + return `
+
+
${escapeHtml(s.name)}${badge}
+
${escapeHtml(s.description || "")}
+
${del} +
`; +} + +async function renderList() { + const body = $("sk-body"); + $("sk-title").textContent = "技能"; + body.innerHTML = '
加载中…
'; + let data; + try { + data = await api("GET", "/v1/skills"); + } catch (e) { + body.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; + return; + } + state.skills = data.skills || []; // 顺手刷新新建任务下拉的缓存 + const platform = state.skills.filter((s) => s.source === "builtin"); + const mine = state.skills.filter((s) => s.source === "user"); + + let html = ""; + html += `
平台 skill (${platform.length})
`; + html += + platform.map(itemHtml).join("") || + '
(无)
'; + html += `
我的 skill (${mine.length})
`; + html += mine.length + ? mine.map(itemHtml).join("") + : '
还没有。让助手「帮我做个 skill」或「把某个平台 skill fork 成我的」即可创建。
'; + + if (data.load_errors && data.load_errors.length) { + const errs = data.load_errors + .map((e) => `${escapeHtml(e.name)}(${escapeHtml(e.reason)})`) + .join(";"); + html += `
⚠ ${data.load_errors.length} 个 skill 因格式问题未加载:${errs}
`; + } + body.innerHTML = html; +} + +async function showDetail(name) { + const body = $("sk-body"); + $("sk-title").innerHTML = `${escapeHtml(name)}`; + $("sk-back").onclick = renderList; + body.innerHTML = '
加载中…
'; + let data; + try { + data = await api("GET", "/v1/skills/" + encodeURIComponent(name)); + } catch (e) { + body.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; + return; + } + body.innerHTML = `
${renderMd(data.content)}
`; + highlightIn(body); +} + +// ───── 顶层绑定 ───── +$("hd-skills").onclick = openSkillsModal; +$("sk-close").onclick = closeSkillsModal; +$("skills-modal").addEventListener("click", (e) => { + if (e.target.id === "skills-modal") closeSkillsModal(); // 点遮罩关闭 +}); + +// 列表区事件委托:删除(冒泡到 [data-del])优先于点开详情(.sk-item) +$("sk-body").addEventListener("click", async (e) => { + const del = e.target.closest("[data-del]"); + if (del) { + e.stopPropagation(); + const name = del.getAttribute("data-del"); + if (!confirm(`删除你的 skill「${name}」?不可撤销(平台同名 skill 不受影响)。`)) return; + del.disabled = true; + try { + await api("DELETE", "/v1/skills/" + encodeURIComponent(name)); + state.skills = null; // 失效缓存,新建任务下拉下次重拉 + renderList(); + } catch (err) { + alert("删除失败: " + err.message); + del.disabled = false; + } + return; + } + const item = e.target.closest(".sk-item"); + if (item) showDetail(item.getAttribute("data-name")); +});