# 实施进度 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 最后更新:2026-05-19(dev SPA 任务/文件行加 `⋯` 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用返回 debounce 刷新右侧文件) --- ## 状态 | Phase | 标题 | 状态 | 备注 | |---|---|---|---| | 1-3 | 骨架 + Skill + run_python | ✅ | 三个 skill;CoreCoder 唯一匹配 edit;敏感 env 过滤 | | 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**D `/v1` JSON API 完工 ✅**(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);**D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅**;**同 task 单活 run 锁 ✅**;**task-level cancel + dev SPA stop 按钮 ✅**;**0004 schema 瘦身 ✅**(删 runs/usage_events);**入口归位 ✅**(`cli.py`→`main.py`,装配 lib 挪 `core/agent_builder.py`,CLI REPL 删,§7 E 撤);真 OIDC 待;C(Executor)待。 | --- ## 已完成关键能力 - **05-19 / 邀请码后端从 env 升级到 `invites` 表(0005 migration)**:接上一条 `ZCBOT_INVITES` env 落地后,用户复盘"用一张表还是 env 哪个更好",讨论 trade-off 后判定:**≤5 人 + 一个人管 + 开发期**这个体量下 env 完胜 — 0 migration / 0 CRUD / 1 秒重启;但用户又转念:"既然将来真发布要表,不如现在就薄薄一张,我直接 DB 里 INSERT 试用,后期再加 CLI 命令"。**判定**:env 升表是低成本前置(schema 极薄,签名几乎不变),省去"未来再写 migration + 同步 env 数据"。**Schema**(0005,`db/migrations/.../20260519_1100_0005_invites_table.py`):`invites(token text pk, name text not null unique, created_at timestamptz default now())`。**设计取舍**:① **token PK** 而非自增 id — lookup 入口直接是 token,PK 自带索引,id 列纯死字段;② **name UNIQUE** — 同 name → 同 uuid5 → 两 token 共身份漏,DB schema 拦比应用层早一层;③ **不存 user_id** — `uuid5(固定 NS, name)` 推导,namespace 永远不动,存它等于冗余 + 偏离单一真相源(改 namespace 全员漂移这个风险一样存在,不靠存 user_id 解);④ **不存 revoked_at** — 撤销直接 `DELETE FROM invites WHERE name=...`,软删的"revoked 是否签 JWT"分支判断 ≤5 人场景用不上;⑤ 不加管理 CLI 子命令 — 用户明确说"我到时候在数据库里直接添加",后期需要再加 `python main.py invite {add|list|revoke}` 薄包装。**ORM**(`core/storage/models.py`)新加 `Invite` class(token PK / name unique / created_at server_default)。**`web/auth.py` 全面重写**:① 删 `_parse_invites` 函数(~40 行 env 字符串解析 + 校验)+ 删 `_INVITE_NAME_RE`(改交给 DB UNIQUE 约束)+ 删 `AuthConfig.invites` 字段 + 删 `AuthConfig.resolve_invite` 方法 + 删 `AuthConfig.from_env` 里读 `ZCBOT_INVITES` 那段 + 删 `AuthConfig.__init__` 多余 invites 参数;② 新增模块级 `resolve_invite(token) -> Optional[(name, user_id)]`:每次 login 走一次 `SELECT name FROM invites WHERE token = ?`,命中后 `uuid5(NS, name)` 算 user_id;**不缓存**,避免 `DELETE` 后还能登的不一致窗口(5 人级别用户开销可忽略,P99 也是 1ms 级);③ docstring 改写(`ZCBOT_INVITES` → `invites 表`,加发码 SQL 示例);④ `__all__` 加 `resolve_invite`(模块函数,不是 AuthConfig 方法,刻意分开 — DB 查询的 callable 不绑 cfg 闭包,更清晰)。**`web/app.py::login_invite` 路由**:从 `auth_cfg.invites` / `auth_cfg.resolve_invite` 改为 `from .auth import resolve_invite`;`if not auth_cfg.invites: 403 "invite login disabled"` 那条早返判断直接删 — 空表跟未配 env 是同一语义,`resolve_invite` 自然返 None;docstring 加发码 / 撤销 SQL 速查。**migration 跑通**:`db upgrade 0004 → 0005` 一把过,`db current` → `0005 (head)`。**Smoke 9 case 全绿**(TestClient 起 app + 直 DB SQL):① `AuthConfig` 不再有 invites/resolve_invite 字段;② `auth.py` 源码 grep 完全无 `ZCBOT_INVITES` / `_parse_invites` 痕迹(env 即使设了也不被读);③ 空表 → resolve_invite 任何 token 返 None;④ 手动 `INSERT INTO invites(token, name) VALUES('smoke_tok_alice','alice')`;⑤ `resolve_invite('smoke_tok_alice')` 命中 → `('alice', uuid5(NS,'alice'))`;⑥ `POST /v1/auth/login_invite {token: 'smoke_tok_alice'}` 200 + 正确 user_id + name;⑦ 错 / 空 token → 403 `invalid invite token`;⑧ JWT 调 `/v1/tasks` 200 + 老 `/v1/auth/login` (platform 路径) 仍工作;⑨ `DELETE FROM invites WHERE name='alice'` 后立刻 login 403(验证无缓存);⑩ 同 name 两行 INSERT 触发 `IntegrityError`(UNIQUE 生效);⑪ 同 token 两行 INSERT 触发 `IntegrityError`(PK 生效)。**没动**:`POST /v1/auth/login`(platform 机器对机器入口,与 invite 路径正交)、JWT 签发逻辑(`mint_token` / `verify_token`)、dev SPA(前端只调 `/v1/auth/login_invite` 路由 contract 没变)、SENTINEL 撤的所有清理(本次之前一步,见下条)。**文档同步**:RUN.md `.env` 示例段去 `ZCBOT_INVITES` 注释 + 加"邀请码不走 env 改 invites 表"指向 + Auth env 段重写邀请码段(指向表 + 发码 SQL + 撤销 SQL) + 日常命令段重写发码流程(`INSERT INTO invites(...)` + 不用重启 web,每次 login 都查 DB) + 路由表 `/v1/auth/login_invite` 说明改 "invites 表 token 未命中(含空表)→ 403" + 故障兜底重写四行 invite 相关 (含 INSERT 冲突时 DB 约束的诊断 + name 修改 = 换身份的语义清晰化) + DESIGN §7.4 schema 段加 invites 表声明 + 元描述;**未动 PROGRESS 老 env 条目**(历史记录,演进过程合理);新加本条作为升级里程碑。**改文件**:`db/migrations/.../20260519_1100_0005_invites_table.py`(+38 行新文件)/ `core/storage/models.py`(+15 行 Invite class + docstring)/ `web/auth.py`(净 -50 行,~30 行加 resolve_invite 函数 + docstring 重写,~80 行删 env 解析)/ `web/app.py`(净 -7 行 login_invite 路由简化)/ `RUN.md`(env / 邀请码 / 命令 / 路由 / 故障兜底五段)/ `DESIGN.md`(§7.4 schema +5 行)/ `PROGRESS.md`(+ 本条)。**净增量**:~-20 行(尽管加了一张表 + ORM,删 env 解析的逻辑更省)。**后期 follow-up**(用户提的):`python main.py invite {add|list|revoke}` 薄包装 —— 真要写时大概就是 3 条 click 子命令分别包 INSERT/SELECT/DELETE,留给真需要管理面的时候做。 - **05-19 / SENTINEL user 彻底撤(数据 + 代码)**:接邀请码 login 落地后,`SENTINEL_USER_ID = UUID('00000000-...')` 这个本地 CLI 时代的兜底 user 已彻底无角色 —— CLI 早撤,web 必从 JWT 拿 user_id,`agent_builder.py:190` 的 `uid = user_id or SENTINEL_USER_ID` fallback 永远不触发(web 永远显式传)。按 CLAUDE.md "不写兼容层" 心智一次连根拔。**DB 数据**:`DELETE FROM users WHERE user_id=SENTINEL` CASCADE 删 5 个 dev 期 task + 307 条 messages(经用户确认不保留);`rm -rf workspace/users/00000000.../`(14 个 smoke 残留目录 + 1 个真实任务,用户确认不留)。**代码 8 处**:① `core/storage/models.py` 删 `SENTINEL_USER_ID` 常量 + 文件 docstring 改;② `core/storage/engine.py` 删 `ensure_local_sentinel` 函数 + `User`/`SENTINEL_USER_ID` import + 文件 docstring 改(改写 "users 行由 web auth 入口按需 INSERT");③ `core/storage/__init__.py` 删两个导出 + docstring 改;④ `core/storage/utils.py` 三个函数 `ensure_local_task_row / upsert_task / check_no_subtask` 的 `user_id` 默认参数(原 `= SENTINEL_USER_ID`)全改必填 + 删 import;⑤ `core/agent_builder.py` `user_id: Optional[UUID] = None` → `user_id: UUID`(KEYWORD_ONLY,前面加 `*,`),删 `uid = user_id or SENTINEL_USER_ID` 改 `uid = user_id`,删 `ensure_local_sentinel()` 调用,删 import,docstring 改两处 — 一处文件头 (移除 "默认填 SENTINEL"),一处 build_agent 函数注释 ("None → SENTINEL" 改 "必填,web 入口从 JWT 拿");两个 `TaskState(...)` 构造点都加 `user_id=uid`;⑥ `core/task.py::TaskState` dataclass 加 `user_id: UUID` 字段(放 task_id 后,无默认值 = 必填),`save()` 透传给 `upsert_task(user_id=self.user_id, ...)`,`from_row()` 加 `user_id=row.user_id`;⑦ `core/session.py::Session.append` 删多余 `ensure_local_task_row` 调用块(8 行)+ 改 docstring(写明 "前置条件:tasks 行已由 web 入口写入") — Session 不持有 user_id,这调用本来就是历史 CLI 残留(REPL 时 task 可能尚未 INSERT),现在 web 入口 100% 先 INSERT,这条 ensure 是 ON CONFLICT DO NOTHING no-op,删了反而清爽;⑧ `core/memory.py` docstring 改 ("本地 CLI = SENTINEL user" → "user_id 由 web auth 入口 JWT sub 透传");⑨ `web/auth.py` 删 `SENTINEL_USER_ID` import + `__all__` 条目 + `ensure_user_row` docstring 提及 SENTINEL 改写;⑩ `db/migrations/.../0001_initial_schema.py:12` 注释改 (`ensure_local_sentinel` → `web.auth.ensure_user_row`)。**关键判断**:`build_agent` 参数顺序问题 — `user_id` 必填放在一堆带默认参数后面会 `SyntaxError: non-default argument follows default`,改 `*, user_id: UUID, ...` keyword-only;唯一 caller `web/app.py:199` 已 keyword 调用,无 break。**Smoke 13 case 全绿**:① 全模块 import 不炸;② TaskState 新字段;③ utils.py 三函数签名 `inspect.Parameter.empty` 必填;④ build_agent KEYWORD_ONLY 必填;⑤ DB 无 SENTINEL 行;⑥ `/v1/auth/login_invite` 200 + uuid5 推导;⑦ POST /v1/tasks 201(走 `ensure_local_task_row(user_id=user_id)`);⑧ GET /v1/tasks count=1;⑨ PATCH 走 update_task 200;⑩ GET /v1/files + /v1/folders 200;⑪ DELETE 204;⑫ user 行存在并清理;⑬ `python main.py db current` 子进程跑通 + 输出 `0004 (head)`。**没动**:DB schema(0004 已最终态,SENTINEL 是数据非 schema)、`users` 表本身、`/v1/auth/login`(platform 机器对机器入口仍需要)、邀请码登录路径。**文档同步**:① RUN.md 关键路径段 Workspace 行去 "本地 CLI sentinel = 00000000..." 表述;② DESIGN.md 三处 — §1 working_dir 路径解释(去 "dev SPA 默认填 SENTINEL")+ §7.0 共享差别表(working_dir / Memory / Auth 三行去 sentinel 措辞,Auth 行加邀请码 + platform_key 双路径)+ §7.9 取舍 (去 "dev SPA 默认填 SENTINEL");③ DESIGN.md §3.7 memory 段去 "本地 CLI 走 SENTINEL"。**改文件**:`core/{storage/models.py,storage/engine.py,storage/__init__.py,storage/utils.py,agent_builder.py,task.py,session.py,memory.py}` + `web/auth.py` + `db/migrations/.../0001_initial_schema.py` + `RUN.md` + `DESIGN.md`(共 12 个文件,净 ~-50 行)。**bonus 价值**:把"操作 user 数据的函数必须显式传 user_id"作为编译期约束(Python 必填参数)固化下来 — 以后再加多 user 相关函数 / caller 时,IDE / typechecker 会直接拦到。 - **05-19 / dev SPA 邀请码登录(`ZCBOT_INVITES` env + `POST /v1/auth/login_invite`)**:用户要把 dev SPA 发给 ≤5 个同事试用,原登录页要求填 `user_id (UUID)` + `platform_key` 两件事 — 同事记不住 UUID、`platform_key` 也不该暴露(那是 platform 与 zcbot 间的机器对机器共享密钥,泄漏 = 任意伪造 user_id)。**方案**:加一条"邀请码"login 路径与原 `/v1/auth/login` 共存(签同款 JWT)。**后端** `web/auth.py`:新增 `ZCBOT_INVITES` env 解析(`name:token,name:token,...` 格式;name 限 `[A-Za-z0-9_-]{1,40}` / token 非空 / 不含 `,:` / ≤200;name 唯一、token 唯一 —— 重复 token 表两人同身份,默认拒);user_id 由 `uuid5(_INVITE_NAMESPACE, name)` 推导(固定 UUID namespace `9b5e7a2a-...`,**不能改**,改了所有邀请用户身份全漂移),重启稳定、纯纯无状态;`AuthConfig.invites` 是 `{token: (name, uuid)}` dict,启动时一次解析完丢内存。**新路由** `POST /v1/auth/login_invite {token}` → 同款 JWT + `{name, user_id}` 返给前端展示用;`ZCBOT_INVITES` 未配 → 整个入口 403 `invite login disabled`(避免裸跑暴露);token 未命中 → 403 `invalid invite token`。**老路由 `/v1/auth/login` 不动** — platform 服务端机器对机器入口语义不一样(它能注入指定 user_id,邀请码只能登 env 里配过的人),OIDC 替换时也只动这条,留着合理。**前端** `web/static/dev.html`:登录卡 2 格(uuid + key)→ 1 格"邀请码"输入(type=password 避免肩窥),去 SENTINEL 预填、去 `LS_UID` 自动填充;header 显示 `name · uuid前8位`(name 缺失老 token 升级前回落到 uuid full,`title` 攻略悬停看完整 uuid);加 `LS_NAME` localStorage 持久化;logout 清三件 LS。**给同事发码流程**(RUN.md 写了):`python -c "import secrets;print(secrets.token_urlsafe(16))"` 生 token,`.env` 加 `ZCBOT_INVITES=alice:tok1,bob:tok2,...`,重启 `main.py web`,把 URL + 各自 token 发给同事。**撤销某人**:从 env 里删那条;**真要换身份**:改 name(他之前的 task 在另一个 user 下访问不到)。**Smoke 14 case 全绿**:① 单元 `_parse_invites` 8 case(未配 / 正常 / uuid5 重启稳定 / 未命中或空 token / 非法 name 拒 / 重复 name 拒 / 重复 token 拒 / 缺 colon 拒 / token 含 colon 拒 / 多余分隔符空白容忍);② 路由 `TestClient` 6 case(命中 alice / 命中 bob 不同 uid / 错 token 403 / 空 token 403 / JWT 调 `/v1/tasks` 200 / 无 Authorization 401 / 老 `/v1/auth/login` 仍工作 / `ZCBOT_INVITES` 未配 invite 路径 403)。**dev.html UI**:`grep` 旧字段 `SENTINEL / li-uid / li-key / platform_key` 全清。**没动**:`require_user` Depends(签 JWT 是同款,后续路由层完全无感)/ `ensure_user_row`(两路径都调,幂等 INSERT)/ DB schema(用户行靠登录时按需建);DESIGN(纯过渡 auth 形态扩展,§7 D' 仍写"PLATFORM_KEY → JWT" 的本意 — invite 是同一条路的小扩展)。**文档**:RUN.md 同步(env 段加 `ZCBOT_INVITES` 说明 + 给同事发码流程 + 路由表加 `/v1/auth/login_invite` + 故障兜底 4 行 invite 相关条目)+ PROGRESS 本条。**改动文件**:`web/auth.py`(+~70 行 `_parse_invites` + `AuthConfig.invites` + `resolve_invite`)/`web/app.py`(+~25 行 `InviteLoginRequest` + 新路由)/`web/static/dev.html`(净 +~15 行 -~20 行,登录块精简 + LS_NAME)/`RUN.md`(env 段 / 路由表 / 故障兜底)。 - **05-19 / dev SPA 任务/文件 `⋯` 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用返回 debounce 刷新右侧**:用户提"左侧任务行加下拉菜单(删除/完成/废弃/导出 docx,不同颜色)、右侧文件同理、文件顶栏长项目名压'文件'换行不要、聊天框加跟文件 panel 一样的上传按钮;另:上传后右侧刷新、工具调用返回时右侧也刷新"。**做法**:① **单例浮层菜单**(`#floating-menu`,`position: fixed`)避开 pane `overflow:auto` 裁剪 — `showMenu(triggerEl, items)` 算 trigger 右下展开,空间不足翻上;点 trigger 外 / resize / 任何 scroll 关菜单。② **任务行**(`renderTaskList`):右侧加 `⋯` trigger,菜单 4 项 `complete/abandon/export/delete`,颜色 `act-complete #2e7d32` / `act-abandon #c77800` / `act-export #1565c0` / `act-delete var(--accent)`;`complete/abandon` 在非 active 任务上 disabled,`export` 在 0 消息时 disabled;点击 trigger `stopPropagation` 不触发 row 选中;`state.tasksById` 缓存避免 menu 里再查。③ **文件行**(`renderFiles` + `fileMenuItems`):删除原内联 `改名 / ×` 两个按钮,统一改 `⋯` 菜单 — `重命名` / `下载`(目录不出现) / `删除`,同套颜色;`state.entriesByRel` 缓存 entry。④ **中间 pane-head 已有的 完成/废弃/导出/删除 4 个按钮保留**(操作当前打开任务还是顺手),重构 `setTaskStatus(tid, status, name)` / `deleteTask(tid, name, nMsg)` / `exportTask(tid)` 接受 tid 参数,中间按钮与左侧菜单共用同一组函数。⑤ **"文件"二字换行**:`.pane-head .label` 加 `white-space: nowrap; flex-shrink: 0`;同时 `#files-proj` 改 `flex: 0 1 auto` + `min-width: 0` + ellipsis + JS 端 `projName.slice(0, 11) + "…"` 截短(完整名留 title) — 双保险防长项目名挤爆顶栏。⑥ **聊天框上传**(`#chat-upload`):与右侧 `#btn-upload` 都触发同一 ``,`uploadSelected` 不变(上传到 `state.filesPath` 当前右侧目录),末尾 `await loadFiles()` 已有刷新。⑦ **工具调用刷新文件**:`handleSseEvent` 的 `tool_result` 分支加 `scheduleFilesRefresh()`,debounce 500ms 避免每次 tool_result 都 hit `/v1/files`(SSE 一轮回复里 tool_call 经常一连串)。**没动**:后端(纯前端 UX 调整);DESIGN(不动 — 非架构);RUN(不动 — 无 CLI / env / 文件布局变化);中间 pane 已有按钮文案与 disabled 规则保持不变。**文档**:只动 PROGRESS(按 CLAUDE.md 三文档边界)。**改文件**:仅 `web/static/dev.html`(+~110 行 JS/CSS,-~10 行旧内联按钮代码)。 - **05-19 / dev SPA `/v1/files/download` 加 `Cache-Control: no-cache` + proposal skill mermaid 文件名 hash → caption + quality_check 加图相关 4 条拦截 + SKILL.md 精简 ~30%**:用户反馈"申报 skill 生成的图没有渲染到 docx 里"。诊断分两层:① 当下这次的真因不是 hash 也不是渲染管线 —— 是模型在 sections 里全写 ASCII 字符画(`┌─┐│`)+ 裸 ```...``` 围栏,从未用 mermaid + `![]()`,matplotlib 生成的 `figures/fig*.png` 静静躺着没人引用,render_docx 按规矩把 ASCII 当代码块原样画上,看起来"没图";② 接着用户反馈"实际文件已更新但浏览器还是旧版,新浏览器能看到新版"——SPA 预览端 fetch `/v1/files/download` 命中浏览器**启发式缓存**(Starlette FileResponse 只发 Last-Modified/ETag,无 Cache-Control,RFC 7234 默认按 mtime 启发式可缓数小时),旧浏览器没 conditional revalidation 就拿了缓存。**修法**:① `web/app.py::download_file` 加 `headers={"Cache-Control": "no-cache"}` —— 浏览器每次都重取(Starlette 不实现服务端 304,no-cache 在这里等价 no-store,workspace 文件小可接受;以后真要省流量再加 If-None-Match 处理);② `skills/proposal/scripts/quality_check.py::check_figures` 新加(共 4 条):**1) `figures/` 有 png 但 sections 0 个 `![](...)` 引用 → 图全没挂上**,2) 任何 fenced 代码块里出现 box-drawing 字符(`┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║▲▼◀▶`)→ ASCII 字符画当图,3) mermaid 块必须有首行 `%% caption: <题>`,4) 同 task 内 mermaid caption 不能撞名;③ **hash → caption 命名重构**(讨论中用户先反对单字段 caption 想用 png 内容,后我提两字段 name+caption,用户最终拍板回归单字段 caption 简化):`render_diagrams.py` 删 `mermaid_hash()` + 改 caption 必填(缺 → 退 2)+ 全 task caption 唯一(撞名 → 退 2)+ 新 `caption_to_stem()` 清洗(保留 CJK/字母/数字,其它折 `_`,截 40 字)+ pass-1 验证 / pass-2 渲染两段式 + 总是覆盖渲染(去 cache 防 caption 不变源变了的孤儿);`render_docx.py` 删 `mermaid_hash()` + 改 caption 查表(同清洗规则),无 caption / 清洗空 / png 缺 → 走原 ASCII fallback;④ **SKILL.md 精简**(~193 行 → ~160 行):资源段更新 4 条脚本描述(render_diagrams 现在 caption 命名 / quality_check 现在 5 类拦截)+ 阶段三段不再吹 "render_diagrams 是可选前置"(改 caption 强制约定段)+ 插图段从 49 行压到 ~22 行(删类型选择细节展开 / 删 matplotlib 配色 dpi figsize 大段细节 → 一行;删 "为什么两段式"长说理段;反模式段合并 ASCII / 占位 / 手写图编号 / 缺 caption / 撞名为一条 "插图相关(`quality_check` 会拦)")。**为什么这一波改这么散**:四件事其实是一根线 —— 用户最初观察"图没出来"实际上是两个 bug 叠加(模型没用 mermaid + 浏览器缓存),修缓存是表层,加 quality_check 是防再犯,caption 命名是顺手把 hash 这层不可读性也清掉,SKILL.md 精简是承接两次改完后该删的冗余。**端到端 smoke**(`/tmp/zcbot_repro` 临时 task):mermaid 块 `%% caption: 总体架构` → `figures/fig_总体架构.png` 落盘 → docx `figures: 1` 报告对、`word/media/image1.png` 1278 bytes 嵌入;negative:缺 caption 退 2 / 撞名退 2(列出 md 位置 + 改名建议);quality_check 拦四条全打:`figures/ 有 N 张 png 0 个 ![]()` / `[md:L] ASCII 字符画 ┌─┐│└─┘` / `[md:L] mermaid 缺首行 %% caption` / `mermaid caption 撞名 X 出现在 md1:L1, md2:L2`。**没动**:`render_docx.py` 主体渲染逻辑(只换 mermaid 块查表那 ~5 行)/ matplotlib 章节生成的 png 命名习惯(`fig1_xxx.png` 风格留着,反正不冲突,`figures/` 同时存在 mermaid 的 `fig_.png` 与 matplotlib 的 `fig_.png` 两种风格)/ `templates/*.md` 里 mermaid 示例首行 `%% caption:` 本来就有(只是历史可选,现在强制约定到位)。**hash → caption 兼容性**:dev phase no compat,直接切;旧 task 里若有 hash 命名的 png 留着,render_docx 找不到对应 `fig_.png` 就走 ASCII fallback,用户重跑 render_diagrams 自动按新规则落 png 即可。**文档**:**只动 PROGRESS + skills/proposal/SKILL.md**(skill 内容/脚本接口变化按 CLAUDE.md 规则不动 DESIGN/RUN —— skill 不是 zcbot 对外 CLI/env/文件布局;但 `Cache-Control` 改动是 `/v1/files/download` 行为微调,客户端无感、文档化为后续 follow-up 可选)。 - **05-19 / dev SPA 文件预览弹框**:用户提:"web 右侧点击文件可以弹框加载预览,带下载按钮"。原行为是 click → 直接 `downloadFile`(走 `/v1/files/download`)落盘,不能在线看。**方案**:复用现有 `/v1/files/download`(blob URL 绕过 auth header 限制,不动后端),前端按扩展名分派渲染器。新加 `#file-preview-modal`(90vw × 90vh,max 1200px),头部 filename + 下载 + × 关,body 按 cat 切不同布局。**分派**:① image(jpg/png/gif/webp/bmp/svg/ico)→ `` blob URL;② pdf → `