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
│ ├── 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 文档提示降级。

View File

@ -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
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 切回 | 必填 |
| `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 共享
---

View File

@ -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) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版
---

View File

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

View File

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

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 路径改写测试。
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

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,
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

View File

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

View File

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

View File

@ -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; }

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"));
});