zcbot/PROGRESS.md

189 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 实施进度
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
最后更新:2026-05-20(dev SPA 输入区移除"⬆ 上传"按钮 + 加"✨ 润色"按钮 + 后端 `POST /v1/tasks/{id}/optimize_prompt` 辅助 LLM 调用,usage_events 走新 kind="prompt_optimize")
---
## 状态
| 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 ✅**;**D' 过渡 auth(邮箱密码 + platform_key → JWT)+ dev SPA ✅**;**单活 run 锁 + cancel ✅**;**0004 schema 瘦身 ✅**(删 runs/usage_events);**入口归位 ✅**(`cli.py`→`main.py`,装配 lib 挪 `core/agent_builder.py`);真 OIDC 待;C(Executor)待。 |
---
## 已完成关键能力
### 2026-05-20
- **dev SPA 输入区移除"⬆ 上传"按钮 + 加"✨ 润色"按钮(后端 `POST /v1/tasks/{id}/optimize_prompt`)**:用户反馈 ① 输入框下的"⬆ 上传"按钮意义不大(右侧文件面板已有同功能 `btn-upload`,完全重复) ② 加一个"润色"按钮 — 用户输简短草稿,根据当前对话模型 + 已选生图模型扩写/优化并替换原文本。**前端**:`web/static/dev.html` 删 `#chat-upload` 按钮 + 它的 onclick(line 1764);同位置加 `<button id="chat-optimize">✨ 润色</button>`(disabled 默认,textarea `input` 事件经 `syncOptimizeBtn` 联动启/禁,要求有内容 + 有 `state.taskId`)。点击 → `state.optimizing=true` + 按钮 disabled + 文案 "润色中…" + chat-hint "润色中…";POST 成功 → `textarea.focus(); textarea.select(); document.execCommand("insertText", false, optimized)` 把"全选 + 插入"作为一个 undo 单元接入 textarea **原生** undo 栈,Ctrl+Z 一次回到原文(execCommand 已 deprecated 但 textarea undo 集成所有浏览器仍支持);失败时贴 chat-hint 不动文本;execCommand 兜底失败(罕见旧浏览器)直接赋值 + 提示"本浏览器不支持撤销"。`sendMessage` 清空 textarea 后 + `renderChatMeta` 切 task 后各补一次 `syncOptimizeBtn`(value="" 不触发 input 事件,得手动 sync)。**后端**:`web/app.py` 加 `OptimizePromptRequest{text, image_model}` + `POST /v1/tasks/{tid}/optimize_prompt` handler — 校验 task 归属 user、text 非空 + ≤4000 字、用 `task.model_profile`(空 fallback default)装配 `LLM`,同步调 `chat()`(非 stream,短文本 3-5s 接受),meta-prompt 包含"当前对话模型 display_name + 选中 image variant 元数据(display_name / default_size / 适合画面细节描述)+ 4 条输出规则(只输出文本/保留原语言/补全模糊点不无中生有/长度合理)+ 用户草稿"。**计费决策**:写一行 `usage_events` **新 kind="prompt_optimize"**(`message_id=NULL`,`task_id` 仍挂当前 task,units 含 `tokens_in/out + usd_to_cny + image_model_hint`),**不** 调 `sync_task_tokens` → tasks 表的 `tokens_prompt/completion/cost_cny`(顶栏"N 条 · M tok"展示用)不被污染。心智:顶栏数字=主对话累计(用户感知),usage_events 全表 SUM=API 账单对账(润色也在内),按 `kind` GROUP BY 可单独评估"这个按钮值不值";单次成本 < ¥0.01(DeepSeek Pro)/ < ¥0.001(Flash)。**不与主对话 run 互斥**:它不写 messages 无 idx 竞争,允许 streaming 期间并行润色下一条草稿。**没动**:`record_chat_usage`(它 hardcode kind="chat",新增 kind 直接 `s.add(UsageEvent(...))` 内联,免给现有函数加参数污染)、DB schema(usage_events.kind 是 Text 列,加新值无需 migration)、image_model 处理(沿用 `_list_image_variants` 元数据)、视频模型(yaml 还没 video 段,等接 seedance 时同模式扩 video_model 字段)、DESIGN(无架构/schema 变化)。**Tradeoff**:① 不做用户偏好持久化(润色风格/温度);② 不接 streaming(短文本完整文本替换比逐字打印体验好,且 textarea undo 集成 execCommand 也只能一次性插入);③ 不接 prompt history(用户失败/退回的原文本可 Ctrl+Z 拿回,不需要服务端记历史)。**未浏览器实测**:HTML/JS 改动小但 textarea undo 行为需真浏览器跑一次确认 Ctrl+Z 链路;后端 `create_app()` import + route 注册通过(`/v1/tasks/{task_id}/optimize_prompt` 已在 routes 表)。
- **dev SPA 中间产物 chip / inline 图去重 + CLAUDE.md 新增"实施前先对方案"段**:用户报"工具结果里挂了一张图,后面 assistant 正文又挂了一张同图,有点重复"。根因:`renderArtifactBarHtml(extractArtifactRels(...))` 在 5 个渲染点都跑过 — `renderMessages` 里 tool 结果卡 / assistant 正文 / assistant tool_calls args 各一处,`handleSseEvent` 里 tool_call / tool_result 各一处。同一 rel 在 tool 结果与紧随 assistant 正文里同时出现(模型 echo 路径)→ 历史回放渲两次。修法:`renderMessages` 顶部建 `const seenRels = new Set()` + `pickFresh(rels)` 闭包,3 个调用点(tool 结果 / assistant 正文 / tool_calls args)全部包一层 — chronological 顺序,首次出现保留(tool 结果常在前),后续重复丢;SSE `ctx``seenRels: new Set()`,tool_call / tool_result 两 handler 共享去重。**对比 querySelector 版**:DOM 查询版 O(n²)(每条 card 渲染时扫 wrap 已有 `[data-rel]`),Set 版 O(n) 无查询,代码量相同还把"什么是 source of truth"明确(不依赖 DOM 已挂 chip 这个隐式状态)。**CLAUDE.md 增段**:开发期需求漂移快,非平凡改动(改 >1 文件 / 行为变化 / 多候选取舍)动手前先用自然语言把方案讲给用户确认,认可后再写代码;一次性 bug 修 / 字面量 / 样式微调可直接动手。方案描述要包含问题定位(文件 / 行号)+ 至少 1 个替代方案 + 涉及性能 / 兼容 / 数据迁移时主动说。**没动**:`extractArtifactRels` / `renderArtifactBarHtml` 实现(它们内部本身已 Set 去重单次调用内重复)、`_workingDirName` / chip 点击委托 / 媒体 blob 缓存、后端、DESIGN(纯前端 UX 修复)、RUN(无对外行为变化)。
- **dev SPA 顶栏加生图模型下拉 + 中间产物图片/视频内联展示**:用户要 ① 项目栏右侧的模型选区加一个生图模型选择(目前只 seedream,默认选上),② 中间产物若是图片/视频直接在对话区展示(不只点击预览)。**生图选择范式判断**:不入 task 列(seedream/seedance 是 tool 范畴,non-chat,task 切粒度太粗;且现在仅一个 variant,加 DB 列纯负债)→ 走**消息级**:UI 下拉的选择跟 `POST /v1/tasks/{id}/messages` body 的 `image_model` 字段一起发,`_run_agent_bg` → `build_agent(image_variant=...)` → seedream tool 装配时按 key 挑 yaml 里 `image` 段的对应 variant_cfg;不入 DB,本 run 内多次 tool call 共用,下条消息可重选。**后端新接口** `GET /v1/image_models`(scan `config/media/doubao.yaml` image 段返 `{variant, display_name, model_id, price_cny_per_image, is_default}` 列表;不要求 `ARK_API_KEY` 已设 — UI 只展示元数据,真调时 `ArkConfig.load()` 那侧再过 key 检查),`_resolve_image_model(variant)` 校验存在性(空串 → 透传走 fallback,非空 → 必须命中 yaml,否则 400)。`agent_builder.build_agent` 新参 `image_variant: str = ""`:非空且命中 → 用它装 SeedreamTool;不命中(yaml 改动后旧选择 stale)静默回 fallback;空 → 沿用"取第一个 variant"。**前端**:`state.imageModels` + `state.imageModel`(per-session,不持久);`loadModels()` 同时拉 `/v1/image_models` 并锁第一个为默认;`renderImageModelDropdown()` 在 `renderModelDropdown` 旁画一个 `生图 [▾]`(yaml 无 variant 时不画);`onChangeImageModel` 纯前端 state 更新无 PATCH;`sendMessage` 把 `state.imageModel` 跟在 POST body 上发出去。**内联媒体**:`_EXT_GROUPS` 加 `video: {mp4,webm,mov,mkv,m4v}` 集合;`renderArtifactBarHtml` 按 `_categorize(rel)` 分支:image/video → 占位 `<span class="art-media[-image|-video]" data-rel="...">`,其他 → 沿用 `.art-chip`;新 `upgradeMediaArtifacts(root)` DOM walk 把占位异步换 `<img>`/`<video controls>`,经 `_fetchMediaBlobUrl(rel)`(Bearer header 不能直 `<img src=>` → fetch 拿 blob 转 `URL.createObjectURL`)+ `_mediaArtifactCache` 同 rel 复用;模态 `openFilePreview``_showVideo`(`<video controls autoplay>`);chip / `.art-media-image` 点击 → 弹模态放大,视频走原生 controls 不拦截(点击=暂停/播放,全屏走浏览器按钮,弹模态反而打断播放)。**缓存生命周期**:cache 走 `selectTask` 切换时 `_flushMediaArtifactCache` 撤销 blob URL;同 task 流式 / 历史回放复用,/clear 不清(FS 文件保留,rel 仍有效);logout 走 `location.reload()` 全清。**没动**:DESIGN(无架构/schema 变化)、Task / TaskState / TaskCreateRequest / TaskPatchRequest schema(早期一稿曾加 `Task.image_model` 列 + 0008 migration,用户复盘后判定 task 粒度不合适撤回 — 测试期 DB downgrade 不留 0008,模型/字段也清干净)、`record_image_usage`(model_profile 仍走 `doubao.<variant_key>`,自然跟着 build_agent 选的 variant)、artifact chip 抽取(其他类型仍走 chip + openFilePreview)。**Tradeoff**:① 不入 DB → 浏览器刷新会回到 yaml 第一个 variant 默认值,但用户中途切换的"上次选了什么"丢了 — 改用 localStorage 即可,先简单版;② 内联图片对每条带产物的消息都触发 fetch,blob URL 累积在 cache 里直到切 task 才回收,长会话场景 + 数十张图理论上内存吃几百 MB(普通 PNG 1-3MB),后续若问题再加 IntersectionObserver 懒加载。
- **LLM 调用切 streaming(cancel 秒退 + 前端打字机)+ 发送/停止合并单按钮**:用户反馈"点停止要等很久"+"发送/停止可以合并"。**问题 1 根因**:`litellm.completion(...)` 是同步阻塞,Python 没标准办法外部线程打断同步 IO;`broker.is_cancelled` 只在 `core/loop.py:run()` 每轮 LLM 前 + tool_calls 之间 poll,所以 cancel 必须等当前整轮 generation 跑完才生效(deepseek v4 + thinking + 长输出几十秒)。**修法**:切 `litellm.completion(stream=True)`,`core/llm.py` 加 `chat_stream()` generator(`stream_options={"include_usage": True}` 让最后 chunk 带 usage;`_build_kwargs` 抽出来给 chat/chat_stream 共用,免重复参数装配);`core/loop.py` 主循环改 `_stream_llm()` 流式迭代,chunk 间 poll cancel,命中 `break` + generator finally `stream.close()` 关底层 httpx 连接;chunks 攒齐用 `litellm.stream_chunk_builder(chunks, messages=...)` 拼回完整 response(自动处理 tool_call name/arguments 跨 chunk 拼接)给 tool_calls 解析 + usage 记账。**cancel 语义对齐**:stream 中途 cancel → 已收 chunk 丢弃不入库不记账(下次 resume 上下文干净);stream 完结后 tool_calls 之间 cancel → 沿用原 `_fill_cancelled_tool_results` 补 cancelled tool message。**前端打字机免费 bonus**:`dev.html:1500-1510` 早就备好接 `text` 事件的 `delta` 字段(rAF 节流 + nearBottom 不抢滚动 + 流中不跑 highlight),但后端原来发的是 `{"type":"text","content":"<整段>"}` 字段名对不上 → 前端永远 match 不到。新逻辑在 `_stream_llm` 里 chunk 到达即 `_emit({"type":"text","delta":...})`,前端自然激活打字机。loop.py 主流程末尾不再 emit 整段 text(content 已通过 delta 流过)。**问题 2 UI**:`web/static/dev.html` 把 `#chat-send`(发送)+ `#chat-cancel`(停止)合并为单 `#chat-action`,新 helper `setActionMode(mode)`(idle="发送" primary 红实心 / streaming="停止" danger 红边 / cancelling="停止中…" disabled);form submit + `chatAction()` 根据 `state.streaming` 分派 sendMessage / cancelCurrentTask;streaming 期间 Enter 不触发停止(textarea 编辑下一条草稿,误触发风险高)。**Smoke 验证**:① 18 chunks 流式 + 文本拼回 ✓ ② tool_call 49 chunks 跨片拼回 `{"a":7,"b":5}` 完整 ✓ ③ 提前 break + close 仅 0.7s(模拟"写 500 字散文中途 cancel")✓。**Tradeoffs**:① streaming 重试只在连接建立阶段(没拿到第一个 chunk 前)生效,中途断流不续 — 实务罕见;② timeout 行为从"整段 timeout"变"chunk 间隔 timeout",新模型接入要测 thinking 不吐 reasoning chunk 的极端情况;③ litellm `stream_chunk_builder` + `stream_options.include_usage` 在 deepseek/doubao/glm/openai 标准协议都正常,新接非主流 provider 时验证。**没动**:probe.py(仍用同步 `chat()`,离线探测不需要 cancel)、CLI 路径(probe 走 chat 不受影响)、broker / SSE 帧格式 / `record_chat_usage` 入参 / DB schema / messages 入库时机(拼回 response 跟非流式等价)。**文档**:`DESIGN.md` §3.1 翻转 tradeoff 表「LLM 同步 call 不可中断」→「LLM 调用走 streaming」+ §7 API 表 cancel 描述改 chunk-level 延迟;`RUN.md` cancel 接口 + 故障兜底表对应行同步;`web/app.py` 两条 docstring 同步。
- **dev SPA seedream tool 透明性 banner(model/size/cost/elapsed)**:用户问"实际生图用哪个模型 / 价格区别 / 前端要不要给用户选";seedream 现仅一个 variant(5.0),无选择空间 — 但用户**能看到**用了什么模型、花了多少是基本透明度。最小路径:SeedreamTool 返回串首行改成 `[seedream] model=... · size=... · cost=¥... · elapsed=...s` 结构化 banner(用 `·` 分隔 + `key=value` 严格格式,正则 parse);dev SPA 新加 `extractMediaBanner(toolName, resultText)` helper,流式 `tool_result` 与历史回放 `role==="tool"` 两路都在 `<details>``<summary>` 旁挂一行徽章(`.tool-banner .kv`,model 红字 / cost 暗红 / 其他灰色);model 文本去 `doubao-` 前缀与 `-260128` 日期后缀截短显示 `seedream-5-0`;**折叠态可见,无需展开**。LLM 看到的完整文本不丢(banner 同条第一行就是字符串)。**没动**:tool schema(不加 model 参数 — 单 variant 没意义,等 seedance 二期 pro/fast 真有价差时统一加 task 级下拉 + `tasks.image_model_profile` 列设计)、artifact chip 抽取(figures/*.png 现有逻辑无变化)、DB / 后端。**Tradeoff**:走文本 banner 而非从 .meta.json fetch — 简单 + 即时,代价是 tool 返回串格式成"前端约定"(改格式要同步前端 regex)。
- **豆包 Seedream 5.0 图像生成 tool 接入(seedance/视频留 Phase 2)+ 0007 migration `cost_usd``cost_cny` 全表统一币种**:用户要接 doubao-seedream-5-0-260128 + doubao-seedance-2-0-260128 + doubao-seedance-2-0-fast-260128,先做 seedream(同步 API 简单,跑通整条管线);seedance 异步 + token 计费复杂,留二期。**架构判断**:seedream/seedance 不是 chat LLM 范式(litellm 不覆盖,异步 task 形态,价格 per-image/per-second),**不进 chat 顶栏 model 下拉,做成 agent 可调 tool**;`config/media/doubao.yaml` 独立命名空间(`ark_api_key_env=ARK_API_KEY` + `ark_base_url=https://ark.cn-beijing.volces.com/api/v3` + image variants);**不复用 `ModelCapabilities`**(chat 长上下文/thinking schema 不适用)。**新文件**:① `core/ark_client.py`(httpx 封装 base URL + bearer auth + 异常翻译 + `download(url, dest)` 流式下载产物 — 复用给后续 seedance);② `tools/seedream.py::SeedreamTool`(prompt 必填 / size / watermark / search 可选 → POST `/images/generations` → 响应解析 `_extract_url`(三种 shape 兜底:OpenAI `data[].url` / 豆包 `data.images[].url` / 递归扫第一个 http url)→ 立刻下载到 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png` + 同名 `.meta.json`(prompt/model_id/size/cost_cny/elapsed/response_id/ts)→ `record_image_usage``kind="image"` 行)。**计费**:`record_image_usage` 接 CNY 直落,**`price_cny_per_image` snapshot 进 units jsonb**(`{"n_images":1, "size":"2048x2048", "search":false, "price_cny_per_image":0.22}`)—— 这是**调价防漂移**关键:豆包改价改 YAML 重启即可,历史 usage_events 自带快照不受污染,跨调价对账 `SELECT units->>'price_cny_per_image', cost_cny ... GROUP BY` 能拉出不同价位累计。**币种统一(0007 migration)**:`tasks.cost_usd` + `usage_events.cost_usd` 双 rename → `cost_cny`,现有数据 `×7.2` 一次性折算(开发期数据小且 chat 多用国产模型 litellm cost map 不收录原本就是 0),`record_chat_usage` 内部把 litellm USD `×7.2` 落 CNY,全表统一币种免按 user 总账单分类汇总。**注册策略**:`agent_builder.py::build_agent` 调 `ArkConfig.load()`,**仅当 `ARK_API_KEY` env 设了才挂 tool**(无 key 用户感知零变化,不会看到 schema 里多个永远报错的工具);构造时注入 task_id / user_id / working_dir / ark_cfg(沿用 `user_root=` 注入范式)。**system prompt**(`prompts/system/general_v1.md`):加「媒体生成工具」段提示按需调用、不主动装饰生成、流程图优先 mermaid (skill 已有管线) — seedream 适合写实/概念/艺术风格图。**没动**:`ModelCapabilities`(避免 schema 污染)、dev SPA(图预览 modal 已支持 png,artifact chip 已识别 figures/*.png 自动渲染缩略图)、`tasks.cost_cny` 列读写路径(record_chat_usage / record_image_usage 都只写 usage_events,task 级累计列仍由后续 sync 补)。**Tradeoff**:① CNY 折算用固定汇率 7.2,涨跌 ±5% 误差开发期接受,真精算应按调用时刻汇率但太重;② 涨价瞬间到 YAML 改完的窗口期记账偏低(豆包不会无预警调价,且 units snapshot 让历史数据可还原)。**待办**:① smoke 真调豆包接口走通(等用户配 `ARK_API_KEY`);② Phase 2 接 seedance(异步 task + polling + 进度 SSE 事件,复用 ark_client.download)。
- **`POST /v1/files/delete` 加 `recursive` 字段(级联删除非空目录) + 顶层目录 task 引用闸 + dev SPA 二次确认显示条目数**:用户报"文件夹内有文件就不给删除",需要级联删除。**后端**:`FileDeleteRequest` 加 `recursive: bool = False`,handler `recursive=False` 沿用 `target.rmdir()`(非空仍 400);`recursive=True` 走 `shutil.rmtree`,但**目标是顶层目录(`target.parent.resolve() == root.resolve() and is_dir()`)且被 ≥1 task 引用**(`SELECT count(*) FROM tasks WHERE user_id=uid AND working_dir=db_form`) → **409**,文案"该顶层目录正被 N 个 task 引用,不能递归删除;请先 DELETE task,再清残留文件"。这复用 `move` 接口的"working_dir = 顶层目录"invariant 守门思路 —— 允许递归删 working_dir 会让 DB 还在引用但 FS artifacts 已没了;DELETE task 流程已经 best-effort rmdir 空目录,DB 行删掉后顶层目录回到"无 task 引用"状态,这时 recursive delete 才放行。空目录(顶层或子级)两种模式都可删,task.working_dir 字段不动(沿用"FS 视图可重生"心智)。**前端**(`web/static/dev.html::deleteFile`):目录删先 `GET /v1/files?path=rel` 探子条目,空目录走原 confirm(`recursive=false`);非空目录二次确认"目录 X 含 N 项(含子目录),将递归删除全部内容,不可恢复。(若为顶层目录且仍被 task 引用,需先删 task)\n确认?"+ `recursive=true`。**没动**:`DELETE /v1/tasks/{id}` 流程(那条仍只 rmdir 空目录,保留"删 task ≠ 删素材"心智)、`POST /v1/files/move` 的顶层目录闸(那是为了维持 invariant,递归删的 409 文案对齐 move 的 409 语义)、smoke 测试(原 case 1/4/6/7 仍跑非递归路径)、DESIGN(API 字段添加非架构变更)。**Tradeoff**:UI 显示的是直接子项条目数,深层子树文件数不预报(只标"含子目录"提示);加 `count` 后端 helper 又给前端一次额外探询,体感分裂,先简单版。
- **fs tool 输出渲染为 user_root-relative 路径(根因消 chip 404 + 防 uuid/部署根泄漏) + dev SPA chip 工作目录锚点修正 + assistant 正文也挂 chip**:用户报对话内 chip 点击 404,根因不在 chip 抽取本身 —— `task.working_dir` DB 形态是 `workspace/users/<uuid>/<name>`(`to_db_path`),前端 `filesPath` 取了 `.split("/").pop()` 末段但 chip 提取器之前直接拿整串作锚点,正则吃到 `workspace/users/<uuid>/<wd>/foo.md`,backend `_safe_join` 拼出来不存在 → 404。两层修:① **tool 侧根治**:`tools/base.py::Tool` 加 `user_root` kwarg + `_display(p)` helper(p 在 user_root 内 → POSIX 相对串,外 → 原绝对),`tools/fs.py` 五个 tool(Read/Write/Edit/Glob/Grep)所有结果串里 `{p}` 替成 `{self._display(p)}` — 现在 `[wrote N chars to wd/foo.md]` 而不再 `[wrote N chars to /home/lighthouse/.../<uuid>/wd/foo.md]`。`core/agent_builder.py::build_agent` 加 `ur_path = user_root(workspace_dir, uid)` 并透传给所有 tool 构造(含 LoadSkillTool / RunPythonTool / ShellTool — base 默认接 None 不影响);`tools/skill_tool.py::LoadSkillTool.__init__` 加 `user_root` 转传 super。**附带收益**:截图分享对话不再泄 user_id + 服务器路径根;chip rel 直接就是 user_root-relative,与 `/v1/files/download` 边界吻合。② **前端 chip 锚点修正**:`web/static/dev.html` 加 `_workingDirName(workingDir)` helper —— `\``/` 后,绝对路径(`/...` 或 `C:/...`)返空(外部 --working-dir 文件不在 user_root,backend 也拒,挂 chip 无意义),否则取最后非空段。5 个 chip 抽取调用点(`renderMessages` 的 tool / assistant tool_calls + assistant 正文 + `handleSseEvent` 的 tool_call / tool_result)统一用这个 helper 代替原 `state.taskMeta.working_dir` 直取。③ **assistant 正文也挂 chip**:`renderMessages` 里 assistant `<div class="body">` 渲完后 `extractArtifactRels(p.content, wd)` 抽出助手 echo 的路径同样挂 chip 条(user 输入不抽,避免他打字过程中误触发)。流式途中不实时挂 — `fetchSse` 收尾自动 `loadMessages()` 重渲染,chip 顺势出现,降低实现复杂度。**没动**:`/v1/files/download` 后端(本来就接 user_root-relative)、ShellTool / RunPythonTool 的 stdout/stderr(subprocess 自己 print 的绝对路径无法干预,且不是 agent 工具直接吐的"系统消息")、DESIGN(无架构/schema 变化)、RUN(无对外命令变化)。**Tradeoff**:旧消息(本次改动前历史 tool result)里仍有绝对路径,但 chip 抽取以 wdName 末段为锚 → 旧路径里的 `/<wdName>/...` 子串也能匹配出正确 rel,**新旧消息 chip 都可点**(回测验证:`extractArtifactRels("/home/.../uuid/wd/foo.md", "wd")` 返 `["wd/foo.md"]`)。
- **`POST /v1/tasks/{id}/clear` 清空对话 + dev SPA「清空对话」按钮**:用户要在同一 task 内重新开始对话。后端新路由:同事务 `SELECT … FOR UPDATE` 锁 + `run_status in (running, cancelling)` → 409(先 cancel)+ `DELETE FROM messages WHERE task_id=tid` + reset `tasks.tokens_prompt/completion/cost_usd=0` + `run_status='idle'` + `run_error=None`,返回新 task dict(`n_messages=0`)。**`usage_events` 表完全不动** — 那是用户级账户账单的 source of truth,清空对话不该影响计费;`usage_events.message_id` FK 是 `ondelete=SET NULL`(models.py:128),message_id 列变 NULL,但 task_id/model_profile/units(tokens_in/out)/cost_usd 全保留,按 task_id 聚合可重建历史累计。**reset task 三列累计 vs 保留累计**:选 reset,因为顶栏「N 条 · M tok」显示"0 条 vs 50k tok"会视觉矛盾;真正账单数据在 usage_events 完整无损。dev SPA 顶栏在「导出对话记录」后插「清空对话」按钮(紫色 hover #8e44ad,区别于完成绿/废弃橙/删除红),`renderChatMeta` 里 `running||n_messages==0 → disabled`,confirm 二次确认(显示任务名 + 消息数),clear 后 `renderMessages([])` + `renderChatMeta()` + `loadTaskList()` 同步列表。**没动**:DESIGN(无架构/schema 字段语义变化)、其他 task 写路径、FS 文件(沿用 task delete 的"FS 视图可重生"心智 — 中间产物保留,模型重起对话可继续基于已有素材推进)、SSE 协议。
- **dev SPA 对话内 tool_call/result 加 artifact chip(复用文件预览 modal)**:用户反馈"中间产物只能在右栏点,对话里不能直接预览/下载"。`web/static/dev.html` 新加两个 helper:`extractArtifactRels(text, workingDir)` 把文本里 `\` 一律归 `/`,正则锚定 `<working_dir>/...`(lead 边界字符类 `[\s"'\`/=:,()<>\[\]{}|]` 避免 `multi_proj_x` 误匹配,末段必须含 `.` 把目录滤掉),Set 去重;`renderArtifactBarHtml(rels)` 渲一行 `.art-chip` 小药丸(`📄 文件名`,前缀 emoji + hover 翻品牌红)。四个渲染点都插入 chip 条:① `renderMessages``role==="tool"` 历史卡;② `renderMessages` 的 assistant `tool_calls` 历史;③ `handleSseEvent``tool_call` 流式;④ `handleSseEvent``tool_result` 流式。`chat-stream` 上加点击委托 → `openFilePreview(rel)`,modal 内已带"下载"按钮所以 chip 不另开二级图标。**取舍**:路径识别限定 `working_dir/` 前缀(skill 脚本 `cd` 后只 print 纯相对路径的情况会漏抓,v1 误判控制代价);纯目录(末段无 `.`)直接跳过。**没动**:右栏文件面板、`openFilePreview` / `downloadFile` 接口(纯复用)、后端、DESIGN、RUN(对外行为零变化,纯 UI 增量)。
- **task 级「宪法」文件 (spec) 命名约定 + `spec_lock``spec` 简化**:同 working_dir 多 task 共享中间产物(`source/` / `sections/` / `figures/` 跨本子复用)是设计意图,但 spec 这种 task 1:1 宪法文件必须隔离 — 两本子 spec 直接撞。文件名约定 `<YYYY-MM-DD>-<task_short_id>-<task_name>.spec.md`:`task_short_id`(`task_id.hex[:8]`,永不变)作主锚,glob `*-<short_id>-*.spec.md` 字典序最大 = current;`<YYYY-MM-DD>` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`<task_name>` 写入作建时元数据,改 task.name 不 cascade(由 short_id 兜底定位)。`core/agent_builder.py::_build_system_prompt` 加 `task_id` / `today` 注入 + 命名约定段 — 所有 skill 共享一份约定文本,SKILL.md 不再重复;proposal / ppt SKILL.md 阶段一加"先 glob 检测已有 spec → 询问沿用/重定调"分支。`_lock` 后缀无信息量去掉(`templates/spec_lock.md` → `templates/spec.md` git mv 保历史)。**没动**:DB schema(无新字段)、`PATCH /v1/tasks/{id}` 改 name 入口(免 cascade)、其他中间产物扁平共享、quality_check.py(`--spec` 接路径,SKILL.md 拼对参数即可)。**反方案**(cascade rename / spec 入 PG / 物理 task 子目录)及"何时升级到 DB 化"信号见 DESIGN §7.9 取舍说明。
- **dev SPA 左 pane 折叠改 rail 模式 + 删 header 冗余按钮 + time-ago 锁宽完成跨行对齐**:用户反馈 ① "原来 zcbot 旁的折叠按钮不要了,没用处" + ② "数字对齐那块现在是不是每块内容左侧对齐?"(实际是右对齐但因 time-ago 宽度变化导致 N 条/N tok 右边界也跟着抖,跨行没真对齐)。两件套:① 折叠模式从「pane display:none」改 VS Code 范式 rail —— `body.left-collapsed #app.ready { grid-template-columns: 40px 1fr 320px }` + `#pane-left > * { display: none }`(藏全部直接子) + override 第一行 pane-head 重显且只留 `#pane-toggle-left`(`> *:not(#pane-toggle-left) { display: none }`,选择器特异性 2 ids 压 1 id);pane-head 第一行用 `position: static` 取消 sticky / `border-bottom: none` / `background: transparent` 看起来更像 rail 非"卡片"。按钮符号根据 `body.left-collapsed` 在 `applyLeftCollapsed` 里翻向(展开态 `` 折叠态 ``)。彻底删 `#hd-toggle-left` + `header .icon-btn` CSS 块,header 不再背 expand 入口的债。② time-ago 加 `flex-shrink: 0; text-align: right; min-width: 64px` 锁宽,**这才是真正解决跨行对齐的关键**:此前 `.num.right-group``margin-left: auto` 把 [N 条][N tok][time] 整组推右,但 time 自身宽度浮动 30~70px(刚刚 / 10 小时前 / 2025-12-05)→ time 左边界抖 → N tok 右边界抖 → N 条 右边界抖,逐级传染。锁 time 宽后整组位置稳定,槽内 `text-align: right` 才能让"条/tok"后缀跨行真正垂直对齐。删 `.badge .time-ago { flex-shrink: 0 }` 合并里的 time-ago(已独立给规则)。**没动**:fmtTokens / 桶分级 / tabular-nums / `.num min-width: 44px`(上一轮已正确)、右 pane / chat 中列。
- **dev SPA 任务行 meta 数字槽位跨行对齐 + 折叠按钮位置调整**:用户报"N 条 / N tok 数字宽窄不一,看着不齐";又说"折叠按钮应该贴刷新按钮"。两件套:① meta CSS 加 `font-variant-numeric: tabular-nums` + `align-items: baseline`,新 `.num` 子选择器 `flex-shrink: 0; text-align: right; min-width: 44px`(右对齐让 `条` / `tok` 后缀跨行垂直对齐);N 条 span 戴 `right-group` 类拿 `margin-left: auto`,把 [N 条][N tok][time-ago] 整组挤右侧,左侧只剩 badge + skill;原 time-ago 上的 inline `margin-left:auto` 移除避免双 push 失效。新 `fmtTokens(n)` helper:<1k 原数 / <10k `1.2k` / <1M `123k` / >=1M `1.2M`,bound 槽位宽度;`title=` hover 出 `123,456 tokens` 完整值(`Number.toLocaleString()`)。② 折叠按钮拆双入口 — `#pane-toggle-left` 放第一行 pane-head 紧贴刷新按钮(展开态用,点击折叠);`#hd-toggle-left` 留 header 但 `style="display:none"` 默认隐藏,仅折叠态显示(用户路径:折叠后 pane display:none → 无法在 pane 内点展开 → 必须 header 保留 expand 入口)。`applyLeftCollapsed(collapsed)` 控制 hd 按钮 display,两按钮共享 `toggleLeftCollapsed()` 实现;每按钮符号固定(pane 内 `` 一直是折叠方向,header 内 `` 一直是展开方向),不再翻向(语义更清)。**没动**:右 pane / chat 列宽、`/v1/tasks` 后端、id8 仍在 row title hover(上次改的不动)、CSS `.small` 等。
- **dev SPA 左 pane 调宽 280→320px + header 折叠 toggle + 任务行精简 meta**:用户报 280px 下底行(badge/skill/N条/Ntok/time/id8)被 flex shrink 后 CJK 字符断行(像"10 小时前"裂成两行)。三件套修:① `#app.ready grid-template-columns` `280px → 320px`(右 pane / chat 不动,从 chat 借 40px,任务名 / 描述 / wd 都更舒展);② header 最左插 `<button id="hd-toggle-left" class="icon-btn"></button>`,点击 toggle `body.left-collapsed` → CSS `grid-template-columns: 0 1fr 320px` + `#pane-left { display: none }`(列归零腾给 chat,折叠态 chevron 翻 ``);state 存 `localStorage zcbot.left-collapsed`,boot 即应用,刷新保持。IntersectionObserver 留着不重建(display:none 期间 sentinel 0 高度自然不触发,展开后重算 layout 若 sentinel 在视口自然续传);③ 任务行删 `id8` span(8 位 hex 调试时才用),挪到 row `title=` hover 出 `${name}\n${task_id}` 完整 id 仍可查;`.task-row .meta > *` 全加 `white-space: nowrap; overflow: hidden; text-overflow: ellipsis` 防内部 CJK 字符破断;badge + time-ago 加 `flex-shrink: 0` 保两端不缩;wd / desc 副行恢复 inline 三件套 `overflow:hidden;text-overflow:ellipsis;white-space:nowrap`(它们是单文本带不是 flex 子元素行,`> *` CSS 不命中文本节点)。**没动**:右 pane 320px 不变(文件预览常用)、chat 中列 1fr(自适应剩余);折叠按钮没做右 pane 对应版(用户没要)。
- **dev SPA 左侧任务列表 pager bar → 滚动加载(ChatGPT/DeepSeek 范式)**:用户嫌底部分页 chrome 别扭。删 `#task-pager`(prev/next/info bar)+ `renderPager` + `resetPageAndReload`,改 `IntersectionObserver` on `#task-sentinel`(`#task-list` 后兄弟,`min-height:1px`),root = `#pane-left`(整 pane 是 scroll 容器,`.pane{overflow:auto}`)+ `rootMargin: 200px 0px` 提前 200px 触发体感更顺。`loadTaskList({append=false})` 双语义:reset 抢占式(filters / refresh / 写操作后,page=1 替换);`append=true` 仅 sentinel 触发,page+1 拼到底,受 `taskLoading || !taskHasMore` 互斥。**并发模型**:用 `_taskLoadSeq` token 让 reset 永远抢占 — 收到响应时若 `mySeq !== _taskLoadSeq` 整段 short-circuit return(也含 finally 的 `taskLoading=false`,避免 reset 在途时被 stale append 错误解锁),解决"append 在途时改筛选被丢"的旧 bug。**新增**:① 首 pane-head 加 `<span id="task-count">共 N 个</span>` muted 小字补偿总数显示;② sentinel 文案三态(加载中… / — 已加载全部 — / 空字符串);③ `renderTaskList(tasks, append)` append 走 `<div>.innerHTML` 临时容器 + `appendChild` 不 clobber 已渲染行,事件 handler 只挂新行。**没动**:`/v1/tasks` 后端(本来就是标准分页 `{page,page_size,count,results}`)、page_size=20 默认、所有 7 处 `loadTaskList()` 旧调用点(默认 reset 语义与原行为等价)。**Tradeoff**:失"跳到第 N 页"但筛选 / 搜索 / 排序 + 滚动覆盖所有导航场景;失"当前页位置"但写操作后跳回顶端在 zcbot 任务规模(几十~几百)体感自然。
- **dev SPA 左侧任务列表行加「最近操作时间」**:用户要"显示最新操作时间"。`renderTaskList` 行 meta 区(badge / skill / N 条 / N tok / id-slice)在 id-slice 之前插一个 `<span class="muted" style="margin-left:auto;">`,文案用新加的 `fmtTimeAgo(iso)` 相对时间 helper:`<60s`→刚刚 / `<1h`N 分钟前 / 同日N 小时前 / 昨日昨天 HH:MM / 同年MM-DD HH:MM / 跨年YYYY-MM-DD,`title=` hover 出完整 `fmtTime` locale 。`margin-left:auto` id-slice 挪到时间 span(让两者一起靠右,中间 8px `.meta gap` 自然分隔)。字段用 `updated_at`(任务任何写操作 改名 / 新消息 / 状态切 都会更新,贴合"最新操作"语义),`/v1/tasks` payload 早已包含,后端零改。**没动**:左 pane 列表默认排序仍 `-created_at`(用户改排序顺序时另说);id-slice 保留(调试参考)。
- **dev SPA 新建任务弹框工作目录 input + datalist `<select>` 下拉**:用户要"做成下拉选择"。 `<input list="folders-datalist">` autocomplete `<select id="nt-wd-sel">`,选项 = `(留空 · 用任务名作目录)` + 既有目录(`name N 个任务` / `空目录`) + `+ 新建目录…` sentinel(`__new__`)。 `__new__` 显示备用 `<input id="nt-wd-new">` 输入新目录名 + autofocus,提交时 `working_dir = sel === "__new__" ? nt-wd-new.value : sel`hint 区改 `updateWdHint()` 三分支(新建 / 留空 / 复用),change + new-input + name-input 三事件触发。`<datalist id="folders-datalist">` 留在 modal 内但不再被它消费,**只供左 pane 顶部 `#filter-wd` 筛选 autocomplete**(datalist 按 id 引用,DOM 位置无关);`loadFolderSuggestions()` 同次拉取灌两边。**没动**:`/v1/folders` API、提交 body 形态(仍 `working_dir: string`,空串语义不变 → 后端 fallback 用任务名)、左 pane filter-wd 仍用 input + datalist(用户只点名"任务弹框")、DESIGN / RUN。**Tradeoff**:纯 select 实现最直接但会失"新名则新建",改两段式(select 含 `+ 新建…`,触发后展开 text input)保留所有原能力。
- **dev SPA 主页轻量美化(纯 CSS / HTML,不动 JS / 路由)**:用户要"简洁美化主页"。改四处:① header 从裸 "zcbot" 文字 → brand wrapper(24px 红渐变 "Z" logo + 标题字号 14→15 + letter-spacing + 顶栏 1px 极淡阴影),沿用登录页 brand 视觉但缩小;② 左 pane 三行 pane-head(任务标签/搜索/排序)用 `#pane-left .pane-head + .pane-head` 选择器把 filter / sort 子行换白底 + `--border-soft #ececec` 分隔,弱化为子层级,把两条 inline `border-top` 顺手去掉(与新 `border-bottom` 重叠会出双线);③ 顶栏 4 个语义按钮(完成/导出/废弃/删除)+ 选入弹框的复制/移动按钮从"常态彩边 + hover 加底色"改"常态中性 + hover 一次性上语义色(color + border + bg)",给 button 基础类加 transition 让色变平滑(沿用现有 `button.danger` 的同款 hover-only 范式);④ 圆角统一:button / input / textarea / select / floating-menu / .msg 4→6,三个 modal 卡片 6→8 + 阴影 `0 8px 24px → 0 12px 32px` 略深显悬浮感。**没动**:布局 / 交互逻辑 / 任何 JS / 后端 / DESIGN(纯视觉)/ RUN(无对外接口变化);dd-item 菜单的语义色保留(菜单内本来就靠色区分动作类型,不属于"顶栏中性"范畴)。
- **加 `config/models/glm.yaml`:智谱 GLM 5.1 接入(litellm zai provider + 国内站 bigmodel.cn)**:用户要加 GLM。litellm 1.83.14 内置 `zai` provider(PR #17307 早就 merge,我初次 grep 漏了 — 只搜了 zhipu/glm/doubao),`zai/glm-5.1` 自动路由到 z.ai 国际站(`api.z.ai`,env `ZAI_API_KEY`)。**用户用国内站 bigmodel.cn**(账号 / key 跟 z.ai 国际站不通用),YAML 走 `api_base: https://open.bigmodel.cn/api/paas/v4` 覆盖 litellm 默认(`core/llm.py:71-72` 已有 `if self.api_base: kwargs["api_base"]=...` 透传通道),env 命名 `ZHIPUAI_API_KEY` 跟国际站 `ZAI_API_KEY` 分开。family=`glm`,单 variant `pro`,context 200K / reliable 100K / max_out 8192,tool calling 标 good,run_python 开。**`thinking_mode: false`**:GLM 的 thinking 协议是 body `{"type":"enabled"}` 开关 +(可选)budget,与 OpenAI/DeepSeek 的 `reasoning_effort` int 等级不同;`core/llm.py:77-78` 只透传 `reasoning_effort`,要接 GLM thinking 得加 family 分支(`if family.startswith("glm"): kwargs["extra_body"]={"thinking":{"type":"enabled"}}`),不在加 YAML 范围,留 TODO。smoke:`ModelCapabilities.load('glm.pro', ...)` 正常 + `litellm.get_llm_provider('zai/glm-5.1')``(model=glm-5.1, provider=zai, default_base=https://api.z.ai/api/paas/v4)`,YAML override 生效后实际打 bigmodel.cn;`/v1/models` 扫描结果含 `glm.pro / 'GLM 5.1' / thinking=False`。**没动**:`core/llm.py`(避免半成品 thinking 分支)、DESIGN.md(只加模型档案,非架构变更)、`default_model`(仍 `deepseek_v4.flash`,GLM 是可选项,前端下拉里出现)。**已知待办**:① 接 GLM thinking 透传;② 豆包图像/视频生成(seedream/seedance,完全不同 API 形态,要单独管线)。
- **files SPA UX 翻面 + 拖拽上传 + 修 checkbox 全局 width bug**:沿用上条新加的两路由,但前端 UX 整套换。**原模型**(select-then-pick-dest):主区行带 checkbox + 顶栏全选三态 + 黄 bar(复制到 / 移动到 / 取消)→ 弹框选目标目录。**新模型**(at-dest-pull-sources):主区只读浏览,顶栏加 `[选入…]` 按钮 → 弹框内浏览任意目录 + 跨目录勾文件 / 子目录(`Set<rel>` 跨切换保留)+ 底部 `[复制到此处]` `[移动到此处]` 两按钮直接落到主区当前 `state.filesPath`。**理由**:用户切任务时主区自动跳 task working_dir,绝大多数操作是"把外面素材喂进当前 working_dir",destination-first 比 source-first 少一次心智切换,且主区干净。**附带**:① 主区 `<input type=checkbox class=row-cb>` 被全局 `input{ width:100%; }` 撑成全行宽 → 把 `.name`(`flex: 1; flex-basis: 0`)挤成 0 宽,行里只剩看不见的文字 + 居中的 checkbox(用户报"看不到文字"),根因不修永远埋雷,改 selector 排除 checkbox/radio/file。② 拖拽上传:`#pane-right` 监听 dragenter/over/leave/drop,有 `Files` 才响应(忽略文本拖拽),`#file-droparea` 红色虚线 overlay,落点 = `state.filesPath`,沿用 `/v1/files/upload`。**删了**:`state.selectedFiles` + `syncBulkBar` + `dirPicker` 模块 + 顶栏 selall + 黄 bar 整块 + 行 checkbox 渲染(按 CLAUDE.md 不留旧 UX)。**没动**:后端 `/v1/files/copy` `/v1/files/move`(同样的 `paths` + `dest_dir`)、DESIGN、RUN。
- **`POST /v1/files/copy` + `/v1/files/move` 跨目录批量搬动**(原"+ dev SPA 多选 + 目录选择弹框"已被上一条翻面替换):用户要"在文件夹间复制/移动文件"。后端两路由共用 `_validate_transfer` 预检 helper(批量原子校验:源存在、不能等于/含 dest、不在 dest 直接子级、批内重名、target 已存 409,任一失败整批 abort,无 FS 副作用)。**move 加额外闸**:任一源是顶层目录且为某 task `working_dir` → 409(维持"working_dir = 顶层目录"invariant — 允许沉到子目录后,rename 顶层只更新当前层 task 的 DB-aware 逻辑会失效,代码复杂度翻倍才能扛住嵌套场景;用户想归档项目目录:先 DELETE task)。**copy 无此闸**,新副本无 task 关联。dev SPA:`.file-row` 加 `<input type=checkbox class=row-cb>` 列 + 顶栏 `#files-selall` 三态(全/半/无),选中 ≥1 出黄底 toolbar(`复制到…` / `移动到…` / `取消选中`)。目录选择弹框 `#dir-picker-modal` 复用 `/v1/files` 浏览(只列目录,面包屑可点回上层,源目录灰禁),底部按钮文案随 mode 切。`state.selectedFiles` 切 task / 切 filesPath 时清,refresh 后剔除已不存在的 rel 保 view 一致。**部分失败**:沿用现有 rename / delete 单向语义,FS 中途失败抛 500 + 已成功项保留(`shutil.move/copytree` 失败几乎只在跨卷断连 / 磁盘满,workspace 同盘罕见)。**没动**:DESIGN(API 添加非语义变更)、RUN(无 CLI / env 变化)、DB schema。
- **working_dir 视为可重生 FS 视图**:DB 是 source of truth,FS 目录可独立删 / 用户手动 rmtree / 跨机器迁移丢失,**下次跑就自动 mkdir 重建**。三处改:① `DELETE /v1/tasks/{id}` 删完后若同 user 下再无 task 引用此 working_dir 且 FS 目录为空 → best-effort `rmdir` 清孤儿(非空 / 不存在 / 外部 --working-dir 静默跳过)。② `POST /v1/files/delete` 顶层目录去掉「有 task 引用就 409」闸,允许独立删空目录,task.working_dir 字段不动。③ `core/agent_builder.py::build_agent``working_dir_path.mkdir(parents=True, exist_ok=True)``if not resume:` 里挪出,resume 也兜底建目录(用户手删 FS 后再 send message 不会炸)。smoke `scripts/smoke_files_rename.py` 增 case 4 (200 + working_dir 不变) / case 8 (DELETE task 空目录自动清) / case 9 (非空目录保留),全 9 pass。**没动**:DB schema、rename 顶层目录的同步 UPDATE 逻辑(rename 是明确改名,和"删后重生"语义不同)、外部 --working-dir(DB 绝对串)的清理(避免误删用户外部项目)。
### 2026-05-19
- **0006 模型切换(c 模式 task 级 A 粒度)+ usage_events v2 表**:`tasks.model_profile` 从死字段变 source-of-truth,顶栏下拉 → `PATCH /v1/tasks/{id}` 即换,**A 粒度下条 send 生效**(当前 run 不受影响;running 中切 UI 提示"跑完后生效")。`build_agent` resume 时优先 task.model_profile,新建 task POST body 加可选 `model_profile`(留空 → `cfg["default_model"]`)。`GET /v1/models` 扫 `config/models/*.yaml` 列可选项(含 display_name / thinking_mode / is_default),`ModelCapabilities` 加 `display_name` 字段,deepseek_v4.yaml 两 variant 各填名。**前端**:chat-meta 加下拉(切了 PATCH+提示)、新建对话框 modal 加 `<select id="nt-model">`、message 历史按 `messages.model_profile` 切换点画小标(`── DeepSeek V4 Pro ──`,连续同 model 不重复)。**统计 schema**:0004 删掉的简陋 usage_events 字段不够多态,本次重建 v2 形态(`event_id/user_id/task_id/message_id/kind/model_profile/units jsonb/cost_usd`),chat 已接入(`core/storage/usage.py::record_chat_usage`,`loop.py` 在 assistant message 入库后调,litellm cost map 算钱),媒体扩展位(image/video/audio kind)预留不动 schema。**双写**:同时回填 `messages.tokens_in/out/model_profile`,查 message 详情时不需 JOIN。**索引**:`(user_id, created_at)` / `(task_id)` / `(model_profile, created_at)`,用户级配额 query JOIN-free。**没动**:CLI / RUN.md(无 env / 命令变化)、`tasks.tokens_prompt/completion/cost_usd` 保留作 task 级粗概览。
- **dev SPA 登录撤回 邮箱+密码,删 invites 表**:前两条"邀请码 env → invites 表(0005)"一日游撤回,复用 users 表本来就有的 email/password_hash 列(0001 schema)+ 0005 加 UNIQUE(email)。`bcrypt` 哈希,新 `/v1/auth/login_password` 路由,新 `main.py user add --email --password` CLI 发用户。dev SPA 登录两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used 持久化 LS)。判定:邀请码 uuid5(NS,name) 推导对外是黑盒(改 name = 换身份),复用 users 列语义清晰也对齐生产路径。**没动**:JWT 签发 / platform_key 路径 / DB users 表列结构。
- **邀请码后端 env → invites 表(0005)** _(已撤,见上条;原条目已删,有需要看 git history)_
- **SENTINEL user 彻底撤(数据 + 代码)**:`SENTINEL_USER_ID = UUID('00000000-...')` 在 web 必从 JWT 拿 user_id 后已无角色,按 CLAUDE.md "不写兼容层" 连根拔。DB CASCADE 删 sentinel user + workspace dotfile 目录;代码 10 处删 import / 默认参数 / fallback,`utils.py` 三函数和 `build_agent``user_id` 从可选变必填(`build_agent` 加 `*,` 转 KEYWORD_ONLY 规避默认参数顺序)。**Bonus**:把"操作 user 数据的函数必须显式传 user_id"作为 Python 必填参数固化,以后多 user 函数 typechecker 会拦到。
- **dev SPA 邀请码登录(env 形态)** _(已撤,见 SENTINEL user 撤之后两条,路径整体改邮箱密码)_
- **任务/文件行 `⋯` 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用 debounce 刷新右侧**:单例浮层菜单(`#floating-menu` position:fixed)避开 pane overflow 裁剪。任务行 4 项(complete/abandon/export/delete,不同颜色,非 active 自动 disable);文件行 3 项(改名/下载/删除);聊天框加上传按钮共用 `<input type="file">`;`tool_result` 事件 debounce 500ms 刷新文件 panel。仅前端,不动后端 / DESIGN / RUN。
- **proposal skill mermaid hash→caption + quality_check 加图相关 4 拦截 + SKILL.md 精简 + `/v1/files/download``Cache-Control: no-cache`**:用户反馈"申报 skill 图没渲染到 docx",诊断双层 bug:① 模型写满 ASCII 字符画从未用 mermaid + `![]()`;② SPA 预览命中浏览器启发式缓存(Starlette FileResponse 无 Cache-Control)。修法:render_diagrams 改 caption 强制必填 + 同 task 唯一(撞名退 2);quality_check 加 4 条(figures/ 有 png 但 sections 0 引用 / 围栏含 box-drawing 字符 / mermaid 缺首行 `%% caption:` / caption 撞名);SKILL.md ~193→~160 行。
- **dev SPA 文件预览弹框**:点击文件不再直接下载,弹 90vw 模态按扩展名分派(image/pdf/text/md→已有 renderMd / docx 用 docx-preview / xlsx 用 SheetJS / pptx 等 fallback "请下载查看")。库懒加载 + blob URL 全局 track + 弹框关时 revoke 防漏;vendor 入 git(jszip / docx-preview / xlsx,~1MB,无 npm 链路就直 vendor 锁版本)。**没动**:后端 app.py(blob URL 路径足够)。
### 2026-05-18
- **入口归位:`cli.py`→`main.py`,原 `main.py`→`core/agent_builder.py`,删 CLI REPL,§7 E 撤**:`main.py` 混三角色(装配 lib + utility + cli/web 共 import 的事实入口),按 SoC 拆。`git mv` 两次(覆盖)+ 5 处 `from main import``from core.agent_builder import`。删 `chat / tasks / export` 三命令 + REPL 主循环 + 内部 helpers(~400 行);新 `main.py` 只剩 `db / probe / web`(后来再加 `user`)。失:CLI 无 auth 直跑 core 通道;补:dev SPA 走同条 web 路径,临时调试写几行 ad-hoc script。
- **0004 schema 大瘦身:删 runs / usage_events,合 run_status / run_error 入 tasks;路由 run_id → task_id**:`usage_events` 全代码库零写零读,`runs` 表 tokens_p/c 写但从未读(真 tokens 走 tasks 累计),started_at/finished_at/error 也只写不读,`run_id` 唯二实用是 broker 频道键 + cancel 参数 — 单活 run 形态下客户端只需 task_id 就够。`tasks` 加 `run_status text default 'idle'`(idle/running/cancelling/error,error 是唯一持久终态)+ `run_error text`。Broker 全 task_id 索引 + 加 `start(task_id)` 清上轮 done 标记。**dev SPA**:`state.currentRunId` → `state.streaming` bool;cancel POST `/v1/tasks/{tid}/cancel``/runs/{rid}/`
- **`POST /v1/files/rename` + 顶层目录 delete 加 task 引用闸**:**`/v1/files/*` 升格为唯一目录树 mutation 入口,DB-FS 一致性作服务端不变量内化**;`GET /v1/folders` 定位"项目聚合视图",只读。顶层目录(`target.parent.resolve() == root.resolve() and is_dir()`)走 DB-aware 分支:事务内 `SELECT ... FOR UPDATE` 锁关联 task + 任一 running/cancelling → 409 + `check_no_subtask(exclude=被改名 tids)` 防嵌套 + UPDATE 在 FS rename 之前(FS 失败可回滚)。**架构教训(§7.9)**:此前提的双命名空间 `/v1/folders/rename` vs `/v1/files/rename` 反了 — `is_top_level` 分支是**从数据状态派生**(path 恰好是 working_dir),不是客户端意图派生,放服务端是更安全的位置。
- **task-level cancel + AgentLoop 协作式 cancel + dev SPA stop 按钮**:Broker 加 `request_cancel / is_cancelled / clear_cancel`(per-task `threading.Event`,`setdefault` 保证 BG 还没 register 也能 set)。Loop 加 `cancel_check` callable + `_fill_cancelled_tool_results` 给未执行 tool_call 补 `[cancelled]` tool message(LiteLLM 协议要求 assistant tool_call 必须有匹配 tool result,否则 resume 报错)。**LLM 同步 call 本身不可中断**(litellm 阻塞,无原生 cancel)— 最坏等当前一轮跑完几十秒。Gate 同步扩:`post_message` 单活 run 检查 `status in ('running', 'cancelling')` 避免新旧 BG 撞 messages.idx。
- **`POST /v1/tasks/{id}/messages` 单活 run 锁 + 孤儿 reaper**:同事务 `SELECT Task ... FOR UPDATE` + 活跃状态检查 + 标 running,三步原子完成避免 TOCTOU(用户连点 send / 多 tab 同时发 → 两 BG 线程争 `messages.idx`)。lifespan 加 reaper:启动时 `UPDATE Task SET run_status='error' WHERE run_status IN ('running','cancelling')` 清进程 crash 留下的孤儿。**未来 TODO**:multi-worker 部署 reaper 不能简单全表清(会误清其他 worker 的真在跑),换 heartbeat + lease。
- **proposal skill 流程图/结构图管线**:`render_diagrams.py` 扫 sections/*.md mermaid 块 → mmdc(本地)或 mermaid.ink(公网) → png;render_docx 加 `add_picture` 识别 `![](...)` 单行 + mermaid 围栏特判;templates 三处占位换成完整 mermaid 例子。图编号 `ctx['fig_no']` 调用链递增不重不漏;mmdc/网络都没的极端环境 docx 仍能产(ASCII 退化)。
- **system prompt skill 机制改"可选辅助"**:接 GET /v1/skills + 下拉落地后,prompt 第 14 行从 `"永远 load 一下"``"简单问答/读代码/改 bug/文件操作直接用通用工具就够,不必为每个任务硬套 skill"`;一旦决定要用仍 load 完整指引。**Tradeoff**:边缘场景(用户提"整理大纲")agent 偏向不 load 可能漏掉好的模板,比"什么都套 coding"的噪音更可接受。
- **`GET /v1/skills` + dev SPA skill 字段改下拉**:lifespan 启动 `SkillRegistry` 扫一次挂 `app.state`(FS 静态运行中不变);返 `{skills:[{name,description}]}` 按 name 升序。前端 `<input>``<select>` + 首项 `(默认 · 不限定)` 空值;option 文案 `name — description`,失败静默退化为只剩默认项。**没动**:`POST /v1/tasks` body 不校验 `skill ∈ registry`(留空 / 任意串都允许)。
- **dev SPA 全套 UI 中文化**:静态文案(login / header / pane-head / 操作按钮 / new task modal)+ 动态文案(status badges / role 标签 / SSE 流式提示 / confirm/alert)全面本地化。技术字段(user_id / UUID / token / SSE event 名 / API 字段名)不动 — 都是 schema 层不影响 UI 中文。
### 2026-05-17
- **0003 schema:name + working_dir + skill 三件套**:用户要任务标识和工作目录解耦(原 name 实际是目录名)。`TRUNCATE tasks CASCADE` + `task_dir → working_dir` + `mode → skill` + 加 `name TEXT NOT NULL`(空表 NOT NULL 不需要 backfill)。新建必传 `name`(显示名,DB NOT NULL,UI 标题用);`working_dir` 可选(留空 fallback 用 name);两者都过 `validate_task_name`。新增 `GET /v1/folders`(FS 非 dotfile 子目录 + 关联 task 计数 + 最后使用时间)给 dev SPA modal 的 datalist 补全用。
- **`GET /v1/tasks` 分页 + 多维筛选 + ordering(DRF 风格)**:标准分页壳 `{page,page_size,count,results}`;6 个 query(page/page_size/status/skill/working_dir 末段名/q ILIKE name+desc/ordering);`-field` 倒序,allowlist `created_at/updated_at/name/status`,非法字段静默忽略,**默认 `-created_at`**(用户要求,创建时间倒序更稳)。dev SPA 加翻页按钮 + 搜索 debounce 300ms + working_dir datalist autocomplete。
- **task 硬删 API + dev SPA delete 按钮 + 文件 per-row 删**:`DELETE /v1/tasks/{id}` user_id ownership 校验 + DB 行删(messages CASCADE)+ **FS task_dir 不动**(同 name 多 task 共享时"最后删了顺便 rmtree"易擦用户素材,经 /files/delete 显式清更安全)。dev SPA chat 面板加 `btn-delete-task`(任何 status 都可删,confirm 带项目名 + 消息条数二次确认);file 面板 per-row 加红 `×`
- **files API 全面 user-rooted(去掉 task_id 前置)**:原 API 用 task_id 拐杖间接拿 working_dir,迫使前端先选 task。`/v1/files/*` 4 路由改 user-rooted(`workspace/users/<uid>/` 为边界),`_safe_join` 边界改 user_root + 加 dotfile 过滤(`.memory/` 隐藏);dev SPA `loadFiles()` 不再 gate on task_id,enterApp 时直接拉。**架构**:与 §7.1 "task / dir 双视图正交"心智对齐,files 操作不该依赖 task。
- **files 面板 UX 项目名 + 修 root crumb bug**:用户混淆"空目录"为"看不到文件夹本身",修两处:① 后端 `cur_rel == "."` 不再追加无意义 "." crumb;② 前端 crumbs 第一格 label 从 "/" 改项目名,整条路径直观 `水泥申报 / 草稿 / draft.md`
- **task_dir 改 eager mkdir**:原"懒 mkdir(skill 首次写产物时建)"是 UUID-named 时代设计,现在 task_dir 是用户给的项目名,**name = 项目声明**,目录就该 task 创建时存在(用户可立刻塞素材文件)。`build_agent` 新建分支 + `web/app.py::create_task` 都加 `mkdir(parents=True, exist_ok=True)`;同 name 多 task 共享 + 已有内容不被擦。
- **task = name-based 项目目录 + memory dotfile**:废弃自动 UUID 派生 + `tasks/` 中间层。新建必给 `name`(简单名,项目目录名);`task_dir = workspace/users/<uid>/<name>/`;同 name 多 task 自动共享同目录(§7.1)。memory 搬 dotfile(`workspace/users/<uid>/.memory/{core.md, extended/}`)跟项目目录扁平共存不撞名;`validate_task_name` 拒 `.` 起头双向防呆。`_cleanup_if_empty` 简化:FS 一律不动(跨 task 复用绝不 rmtree),空 task 只删 DB 行。
### 2026-05-15
- **§7 D 阶段:`/v1` JSON API 落地;Phase G Jinja2/HTMX UI 路线撤**:用户决定与已有 platform 联调,前端用 platform 框架,本仓库再维护 HTML/CSS 就是双套浪费。删 `web/templates/*` + `web/static/*` CSS + jinja2/markdown-it-py/pygments 依赖;重写 `web/app.py``/v1/` 前缀 JSON;SSE event payload 由 HTML 片段切 JSON(`event: <type>` + `data: <JSON>`)。**沉淀**:G 阶段的 sink 协议 / RunBroker fan-out / no-subtask / files 路径安全归一 / task_dir 相对存储全部保留,不被 UI 层牵连。dev SPA `web/static/dev.html` 留一份升级为本地 dogfood 主路径(单文件 vanilla JS,3 栏)。
- **§7 D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA**:`pyjwt` HS256,`AuthConfig.from_env()` 启动校验 `PLATFORM_KEY` / `JWT_SECRET` 必填(任一缺失 fail-fast);`HTTPBearer` Depends + `make_require_user(cfg)` 工厂闭包持 cfg。数据隔离全 `Task.user_id == user_id` + `_assert_owns_task` helper;跨 user 视为 404 不暴露存在性。**SSE 走 fetch + ReadableStream**(`EventSource` 不支持自定义 header,token 没法塞,手解 SSE frame)。**没动 core**(本地 CLI 路径不进 web auth);**TODO**:真 OIDC 接入(替换 /v1/auth/login 内部为 ID token 校验,路由层不动)。
- **task_dir 改相对存储**:DB `tasks.task_dir` 原存绝对(`D:\projects\...`),改为 **ROOT 内→相对 posix、ROOT 外→保留绝对**(用户 `--task-dir` 指外部项目场景)。新增 `core/paths.py::{ROOT, to_db_path, from_db_path}` 三出口,所有读写边界统一过这里;alembic 0002 一次 UPDATE 把现有 ROOT-prefix 行转相对。`CLAUDE.md` 加"开发阶段不写兼容层"心智(用户指示)。
- **workspace 布局统一 per-user**:`workspace/tasks/<uuid>/` + 全局 `workspace/memory/`**`workspace/users/<user_id>/{tasks/<uuid>,memory/}/`**。`build_agent` / memory / web `create_task` 全程透传 user_id;**清旧数据不留兼容**(DELETE tasks CASCADE + `rm -rf workspace/tasks/`)。
- **litellm 启动 cost map 网络警告兜底**:`import litellm` 之前 `os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")` 走打包的本地 cost map,跳过 httpx.get;冷启动从 ~5s SSL 超时降到 <1s
- **Phase G G1-G6 Jinja2/HTMX Web UI(05-1405-15)** _(全撤,被 D 阶段 `/v1` JSON API + dev SPA 替换;沉淀的 sink / broker / no-subtask / files 安全归一保留)_
### 2026-05-14
- **§7.1 心智模型修正:Folder-centric Task 一等公民 + Dir 文件副视图**:dir 不是 task 父容器;双视图正交task_dir 留空 = 一次性对话 / 指定 = 项目化 这条二分语义入文
- **§7 B Steps 1-4 + 6(基建 + Session/TaskState ORM + task_dir 双形态 + no-subtask)**:`core/storage/{engine,models}.py` SQLAlchemy 2.x ORM(5 )+ alembic + `cli db {upgrade,downgrade,current}` + `ZCBOT_DB_URL` 必填;`core/session.py` messages PG(append-only,jsonb,idx 递增);`core/task.py` TaskState 保留内存 DTO 落地走 PG;`state.json` 全废;`check_no_subtask` user 下查前缀嵌套(Python fetch 后归一比对, OS 分隔符容差)。**取消** Step 5 migrate-from-fs(用户决定不兼容旧 workspace)。
### 2026-05-12
- **§7 改写**:platform/core 多租户方案废弃, user-direct(folder-centric 后续 §7.1 修为 task-primary;task/messages PG;no-subtask;hard cascade)。
### 历史(2026-Q1 → 05-11)
- **Phase 1-4**:骨架 / skill / run_python / Model Profile + Probingppt v3 加商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI
- **05-06 Phase 6 部分**:task + state.json + tokens 累计;CLI `tasks` + REPL `/status /done /abandon /desc`;移除 legacy `workspace/sessions/`
- **05-07 TUI + task_dir**:rich Markdown 渲染;spinner 显实时耗时 + 累计 token;system prompt 注入 task_dir 绝对路径,产物收敛 `workspace/tasks/<id>/`
- **05-08 REPL 切换 + 懒创建**:`/resume [last|<id>]`;`build_agent` 不预占文件;`_cleanup_if_empty` 三条件守门。
- **05-09→05-10 §7 草案 + 导出**:DESIGN §7 初版(05-12 重写);`cli.py export <task_id>` + `core/export_docx.py`
- **05-11 原子写 + 双层记忆 + §7 A**:`atomic_write_text` 接管 save;`core/memory.py`(core.md 入 prompt,extended/* 走索引);loop 事件流化(`sink.emit`)铺 SSE 路。
---
## 关键决策与偏差
| 项 | 决策 | 备注 |
|---|---|---|
| 工具基目录 | cwd(读)+ working_dir(写) | system prompt 同时注入两者绝对路径 |
| Workspace 布局 | `workspace/users/<user_id>/{.memory/, <name>/}` | per-user 隔离;memory dotfile 防撞;`<name>` 用户起项目名,同 name 多 task 共享 |
| Eval Suite | 不做 | 个人工具 dogfooding |
| 版本化 prompt | 直接 `general_v1.md` | Windows 软链接麻烦,真要切再做 |
| run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 |
| 兼容层 | 开发期不写 | DB schema / 字段 / API 改动直接切,见 CLAUDE.md |
| `/v1/files/*` 与 DB | files API 作目录树唯一 mutation 入口,DB-FS 一致性服务端内化 | rename / delete 顶层目录 DB-aware(SELECT FOR UPDATE + check_no_subtask + 事务回滚) |
| 单活 run | task 同时最多 1 个活 run | gate 在 `post_message` 同事务 `SELECT FOR UPDATE`,挡连点 send / 多 tab |
| LLM 调用走 streaming | `LLM.chat_stream` + `litellm.stream_chunk_builder` 拼回 response;cancel poll 在 stream chunk 间 + tool_call 之间 | cancel 延迟 100ms 级;顺带 content delta 即时 emit `text` 事件给前端打字机渲染 |
| 发送/停止单按钮 | UI 根据 `state.streaming` 切态;cancel 时 setActionMode("cancelling") 临时 disable | streaming 期间 Enter 不触发停止(防误触) |
---
## 文件清单
```
core/capabilities.py 71
core/llm.py 151 ← litellm 离线 cost map env + chat_stream(stream=True + include_usage)
core/loop.py 268 ← §7 A sink.emit + _stream_llm(chunk 间 poll cancel + emit delta)
core/sinks.py 101 ← §7 A
core/ui.py 38
core/paths.py 50 ← task_dir db form 归一(to_db_path / from_db_path)
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/memory.py 81 ← per-user `.memory/` dotfile
core/export_docx.py 383
core/storage/__init__.py 29 ← record_chat_usage 出口(0006)
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 → cost_cny)
core/storage/usage.py 125 ← record_chat_usage(USD→CNY ×7.2)+ record_image_usage(media tool 入口,单价 snapshot 进 units)
core/storage/utils.py 136
core/ark_client.py 105 ← 火山方舟 HTTP 客户端(共享给 seedream / 后续 seedance)
core/agent_builder.py 325 ← 装配 lib(05-20 加 SeedreamTool 注册,有 ARK_API_KEY 才挂)
tools/{base,fs,shell,run_python,skill_tool,seedream}.py ~640 行
main.py ~210 ← 入口:web / db / probe / user(05-19 加 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 ← 0005 一日游 invites 已撤,接 users.email UNIQUE
0006_usage_events_v2_and_message_model.py 60 ← messages.model_profile 列 + usage_events v2 表(多态 units jsonb)
0007_cost_usd_to_cny.py 40 ← tasks/usage_events 双 rename cost_usd→cost_cny + ×7.2 backfill
web/__init__.py 5
web/app.py ~1320 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files copy/move
web/auth.py ~190 ← D' 过渡:邮箱密码 + platform_key → JWT
web/broker.py 121 ← in-process pub/sub + cancel signal(全 task_id 索引)
web/sinks.py 21
web/static/dev.html ~2480 ← D' dev SPA(3 栏 + 文件预览 + 两 tab 登录 + 选入弹框 + 发送/停止单按钮 + 流式打字机渲染激活)
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx(office 预览)
─────────────────────────────────
Python 合计 ~3400 行(+ dev.html 1700 静态 + vendor 1MB)
```
`skills/ppt|proposal|coding/` 脚本 ~600 行 + SKILL.md / references / config / prompts(含 `config/media/doubao.yaml`)+ alembic.ini,总仓库约 3700 行。
---
## 下一步候选(性价比排序)
1. **真 OIDC 接入 + CORS 收紧**(~1 天)—— `/v1/auth/login` 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。**真发布给真实用户前必做**。
2. **§7 C Executor + sandbox**(~2-3 天)—— `run_python`/`shell` → `Executor.run(...)`,本地保留 subprocess、SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。多用户在线跑代码前置。
3. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
> §7 B + D + D'(过渡 auth)+ 单活 run 锁 + cancel + 0004 schema 瘦身 + 入口归位 主体已完工。剩余路线:真 OIDC → C(Executor)→ F(deploy / billing)。**§7 E CLI 双模式撤**(2026-05-18,§7.9):dev SPA 已是本地 dogfood 主路径,CLI REPL 删,无 `--remote` 双 transport 维护税。原 Phase G Web UI 路线撤(§7.9),UI 改 platform 端实现;`web/static/dev.html` 是开发期单文件 SPA,跟 platform UI 并存不冲突。