feat(skills): 用户私有 skill(.skills)+ 创作工具 + skill-creator + Web 查看页

每用户可在私有 .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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-11 09:46:39 +08:00
parent d9b48bdb96
commit 958678aa12
15 changed files with 968 additions and 91 deletions

View File

@ -39,7 +39,8 @@ zcbot/
│ ├── fs.py # read / write / edit (唯一匹配) / glob / grep │ ├── fs.py # read / write / edit (唯一匹配) / glob / grep
│ ├── shell.py # subprocess + 黑名单 │ ├── shell.py # subprocess + 黑名单
│ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤 │ ├── 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 ├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets
├── prompts/system/general_v1.md ├── prompts/system/general_v1.md
├── config/{agent.yaml, models/*.yaml} ├── 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)。 yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。
### 3.4 工具系统(Hybrid 范式) ### 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`)— 批处理 / 算数据 / 生成文档。 **Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。
关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。 关键设计:`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` 按需拉)。 对齐 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 决定模型能否触发。 原则:写 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 ### 3.6 Session 与 Task
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。 **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,改物理边界替代代码护栏): 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/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK/引号透明传);`tool_runner.py` 复用 `tools/fs.py`,skill references 走 `skills:/sandbox/skills:ro` mount。 - **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/<wd> -i <c> python /sandbox/tool_runner.py <name>` + 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 下噪声。 - 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 文档提示降级。 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 文档提示降级。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `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 ### 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` 仍过。 - **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/probe.py 243
core/session.py 153 ← ORM core/session.py 153 ← ORM
core/task.py 82 ← PG-backed TaskState 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/memory.py 81 ← per-user `.memory/` dotfile
core/export_docx.py 383 core/export_docx.py 383
core/storage/{__init__,engine,models,usage,utils}.py ← 4 表(0004-0007 演进);record_chat/image_usage core/storage/{__init__,engine,models,usage,utils}.py ← 4 表(0004-0007 演进);record_chat/image_usage
core/ark_client.py 105 ← 火山方舟 HTTP 客户端 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 容器池 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 main.py ~210 ← 入口:web / db / probe / user / sandbox check
db/migrations/versions/ 0001-0008 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/auth.py ~190 ← 邮箱密码 + platform_key → JWT
web/broker.py / sinks.py / pptx_render.py 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 web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3400 行(+ dev SPA + vendor 1MB);加 skills 脚本 + 配置,总仓库约 3800 行 Python 合计 ~3400 行(+ dev SPA + vendor 1MB);加 skills 脚本 + 配置,总仓库约 3800 行

4
RUN.md
View File

@ -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 切回 | 必填 | | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 | | `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 | | `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/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 | | `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` | 必填 | | `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: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 | | `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
@ -735,6 +738,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`): - **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离 - `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离
- `workspace/users/<user_id>/.skills/<name>/SKILL.md` — 用户私有 skill,dotfile 隐藏;只对该用户生效,与内置同名则覆盖内置(user wins)。由 agent 工具 `save_skill` / `fork_skill` 写(host-side,不走沙箱 fs);docker 下随 user_root bind 到 `/workspace/.skills`
- `workspace/users/<user_id>/<working_dir>/` — 工作目录,用户起名,同 working_dir 多 task 共享 - `workspace/users/<user_id>/<working_dir>/` — 工作目录,用户起名,同 working_dir 多 task 共享
--- ---

View File

@ -1,11 +1,13 @@
# zcbot Skill 清单 # zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-06-08 最后更新:2026-06-11
Skill 总数:14 Skill 总数:15
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
> **用户私有 skill**:除内置 skill 外,每个用户可在自己私有的 `.skills/` 下创建 / 改造 skill(只对自己生效,不影响他人)。用 `skill-creator` 引导即可——从零写或 fork 某个内置 skill 再改。用户 skill 与内置**同名则覆盖内置**(列表里标 `[你的·已覆盖内置]`),改名则并存。
--- ---
## 速览 ## 速览
@ -26,6 +28,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
| 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) | | 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) |
| 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) | | 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) |
| 通用 | [coding](#coding) | 修代码 / 调试 / 重构 | | 通用 | [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/<name>/`(文件面板隐藏),用 `save_skill` / `fork_skill` 落盘(**不走 fs/shell**——沙箱 fs 根够不到那里)
- 造好 / 改好后**下一条消息**才生效(registry 每轮重建)
- 同名内置 → 覆盖(user wins,列表显式标注);改名 → 并存
- `save_skill` 写时校验 frontmatter(缺 description / YAML 坏直接拒),挡住"加载失败"黑洞
**典型产物**:`.skills/<name>/SKILL.md`(+ fork 带来的 scripts / references / templates)。
---
## 跨 skill 协作 ## 跨 skill 协作
实际任务往往跨多个 skill,典型组合: 实际任务往往跨多个 skill,典型组合:
@ -439,6 +467,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
- **写标准全流程**:analyze(定标准化对象) → stats_ml(配方-性能 / 精密度试验数据定指标) → research / documents(查国内外现有标准与现状) → standard(起草标准 + 编制说明) → plot_pub(出图) → review(送审前终审) - **写标准全流程**:analyze(定标准化对象) → stats_ml(配方-性能 / 精密度试验数据定指标) → research / documents(查国内外现有标准与现状) → standard(起草标准 + 编制说明) → plot_pub(出图) → review(送审前终审)
- **PPT 汇报**:analyze(提炼论点) → research / documents(找数据 + 引文) → plot_pub(出图) → ppt(组装 deck) → imagegen(可选,做封面 / 引子页) - **PPT 汇报**:analyze(提炼论点) → research / documents(找数据 + 引文) → plot_pub(出图) → ppt(组装 deck) → imagegen(可选,做封面 / 引子页)
- **晶体计算**:pymatgen(算 XRD / 相图) → plot_pub(出图) → proposal / patent(写到本子 / 交底书里) - **晶体计算**:pymatgen(算 XRD / 相图) → plot_pub(出图) → proposal / patent(写到本子 / 交底书里)
- **定制能力**:skill-creator(fork 某内置 skill,如 ppt / proposal) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版
--- ---

View File

@ -49,6 +49,7 @@ from tools.run_python import RunPythonTool
from tools.seedance import SeedanceTool from tools.seedance import SeedanceTool
from tools.seedream import SeedreamTool from tools.seedream import SeedreamTool
from tools.shell import ShellTool from tools.shell import ShellTool
from tools.skill_authoring import ForkSkillTool, SaveSkillTool
from tools.skill_tool import LoadSkillTool from tools.skill_tool import LoadSkillTool
from tools.task_progress import TaskProgressTool from tools.task_progress import TaskProgressTool
from tools.web_fetch import WebFetchTool 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: def user_root(workspace_dir: Path, user_id: UUID) -> Path:
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` 都在下面。""" """per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` / `.skills/` 都在下面。"""
d = workspace_dir / "users" / str(user_id) d = workspace_dir / "users" / str(user_id)
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d 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): class InvalidTaskName(ValueError):
"""task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。""" """task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。"""
@ -368,7 +393,8 @@ def build_agent(
tool_base = Path(tool_base) if tool_base else Path.cwd() 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), # 媒体配置提前 load 一次:既决定 system prompt 要不要追加媒体段(media_enabled),
# 也复用给下方 seedream/seedance 注册(避免重复读 doubao.yaml)。无 ARK_API_KEY → None。 # 也复用给下方 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) wf = WebFetchTool(base_dir=tool_base, user_root=ur_path)
tools[wf.name] = wf tools[wf.name] = wf
import os
# Secret-bearing domain tools stay host-side. Never expose DOCUMENT_SEARCH_API_KEY # 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 # / MP_API_KEY to run_python or the sandbox; only register typed tools when the
# corresponding host env exists. # corresponding host env exists.
@ -477,22 +501,20 @@ def build_agent(
tools[t.name] = t tools[t.name] = t
if skills.skills: if skills.skills:
# docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到 # LoadSkillTool 返回头里的 dir 由 registry 按 skill.source 给容器内路径
# /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM # (内置 → /sandbox/skills,用户 → /workspace/.skills);host backend → host 绝对路径。
# 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。 ls = LoadSkillTool(registry=skills, base_dir=tool_base, user_root=ur_path)
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,
)
tools[ls.name] = ls 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: if caps.enable_run_python:
rp = RunPythonTool(base_dir=tool_base, user_root=ur_path) rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
tools[rp.name] = rp tools[rp.name] = rp

View File

@ -1,15 +1,19 @@
"""Skill 注册表 (Anthropic 标准格式)。 """Skill 注册表 (Anthropic 标准格式)。
每个 skill skills/<name>/ 目录,内含 SKILL.md( frontmatter)+ 可选的 每个 skill <root>/<name>/ 目录,内含 SKILL.md( frontmatter)+ 可选的
references/scripts/assets/启动时只读 frontmatter discovery,完整 SKILL.md references/scripts/assets/启动时只读 frontmatter discovery,完整 SKILL.md
references agent 按需加载(渐进披露) references agent 按需加载(渐进披露)
多来源:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`,可写)
来源按顺序扫,**后扫的同名覆盖先扫的** 用户 skill 排在内置之后,"用户覆盖
内置"(user wins);覆盖关系记进 `user_overrides` 供 discovery 显式标注,不静默。
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Tuple from typing import Dict, List, Optional, Tuple, Union
import yaml import yaml
@ -18,7 +22,11 @@ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
def parse_frontmatter(text: str) -> Tuple[dict, str]: 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) m = _FRONTMATTER_RE.match(text)
if not m: if not m:
return {}, text return {}, text
@ -28,11 +36,20 @@ def parse_frontmatter(text: str) -> Tuple[dict, str]:
return meta, text[m.end():] return meta, text[m.end():]
class SkillLoadError(Exception):
"""skill 目录有 SKILL.md 但加载失败(YAML 坏 / 缺 description 等)。
"没有 SKILL.md(根本不是 skill 目录,静默跳过)"区分:前者要面向用户报,
后者是正常的非 skill 子目录
"""
@dataclass @dataclass
class Skill: class Skill:
name: str name: str
description: str description: str
skill_dir: Path skill_dir: Path
source: str = "builtin" # 'builtin' | 'user'
@property @property
def skill_md(self) -> Path: def skill_md(self) -> Path:
@ -42,40 +59,110 @@ class Skill:
return self.skill_md.read_text(encoding="utf-8") return self.skill_md.read_text(encoding="utf-8")
@classmethod @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" md = skill_dir / "SKILL.md"
if not md.exists(): if not md.exists():
return None return None # 不是 skill 目录,静默跳过
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8")) 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 name = meta.get("name") or skill_dir.name
desc = meta.get("description") or "" desc = meta.get("description") or ""
if not desc: if not desc:
return None # description 是 discovery 的关键,缺了不收 raise SkillLoadError("缺 description(frontmatter 必须有 name + description)")
return cls(name=name, description=desc, skill_dir=skill_dir) 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: class SkillRegistry:
def __init__(self, skills_dir: Path) -> None: def __init__(self, sources: SourcesArg) -> None:
self.skills_dir = Path(skills_dir) # 单个 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] = {} 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() self._scan()
def _scan(self) -> None: def _scan(self) -> None:
if not self.skills_dir.exists(): for src in self.sources:
return self._container_roots[src.source] = src.container_root
for child in sorted(self.skills_dir.iterdir()): if not src.root.exists():
if not child.is_dir(): continue # 用户没有 .skills 目录 → 一次 exists() 跳过,零成本
continue for child in sorted(src.root.iterdir()):
skill = Skill.from_dir(child) if not child.is_dir():
if skill is not None: continue
self.skills[skill.name] = skill 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: def discovery_block(self) -> str:
"""启动时注入 system prompt 的 skill 列表(name + description)。""" """注入 system prompt 的 skill 列表(name + description + 来源标注)。"""
if not self.skills: if not self.skills and not self.load_errors:
return "" return ""
lines = [f"- **{s.name}**: {s.description}" for s in self.skills.values()] lines = []
return "\n".join(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]: def get(self, name: str) -> Optional[Skill]:
return self.skills.get(name) return self.skills.get(name)

View File

@ -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/<name>/SKILL.md`(与 `.memory/` 同级,文件面板里隐藏)。**你不用、也不该用 write/shell 去手写这个目录** —— 沙箱 fs 的根不指向那里,跨 host/docker 不可靠。一律用下面两个工具,它们 host 侧直接落到正确位置。
**两个工具**:
- `save_skill(name, content)` — 新建 / 覆盖 `.skills/<name>/SKILL.md`。`content` 是完整 SKILL.md(含 frontmatter)。写时校验 frontmatter 合法且有 description,不合格直接拒。
- `fork_skill(src, new_name)` — 把内置(或用户已有)skill **整目录**拷到 `.skills/<new_name>/`,脚本 / 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/<new_name>/` 路径。用 `read` 看 SKILL.md,用 `edit` 改用户想改的部分(规矩 / 模板 / 默认值)。
3. 改完告诉用户:下条消息起,`<new_name>` 就在 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/<name>/scripts/`,SKILL.md 里用 `<skill_dir>/scripts/xxx.py` 引(`<skill_dir>` 是 `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/<name>/`)
- 是覆盖内置还是新增 / 并存
- 一句话:下条消息发什么就能触发它(或让 LLM 自动按 description 路由)

View File

@ -1,8 +1,9 @@
"""LoadSkillTool 路径改写测试。 """LoadSkillTool 路径改写测试。
docker backend fs/shell/run_python 在容器里跑,skills/ bind mount docker backend fs/shell/run_python 在容器里跑,skill 目录按来源 bind 到不同挂载点
`/sandbox/skills:ro`LoadSkillTool 返回头里的 `dir` 必须是容器路径而不是 host (内置 `/sandbox/skills:ro`,用户 `/workspace/.skills`)LoadSkillTool 返回头里的
绝对路径,否则 LLM host 路径调 read references 时容器 namespace 不通 `dir` 必须是容器路径而不是 host 绝对路径,否则 LLM host 路径调 read references
容器 namespace 不通容器路径由 `SkillSource.container_root` `skill.source` 决定
""" """
from __future__ import annotations from __future__ import annotations
@ -10,7 +11,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from core.skills import SkillRegistry from core.skills import SkillRegistry, SkillSource
from tools.skill_tool import LoadSkillTool from tools.skill_tool import LoadSkillTool
@ -24,45 +25,45 @@ class TestLoadSkillToolPathRewrite(unittest.TestCase):
"---\nname: demo\ndescription: 测试用\n---\n\n# Demo body\n", "---\nname: demo\ndescription: 测试用\n---\n\n# Demo body\n",
encoding="utf-8", encoding="utf-8",
) )
self.registry = SkillRegistry(self.skills_dir)
def tearDown(self): def tearDown(self):
self.tmpdir.cleanup() self.tmpdir.cleanup()
def test_host_backend_returns_host_path(self): def test_host_backend_returns_host_path(self):
"""没传 container_skills_dir → header 用 host 绝对路径(原行为)。""" """container_root=None → header 用 host 绝对路径(原行为)。"""
tool = LoadSkillTool(registry=self.registry) registry = SkillRegistry(self.skills_dir) # 单 Path → builtin, container_root=None
tool = LoadSkillTool(registry=registry)
out = tool.execute(name="demo") out = tool.execute(name="demo")
host_path = str((self.skills_dir / "demo")) host_path = str((self.skills_dir / "demo"))
self.assertIn(f"dir={host_path}", out) self.assertIn(f"dir={host_path}", out)
self.assertIn("# Demo body", out) self.assertIn("# Demo body", out)
def test_docker_backend_rewrites_to_sandbox_path(self): def test_docker_backend_rewrites_to_sandbox_path(self):
"""传 container_skills_dir=/sandbox/skills → header 用容器路径,且不漏 host 路径。""" """container_root=/sandbox/skills → header 用容器路径,且不漏 host 路径。"""
tool = LoadSkillTool( registry = SkillRegistry(
registry=self.registry, SkillSource(self.skills_dir, "builtin", "/sandbox/skills")
container_skills_dir="/sandbox/skills",
) )
tool = LoadSkillTool(registry=registry)
out = tool.execute(name="demo") out = tool.execute(name="demo")
self.assertIn("dir=/sandbox/skills/demo", out) self.assertIn("dir=/sandbox/skills/demo", out)
# host 临时目录路径不应出现在 header(防止改写不彻底) # host 临时目录路径不应出现在 header(防止改写不彻底)
host_path = str((self.skills_dir / "demo")) host_path = str((self.skills_dir / "demo"))
self.assertNotIn(host_path, out) self.assertNotIn(host_path, out)
# body 不变
self.assertIn("# Demo body", out) self.assertIn("# Demo body", out)
def test_docker_backend_strips_trailing_slash(self): def test_docker_backend_strips_trailing_slash(self):
"""container_skills_dir 带末尾斜杠 → 拼接路径不应出现双斜杠。""" """container_root 带末尾斜杠 → 拼接路径不应出现双斜杠。"""
tool = LoadSkillTool( registry = SkillRegistry(
registry=self.registry, SkillSource(self.skills_dir, "builtin", "/sandbox/skills/")
container_skills_dir="/sandbox/skills/",
) )
tool = LoadSkillTool(registry=registry)
out = tool.execute(name="demo") out = tool.execute(name="demo")
self.assertIn("dir=/sandbox/skills/demo", out) self.assertIn("dir=/sandbox/skills/demo", out)
self.assertNotIn("//demo", out) self.assertNotIn("//demo", out)
def test_unknown_skill_returns_error(self): 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") out = tool.execute(name="nonexistent")
self.assertIn("not found", out) self.assertIn("not found", out)
self.assertIn("demo", out) # available list self.assertIn("demo", out) # available list

205
tests/test_user_skills.py Normal file
View File

@ -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()

178
tools/skill_authoring.py Normal file
View File

@ -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/<name>/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/<new_name>/, 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 改造它]"
)

View File

@ -36,16 +36,9 @@ class LoadSkillTool(Tool):
registry: SkillRegistry, registry: SkillRegistry,
base_dir: Optional[Path] = None, base_dir: Optional[Path] = None,
user_root: Optional[Path] = None, user_root: Optional[Path] = None,
container_skills_dir: Optional[str] = None,
) -> None: ) -> None:
super().__init__(base_dir, user_root=user_root) super().__init__(base_dir, user_root=user_root)
self.registry = registry self.registry = registry
# docker backend 下,fs / shell / run_python 都在容器里跑,host skills/ bind
# mount 到 /sandbox/skills:ro(pool.py)。header 里的 dir 要给容器内可用路径
# —— 否则 LLM 拿 host 绝对路径(`/home/.../skills/<name>`)去 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: def execute(self, name: str) -> str:
skill = self.registry.get(name) skill = self.registry.get(name)
@ -53,9 +46,10 @@ class LoadSkillTool(Tool):
available = ", ".join(self.registry.skills.keys()) or "(none)" available = ", ".join(self.registry.skills.keys()) or "(none)"
return f"[Error] skill '{name}' not found. Available: {available}" return f"[Error] skill '{name}' not found. Available: {available}"
body = skill.full_content() body = skill.full_content()
if self.container_skills_dir is not None: # docker backend 下 fs/shell/run_python 都在容器里跑,skill 目录按来源 bind 到
dir_str = f"{self.container_skills_dir.rstrip('/')}/{skill.name}" # 不同挂载点(内置 → /sandbox/skills:ro,用户 → /workspace/.skills);registry
else: # 据 skill.source 给容器内路径,否则 LLM 拿 host 绝对路径在沙盒里 read 不到
dir_str = str(skill.skill_dir) # 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" header = f"[skill={skill.name}, dir={dir_str}]\n"
return header + body return header + body

View File

@ -1129,26 +1129,78 @@ def create_app() -> FastAPI:
@app.get("/v1/skills", tags=["skills"]) @app.get("/v1/skills", tags=["skills"])
def list_skills(user_id: UUID = Depends(require_user)): def list_skills(user_id: UUID = Depends(require_user)):
"""列出当前可用的 skill(智能体类型),供新建 task 时下拉选择。 """列出当前用户可用的 skill(内置 + 自己的),供新建 task 时下拉选择。
每次请求现扫 `skills/<name>/SKILL.md` frontmatter(~9 个文件,稳态 ~3ms), 每次请求现扫(内置 `skills/<name>/SKILL.md` + 用户 `.skills/<name>/SKILL.md`,
以便加 / / skill 目录后无需重启 web 即可在前端下拉看到 稳态 ~3ms), / / skill 目录后无需重启即可在前端看到
`core/agent_builder.py::build_agent` 同样每次新建 SkillRegistry, `core/agent_builder.py::build_agent` 同样每次新建 SkillRegistry,所以 agent 内部
所以 agent 内部 `load_skill` 工具与 system prompt discovery 也是热的 `load_skill` system prompt discovery 也是热的源标 `source`(builtin/user)+
排序按 name 升序(registry 内部 iterdir + sorted) `overrides_builtin`(用户 skill 覆盖了同名内置)`load_errors` 列出用户 skill
frontmatter 问题未加载的,供前端提示
""" """
from core.agent_builder import load_config from core.agent_builder import build_skill_registry, load_config, resolve_workspace
from core.paths import ROOT
from core.skills import SkillRegistry
cfg = load_config() 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 { return {
"skills": [ "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() 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/<name>/` 整目录)。
只能删 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"]) @app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"])
def delete_task(task_id: str, user_id: UUID = Depends(require_user)): def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
"""硬删除:DELETE DB 行(messages / usage_events CASCADE)。 """硬删除:DELETE DB 行(messages / usage_events CASCADE)。

View File

@ -200,6 +200,66 @@
display: flex; gap: 8px; justify-content: flex-end; 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 ───── */ /* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; } #app { display: none; height: 100vh; }
#app.ready { #app.ready {
@ -990,6 +1050,18 @@
</div> </div>
</div> </div>
<!-- ───── 技能查看 modal ───── -->
<div id="skills-modal" class="modal">
<div class="card">
<h3>
<span id="sk-title">技能</span>
<span class="spacer"></span>
<button id="sk-close" class="sk-x" title="关闭"></button>
</h3>
<div class="body" id="sk-body"><div class="muted" style="padding:8px;">加载中…</div></div>
</div>
</div>
<!-- ───── embed-mode waiting overlay (token 握手中) ───── --> <!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
<div id="embed-waiting"> <div id="embed-waiting">
<div class="spinner"></div> <div class="spinner"></div>
@ -1056,6 +1128,9 @@
<div id="task-list"><div class="empty">加载中…</div></div> <div id="task-list"><div class="empty">加载中…</div></div>
<div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div> <div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div>
</div> </div>
<div id="rail-resources" title="我的资源">
<button id="hd-skills" title="查看平台 / 我的 skill">🧩 技能</button>
</div>
</div> </div>
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div> <div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>

View File

@ -6,6 +6,7 @@ import { humanSize, fmtTime } from "./format.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { api } from "./api.js"; import { api } from "./api.js";
import { closeChpwModal } from "./auth.js"; import { closeChpwModal } from "./auth.js";
import { closeSkillsModal } from "./skills.js";
import { closeFilePreview, closeMiniPreview } from "./preview.js"; import { closeFilePreview, closeMiniPreview } from "./preview.js";
import { closeSrcPicker, loadFiles } from "./files.js"; import { closeSrcPicker, loadFiles } from "./files.js";
import { loadFolderSuggestions } from "./newtask.js"; import { loadFolderSuggestions } from "./newtask.js";
@ -58,6 +59,7 @@ document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80) // 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; } 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 ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; } if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }

112
web/static/js/skills.js Normal file
View File

@ -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
? ' <span class="sk-badge">已覆盖平台同名</span>'
: "";
const del =
s.source === "user"
? `<button class="sk-del small danger" data-del="${escapeHtml(s.name)}" title="删除我的这个 skill">删除</button>`
: "";
return `<div class="sk-item" data-name="${escapeHtml(s.name)}">
<div class="sk-item-main">
<div class="sk-name">${escapeHtml(s.name)}${badge}</div>
<div class="sk-desc">${escapeHtml(s.description || "")}</div>
</div>${del}
</div>`;
}
async function renderList() {
const body = $("sk-body");
$("sk-title").textContent = "技能";
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/skills");
} catch (e) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
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 += `<div class="sk-group-title">平台 skill (${platform.length})</div>`;
html +=
platform.map(itemHtml).join("") ||
'<div class="muted" style="padding:4px 8px;">(无)</div>';
html += `<div class="sk-group-title" style="margin-top:14px;">我的 skill (${mine.length})</div>`;
html += mine.length
? mine.map(itemHtml).join("")
: '<div class="muted" style="padding:4px 8px;">还没有。让助手「帮我做个 skill」或「把某个平台 skill fork 成我的」即可创建。</div>';
if (data.load_errors && data.load_errors.length) {
const errs = data.load_errors
.map((e) => `${escapeHtml(e.name)}(${escapeHtml(e.reason)})`)
.join("");
html += `<div class="sk-loaderr">⚠ ${data.load_errors.length} 个 skill 因格式问题未加载:${errs}</div>`;
}
body.innerHTML = html;
}
async function showDetail(name) {
const body = $("sk-body");
$("sk-title").innerHTML = `<button id="sk-back" class="small"> 返回</button><span class="sk-detail-name">${escapeHtml(name)}</span>`;
$("sk-back").onclick = renderList;
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/skills/" + encodeURIComponent(name));
} catch (e) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
body.innerHTML = `<div class="sk-detail">${renderMd(data.content)}</div>`;
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"));
});