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:
parent
d9b48bdb96
commit
958678aa12
|
|
@ -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/<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 下噪声。
|
||||
|
||||
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 文档提示降级。
|
||||
|
|
|
|||
17
PROGRESS.md
17
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 行
|
||||
|
|
|
|||
4
RUN.md
4
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/<name>/` 整目录);只删 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: <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)
|
||||
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
|
||||
- `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 共享
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
# zcbot Skill 清单
|
||||
|
||||
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
||||
最后更新:2026-06-08
|
||||
Skill 总数:14
|
||||
最后更新:2026-06-11
|
||||
Skill 总数:15
|
||||
|
||||
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 起 / 段) |
|
||||
| 通用 | [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/<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,典型组合:
|
||||
|
|
@ -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) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 子树根:`<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.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
|
||||
|
|
|
|||
127
core/skills.py
127
core/skills.py
|
|
@ -1,15 +1,19 @@
|
|||
"""Skill 注册表 (Anthropic 标准格式)。
|
||||
|
||||
每个 skill 是 skills/<name>/ 目录,内含 SKILL.md(带 frontmatter)+ 可选的
|
||||
每个 skill 是 <root>/<name>/ 目录,内含 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()):
|
||||
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
|
||||
skill = Skill.from_dir(child)
|
||||
if skill is not None:
|
||||
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:
|
||||
"""启动时注入 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)
|
||||
|
|
|
|||
|
|
@ -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 路由)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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 改造它]"
|
||||
)
|
||||
|
|
@ -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/<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:
|
||||
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
|
||||
|
|
|
|||
78
web/app.py
78
web/app.py
|
|
@ -1129,25 +1129,77 @@ def create_app() -> FastAPI:
|
|||
|
||||
@app.get("/v1/skills", tags=["skills"])
|
||||
def list_skills(user_id: UUID = Depends(require_user)):
|
||||
"""列出当前可用的 skill(智能体类型),供新建 task 时下拉选择。
|
||||
"""列出当前用户可用的 skill(内置 + 自己的),供新建 task 时下拉选择。
|
||||
|
||||
每次请求现扫 `skills/<name>/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/<name>/SKILL.md` + 用户 `.skills/<name>/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}
|
||||
for s in reg.skills.values()
|
||||
]
|
||||
{
|
||||
"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/<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"])
|
||||
def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</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 握手中) ───── -->
|
||||
<div id="embed-waiting">
|
||||
<div class="spinner"></div>
|
||||
|
|
@ -1056,6 +1128,9 @@
|
|||
<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>
|
||||
<div id="rail-resources" title="我的资源">
|
||||
<button id="hd-skills" title="查看平台 / 我的 skill">🧩 技能</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
Loading…
Reference in New Issue