zcbot/PROGRESS.md

85 KiB
Raw Blame History

实施进度

配合 DESIGN.md。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 git log / git diff

最后更新:2026-05-21(research skill 三次迭代:fetch_pdf 改走静态直链跟 fetch_xml 对齐,绕开 paper_pdf_view 路径 bug → 5/5 fetch 100% 成功;二次迭代 pdf_url/xml_url 直链 + fetch_xml + pg_trgm 索引;顶栏 token 累计修)


状态

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.pymain.py,装配 lib 挪 core/agent_builder.py);真 OIDC 待;C(Executor)待。

已完成关键能力

2026-05-21

  • research skill 三次迭代:fetch_pdf 改走静态直链跟 fetch_xml 对齐(绕开 paper_pdf_view 路径 bug):二次迭代 redeploy + nginx 修(proxy_set_header Host $http_host 透传含端口的 Host)后跑 smoke 发现:fetch_xml 5/5 PASS,fetch_pdf 5/5 仍 404 —— 同一批 cement paper 同一目录,XML 能走 nginx 静态 serve 下,PDF 走 paper_pdf_view 端点全 404。用户确认 "has_fulltext_pdf=True 就保证 disk 有 PDF",证实是 paper_pdf_view 的 init_paper_path 计算的 disk 路径与实际不一致(非数据问题,是端点 bug)。修法:paper.py::fetch_pdf 改成跟 fetch_xml 同范式 —— 从 paper["pdf_url"] 读静态直链 + 走 _stream_to,删原 /resm/paper/<id>/pdf/ 调用;has_fulltext_pdf=False / pdf_url 空 → 抛 RuntimeError(契约与 fetch_xml 一致)。删常量 _PDF 不再被引用。SKILL.md 同步描述,smoke step 3 提示文案更新。验证:smoke 跑通 5.4MB PDF / 3843ms 下载 + 152ms 复用,5/5 候选 100% 成功(对照之前 5/5 全 404)。没动:paper_pdf_view 端点本身(浏览器用户 inline disposition 可能仍在用,这是 paper_server 内部端点 bug 由用户后续修);fetch_xml / _resolve_to_id / _stream_to / SerializerMethodField 后端拼 URL 逻辑;PaperListSerializer / migration 0006 / nginx 配置(已生效)。Tradeoff:① helper 不再依赖 paper_pdf_view 的 has_fulltext_pdf 二次预检,只信 list/retrieve 返的字段 + nginx static serve(透传 404 即客户端 raise);② paper_pdf_view bug 还在,但不影响 zcbot 工作流(zcbot 走静态直链),由 paper_server 自己后续处理。

  • research skill 二次迭代:list 端点加 pdf_url / xml_url 直链 + 新增 fetch_xml + pg_trgm GIN 索引根治 SearchFilter timeout:首版 redeploy 后跑 scripts/smoke_paper_skill.py 发现两个 paper_server 既有问题 — P1 ?search=cement 不带 filter 30s timeout(title ILIKE '%xxx%' OR ... 跨 3 列前后通配,B-tree 索引救不了),P2 5/5 候选 paper_pdf_view 全 404(has_fulltext_pdf=True 但磁盘文件缺失,DB/disk 不一致)。用户指出 media/papers/<Y>/<M>/<D>/<safe_doi>.{pdf,xml} 静态 URL 可直接访问,且 XML 也走同样路径(原 paper_pdf_view 只支持 PDF,XML 没任何 API 暴露)。paper_server 改动:① apps/resm/serializers.py 加两个 SerializerMethodField pdf_url / xml_url,基于 obj.publication_date.{year,month,day} + safe_doi 后端拼,用 request.build_absolute_uri() 给完整 URL;has_fulltext_pdf/xml=False / publication_date=None(unknown 目录)返空串避免 LLM 拿到 URL 就 404。② 新 migration 0006_pg_trgm_index.py:CREATE EXTENSION IF NOT EXISTS pg_trgm + 3 列 GIN 索引(title / first_author / first_author_institution),把 ILIKE '%xxx%' 全表扫降到几十 ms。zcbot 改动:① paper.py _LIST_FIELDS 扩到 16 字段(加 publication_date / has_fulltext_xml / pdf_url / xml_url);② 新加 fetch_xml(id_or_doi, working_dir) -> str helper,对称 fetch_pdf走 paper_server media 静态直链(从 paper["xml_url"] 读),has_fulltext_xml=Falsexml_url 空都抛 RuntimeError;fetch_pdf 抽出 _stream_to(url, dest) 共用 helper;③ SKILL.md 工作流加 "XML 优先 PDF" 原则(XML 已结构化:章节/参考文献/caption 有标签,LLM 友好,免 OCR / PDF 文本抽取)+ fetch_xml 文档 + 错误处理表更新;④ smoke_paper_skill.py 新增 step 0(trgm 速度验证,>5s 警告 migration 没跑生效)+ step 4(fetch_xml 链路 ×2 复用 + 多关键词候选轮询)+ step 1 字段集 expected 同步扩到 16。对比方案:① abstract 从 list 移除以缓解 P1 —— 复盘 P1 根因是 SearchFilter 跨列 ILIKE 跟 abstract 字段无关,移了不解决问题反失收益,不动;② pdf_url 走 paper_pdf_view 端点 —— 仍依赖 has_fulltext_pdf 预检 DB 标记,直链跳过 DB 标记,disk 在就能下 + 同时让 XML 走同一范式;③ pg_trgm 索引用 CONCURRENTLY 不锁表 —— Django migration 默认 atomic 不支持,且 dev 期表行数小普通 CREATE INDEX 秒级,不必引入 atomic = False 复杂度。Tradeoffs:① URL 直链字段是后端拼,paper_server media 路径若改 → serializer 同步改一处,前向兼容性强于客户端拼;② pg_trgm 扩展需 superuser 权限一次性安装,普通用户运行 migration 报权限错时手动 psql -c "CREATE EXTENSION pg_trgm;" 即可;③ fetch_xml 不走 paper_pdf_view 范式 —— 因为没对应 API,且静态 URL 不依赖 has_fulltext_xml DB 标记预检(LLM 能跳过 stale 标记拿到 disk 上的文件);④ P2 数据一致性问题(DB 标 has_fulltext_pdf=True 但 disk 文件丢)本次不修,属于 paper_server 抓取 pipeline 的事,smoke 里已加多候选轮询逻辑容忍。没动:Paper model / views.py(retrieve mixin / select_related / filterset_class 已在前一条改完)/ urls / paper_pdf_view / search_fields / _resolve_to_id / DESIGN / RUN。部署侧遗留(非代码 bug):nginx 反代到 paper_server 时缺 proxy_set_header Host $http_host;,Django request.get_host() 拿到的是不带端口的 paper.xxhhcty.xyz,导致 build_absolute_uri 出来的 pdf_url / xml_url 漏 :8080 端口 → 客户端直链 fetch 失败。修法:nginx location 块加上述 header 透传,reload 即生效,无需 paper_server 重启 / 改代码(代码层面 build_absolute_uri 已是标准做法)。

  • 顶栏 token 累计修(sync_task_tokens 改走 messages SUM,删 LLM.TokenCounter):用户报"token 计数一直 0"。复盘:5/20 把 loop 切流式后,LLM.token_counter.add() 只在同步 chat() 路径里调,新走的 chat_stream() 路径从来不更新它;agent_builder.sync_task_tokens(task_state, llm) 每轮 run 后从 llm.token_counter.{prompt,completion}_tokens 读累计 UPDATE 进 tasks.tokens_prompt/completion — 内存计数器永远 0 → tasks 行 0/0 → _task_dict 顶栏数字 0。DB 验证:5/20 08:55 之前最后一个 task 4568/934,之后所有 task 0/0;但每条 assistant message 的 tokens_in/out 都是对的(3223/1014 这种,record_chat_usage 在 loop 里写),所以 source-of-truth 在 messages 表完好,只是 task 级冗余汇总列没同步。修法:删 LLM.TokenCounter 整个类 + .token_counter 属性 + chat() 里那行 .add() 调用;sync_task_tokens 改签名为 (task_state)(不再要 llm),内部 SELECT coalesce(sum(tokens_in),0), coalesce(sum(tokens_out),0) FROM messages WHERE task_id=? 现算后 UPDATE。ConsoleEventSink 同步删 token_counter 回调参数 + spinner fmt 的 ctx N tok 尾巴(CLI 旁路,改动小)。web/app.py:273 调用点改 sync_task_tokens(task_state)对比方案:① 一行补丁在 core/loop.py 拼回 response 后补 self.llm.token_counter.add(response.usage) — 最小,但留着 TokenCounter 这个"内存计数器 vs DB 真相"双写源头不解决根因;② 当前方案改 4 文件去掉冗余,符合"开发期以最优实现为准不留兼容层"。性能:(task_id) FK + uq_messages_task_idx 复合索引,单 task 行数顶天几百,SUM 两 int 亚毫秒,在刚跑完几秒 LLM 的 round-trip 噪声里。Backfill:SELECT task_id, SUM(...) GROUP BY task_id 一次性把现有 0/0 行修对,4 个 task 累计补正(52943/6593、26191/8687、10138/427、6399/1069)。没动:DB schema(tasks.tokens_prompt/completion 列保留作汇总展示,只是数据源改 messages 现算)、record_chat_usage(per-message 写入逻辑就是真相源)、loop / streaming 流程、DESIGN(§348 描述 sync_task_tokens 维护 仍准确,只是实现细节变,不属于架构/schema/API 语义变化)。

  • 同 wd 并发软警告 banner + task header 📁 wd 仅在 name≠wdName 时显示 + /v1/tasksrun_status 筛选:用户问"task + working_dir 设计如何 / 同 wd 多 task 并发咋处理",评估了 γ(同 wd 单活 gate)/ short_id 全产物隔离 / clone task 三个方案均判定过度工程 —— dogfood 经验同 wd 基本不并发,定了 Claude Code 同款"信任 + 软警告 + 承认 limitation"。后端:GET /v1/tasksrun_status query 参数(逗号分隔,allowlist idle/running/cancelling/error,非法静默忽略),拼 Task.run_status.in_(set) 条件;复用现有 (user_id, working_dir) 索引,同 wd 活跃 task 常态 0/1 行近零开销。前端:① refreshConcurrentWarnings()selectTask + SSE 收尾两点 fire-and-forget 拉 working_dir=<末段名>&run_status=running,cancelling&page_size=10,过滤 task_id != current 后存 state.concurrentWarnings;② renderConcurrentWarning()#chat-meta 后 / #chat-stream 前插 #wd-concurrent-warn 黄底 banner(⚠ + 项目名 + 邻居 task name + run_status + "等 N 个"),非阻塞不挡发送;③ renderChatMeta📁 wdName 改为"仅 wdName !== taskName 时显示"(留空 fallback 多数场景 name == wd,显示是噪音;不同时显示提示项目归属)。对比方案:γ 硬挡破坏对话切换流畅性,short_id 全产物破坏 §7.1 扁平共享语义 + SKILL.md 改造成本,clone task 工程量对零频场景过重;软警告 ~80 行(后端 10 + 前端 70)实现意图,真高频再升级。没动:不轮询(接受"邻居 task 在我浏览时收尾,banner 短暂 stale 几秒"边界 — 同 wd 并发本就近 0)、警告无"不再提示"开关、不点击跳目标 task、宪法文件 short_id 命名约定保留(§7.9 2026-05-20 不动)、不加 clone / gate / 物理隔离。文档:DESIGN §7.8 风险表"文件级悲观锁"(本就未实现)行替换为"软警告 + known limitation";§7.9 新增 2026-05-21 取舍条说明 γ/short_id/clone 三方案为何都不选 + 引 Claude Code 同款设计。

  • paper_server → zcbot research skill(查文献 / get abstract / 拉 PDF):用户要 zcbot 能查内部部署的 paper_server(http://paper.xxhhcty.xyz:8080/,OpenAlex 元数据 + Sci-Hub PDF 抓取)。范式判断:不做 tool(频次低 + zcbot 没 ToolSearch 基建,3 个函数 schema 永驻 chat context 不划算)、不做 MCP(部署/分发成本)、不裸 run_python 调 httpx(每次重复写 base_url / 字段名,且易漂移)、不做 helper-lib(LLM 不知道该 import 啥) → 做成 skill(同 proposal/ppt 范式,SKILL.md + paper.py helper 同目录,LLM load_skill("research") 后用 run_python 调 helper)。zcbot 新增:① skills/research/SKILL.md:何时用 / 何时不用 / 三函数签名 + 示例 / 工作流(search 看 abstract → 必要时 fetch_pdf → read PDF)/ 错误处理 / 反模式 / "keyword 优先英文"专段(库主语料英文,用户中文输入要转专业英文术语,带中英对照例子表)。② skills/research/paper.py(~140 行):search(keyword, year, year_gte, year_lte, doi, first_author, publication_name, has_pdf, is_oa, limit) → paper_server /api/resm/paper/ list 端点,精简 12 字段返(含 abstract);has_pdf 走精确的 has_fulltext_pdf filter(不是 has_fulltext 那个含 xml 的);get_paper(id_or_doi) → retrieve 端点(list 已带 abstract,正常工作流不需要,仅用户给单 id/DOI 想拿全字段时用);fetch_pdf(id_or_doi, working_dir)/resm/paper/<id>/pdf/ 流式下载到 <working_dir>/papers/<safe_doi>.pdf,已存在跳过,has_fulltext_pdf=False 抛 RuntimeError;_resolve_to_id DOI → id(10. 前缀启发式);base_url 默认 http://paper.xxhhcty.xyz:8080PAPER_SERVER_URL env 覆盖。③ tools/run_python.py 注入 PYTHONPATH=base_dir(关键 enabler):子进程 cwd 是 zcbot 仓库根但默认 PYTHONPATH 不含项目根 → 不能 from skills.research.paper import ...;env["PYTHONIOENCODING"] 那行后加 env["PYTHONPATH"] = str(self.base_dir) + os.pathsep + env.get("PYTHONPATH", ""),LLM 能直接 import 不必折腾 sys.path。paper_server 改动:① apps/resm/serializers.py:PaperListSerializerabstract = SerializerMethodField() 从 O2O paper.abstract.abstract 取(无行 / has_abstract=False → 空串);list 和 retrieve 共用一个 serializer,不分 list/detail(原 handoff §A 想分两个,落地时砍掉降复杂度)。② apps/resm/views.py:PaperViewSetCustomRetrieveModelMixin(原只挂 list,retrieve 端点根本不存在 → /api/resm/paper/<id>/ 404 是个 bug);queryset 改 Paper.objects.select_related("abstract").all() 解决 N+1(list 20 条原本 21 次 query,现 1 次 LEFT JOIN);filterset_fieldsfilterset_class = PaperFilterSet。③ 新增 apps/resm/filters.py(~20 行):PaperFilterSet 声明 publication_year_gte/lte NumberFilter(LLM 做"近 5 年文献"用)+ exact 字段 publication_year/type/fetch_status/has_abstract/has_fulltext/has_fulltext_pdf/is_oa/publication_name/first_author/openalex_id/doi。④ search_fields 不动(仍是 title/first_author/first_author_institution)— 评估过加 abstract__abstract 提升中→英 keyword 召回,被用户判定相关性下降 + 性能担忧 > 召回收益,保持现状,靠 SKILL.md 引导 LLM 转英文 keyword 而不是扩 search_fieldsTradeoffs:① skill 内 helper 范式让 paper_server API 漂移时改一处(paper.py)而不是 prompt + tool schema;② DOI 启发式 _is_doi 容易误判像 arxiv/2401.xxxxx 这种非标准串(prefix 不是 10.),paper_server 内部用真 DOI(10.xxx/...)所以本库内场景稳;③ search(limit>50) 自动夹紧到 50 防 LLM 误用一次性拉全表;④ list 加 abstract 后 payload 从 ~1KB/条 涨到 ~3KB/条,默认 limit=10 也就 30KB,内网毫无感知 + 省去 LLM 逐条 get_paper 的 roundtrip(主要收益)。没动:tool 系统 / agent_builder.py / config / ModelCapabilities / ToolSearch 基建 / paper_pdf_view / urls / Paper model / DESIGN(skill 是已有抽象)/ RUN(PAPER_SERVER_URL 是可选 env,有默认值)。遗留:paper_server 三个文件已落地,由用户 redeploy;redeploy 后跑 scripts/smoke_paper_skill.py(三步:search list shape + abstract 字段 / get_paper retrieve 端点 / fetch_pdf 落盘 + 复用)。

  • dev SPA chip 维度二次校准:工具 I/O 走产物白名单 + 助手正文无条件挂 chip 绕开 seenRels:截图反馈"助手回复里 echo 的产物图路径(rust介绍/figures/...png)没挂 chip"。复盘上一条改动 + febe04a:① 上一条把工具 I/O 的 chip gate 也解了 —— 实际意图是"glob/grep 列出的引用不该挂(否则把命中的老 figures/foo.png 当新产物展示)"故 gate 该留;② febe04aseenRels 全局去重把"防同图被 inline 两次"做过头了,把助手正文 echo 的同路径 chip 也吃掉。最终模型(三条规则):① 工具 I/O(args/result):chip 抽取只对产物工具(seedream/seedance);② 产物工具的产物图/视频:inline 大图;③ 助手正文 echo 的路径:永远挂 chip(绕开 seenRels)+ 强制 allowInlineMedia=false(只小按钮,绝不重复 inline 大图 —— 因为产物工具上面已经 inline 过了)。改动:renderMessages 3 处(tool 卡 / assistant 正文 / assistant tool_calls args)+ SSE 2 处(tool_call / tool_result)按上面规则改写;pickFresh(seenRels 读写)只在产物工具的两处保留(防同图 inline 二次),assistant 正文改成 renderArtifactBarHtml(extractArtifactRels(...), false) —— 不读不写 seenRels,直接 chip。SSE 处 upgradeMediaArtifacts 同步 gate 到 if (isProducer) 下,非产物工具不发 blob fetch。为什么 chip 重复出现无害:chip 是 monospace 小字 + 5px 圆角小按钮,占 1 行;同路径在 tool 结果 + assistant 正文都出现,体感是"工具产出了它 + 助手又提到它",是合理叙事节点,跟"两张同样的大 PNG 占整屏"完全不同视觉量级。对比方案:① 助手正文也走 seenRels 但区分 chip/inline 类型(seen=path 同时也存 cat),只去重 inline、放过 chip — 复杂度涨,逻辑分支多;② 后端 tool_result 元信息显式标 produced_files(前端不再启发式抽路径)— 干净但 SSE/历史回放/seedream 全要改,成本最大,不上。当前方案 4 行实现意图。没动:extractArtifactRels regex / _categorize / 媒体 blob 缓存 / chip 点击委托 / 后端 / DESIGN(纯前端 UX 反复)/ RUN。遗留:用户提"绝对路径有些没挂 chip",等具体例子再排(可能是 wd_name 与历史路径段不齐 / 跨 task 路径)。

  • dev SPA chip 维度解绑产物工具白名单 + renderArtifactBarHtmlallowInlineMedia 参数:用户反馈"text 里必须挂 chip(不需要图片 banner,就是原先的 chip)"。复盘 1e4548d:它把"图片不该被无关工具误内联"和"chip 该不该挂"绑成同一个白名单 gate,砍多了 —— 用户看到 grep/read 结果里的路径直觉上想点开预览,但 chip 被一起锁了。修法:把 gate 降级到"图片/视频是否 inline"那层。renderArtifactBarHtml(rels, allowInlineMedia=true) 加第二参,false 时图片/视频也走 .art-chip 按钮(点开仍弹预览 modal,跟其它格式一致);4 处 tool 调用点(renderMessages tool 卡 + assistant tool_calls args + SSE tool_call + SSE tool_result)解绑 ARTIFACT_PRODUCING_TOOLS.has(...) gate,改成无条件 extractArtifactRels,只把 inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(name) 透传给 renderArtifactBarHtml 的第二参;SSE 两处的 upgradeMediaArtifacts(asstCard) 也 gate 到 if (inlineMedia) 下,非产物工具就不发 blob fetch(不必要 / 也不会 inline);assistant 正文(行 1416)沿用默认 true,继续 inline。两类白名单语义:ARTIFACT_PRODUCING_TOOLS 现在专管"inline 大图/视频",chip 自身不受其限;extractMediaBanner 的"媒体 banner"(seedream/seedance tool_result 首行 model/size/cost/elapsed)仍单独白名单(它依赖工具协议的 key=value 格式,跟产物维度独立)。Tradeoff:① grep 一个老 PNG → 现在会挂 chip,但会内联大图 — 用户能点开预览但屏幕不被无关老图占满;② 用户提了"绝对路径有些没挂 chip" — 推后再修,先验证当前方案符合预期。没动:extractArtifactRels 本身(regex 不变,现 4 处调用点都走它)、_categorize / chip 点击委托 / 媒体 blob 缓存、后端、DESIGN(纯前端 UX,无架构/schema 变化)、RUN(无对外行为变化)。

  • loop.py tool message append 补 name 字段 + backfill 历史:用户报"重开历史 task,seedream 生成的图既没有 elapsed banner 也没挂 chip"。根因:core/loop.py 第 161-167 行 append tool 消息时只写 role/tool_call_id/content,没存 name;前端 dev.html 历史渲染依赖 payload.name 判断是否产物工具(ARTIFACT_PRODUCING_TOOLS.has(p.name))+ 抽 elapsed banner(extractMediaBanner(p.name, ...)),刷新后两者全黑。流式时正常 — SSE event 单独带 name(_emit("tool_result", name=...)),但 SSE 数据不入 DB,所以只有"刚生成那一刻"能看到。修法:loop.py 一行加 "name": tc.function.name 进 session.append 的 dict(OpenAI tool message spec 本来就有这字段,LiteLLM 接受);cancelled 占位那处(第 96-99 行)不动 —— 它的 content 是 [cancelled by user] 占位串,banner 正则匹配不上、chip 抽不出路径,挂 name 也无效果。对比方案:① 前端按 tool_call_id 反查上一条 assistant 的 tool_calls[].id → name(纯前端,不动后端 / DB)— 但每条 tool 消息渲染时都得线性扫之前所有 assistant 消息,O(n²) + 散在 5 个渲染点;② 用户提议的"路径带 user_id 前缀作为产物信号"— 不解决历史数据(已存内容里没 user_id 串)、判别力不够(grep/read echo 老图也会带)、违背 commit 9a7620f+5ff09b9 的 user_root-relative 简化方向。一行 fix + backfill 是改动最小同时彻底的方案。Backfill 脚本 scripts/backfill_tool_message_name.py:按 task 分组扫 assistant.tool_calls 建 tool_call_id → name map,再扫该 task 的 tool 消息按 tool_call_id 查 map 补 name,flag_modified 标 JSONB 变更;默认 dry-run,--apply 真写;幂等(已有 name 跳过)。本地 17 条 tool 消息全部填上(seedream/glob/shell/read/write/load_skill 等),0 unresolved。没动:DB schema(payload 是 jsonb,加 key 无需 migration)、前端(已经在读 p.name,本来就支持只是历史数据没填)、其他 tool 调用路径、DESIGN(纯实现 bug,无架构变化)、RUN(无对外行为变化)。

2026-05-20

  • dev SPA chip 抽取改"产物工具白名单"门控(根因消 grep/read 类工具误挂无关文件 chip + 图片误 inline 预览):用户报"生成的图正常预览,但 grep 类工具的结果里 figures/ 下另一张老图也被 inline 出来了"。范围其实更大 — extractArtifactRels + renderArtifactBarHtml通用产物展示(image/video → inline,其他扩展名 → 可点 chip),所以 grep/read/shell/glob 等通用工具结果里 echo 的任何带扩展名路径(.py/.md/.png...)都会被当产物挂出来,图片只是其中最扎眼的一种;seenRels 只能去重同路径,挡不住"figures/ 下别的老图第一次出现"。修法:web/static/dev.html 新加 ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]) 白名单(产物维度,与 extractMediaBanner 的"媒体 banner 维度"解耦 — 将来若加"生成 docx 的工具",入这里但不入 banner 白名单),4 处工具 I/O 调用点全部用 ARTIFACT_PRODUCING_TOOLS.has(toolName) 三元短路:① renderMessagesrole==="tool" 历史卡(行 1395-1404)② renderMessages 的 assistant tool_calls args(行 1422-1437)③ SSE handleSseEventtool_call(行 1692-1707)④ SSE tool_result(行 1714-1729);assistant 正文(行 1417)不门控,沿用 seenRels 兜底(助手主动 echo "刚生成的 xxx.png" 仍能挂 chip,seenRels 防同图重复)。对比方案:② 目录限制(regex 只匹配 <wd>/figures/)— 把通用 chip 系统降级为只服务 seedream,未来非媒体产物(pdf/docx/zip)就被锁死;③ 后端 tool_result 元信息带 produced_files 显式列表 — 最干净但 SSE / 历史回放 / seedream 都要改,改动量最大。chip 系统的本意是"这次工具调用新产出的东西",grep/read 输出里的路径是"引用"不是"产物",white-list 在工具级过滤是正确语义,改动也最小。Tradeoff:read figures/foo.png 后老图不再挂 chip — 但这就是对的(读 ≠ 产);未来加新的产物生成工具需补白名单一行(成本极低,且本就该明确登记)。没动:extractArtifactRels / renderArtifactBarHtml 实现(它们仍 generic,只是调用入口被门控)、_workingDirName / 媒体 blob 缓存 / chip 点击委托、后端、DESIGN(纯前端 UX 修复,无架构/schema 变化)、RUN(无对外行为变化)。

  • 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.pyOptimizePromptRequest{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 ctxseenRels: 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_bgbuild_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;sendMessagestate.imageModel 跟在 POST body 上发出去。内联媒体:_EXT_GROUPSvideo: {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.pychat_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_usdcost_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_usagekind="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_agentArkConfig.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/deleterecursive 字段(级联删除非空目录) + 顶层目录 task 引用闸 + dev SPA 二次确认显示条目数:用户报"文件夹内有文件就不给删除",需要级联删除。后端:FileDeleteRequestrecursive: bool = False,handler recursive=False 沿用 target.rmdir()(非空仍 400);recursive=Trueshutil.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::Tooluser_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_agentur_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,区别于完成绿/废弃橙/删除红),renderChatMetarunning||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 条:① renderMessagesrole==="tool"历史卡;②renderMessages的 assistanttool_calls历史;③handleSseEventtool_call流式;④handleSseEventtool_result 流式。chat-stream上加点击委托 →openFilePreview(rel),modal 内已带"下载"按钮所以 chip 不另开二级图标。**取舍**:路径识别限定 working_dir/前缀(skill 脚本cd后只 print 纯相对路径的情况会漏抓,v1 误判控制代价);纯目录(末段无.)直接跳过。**没动**:右栏文件面板、openFilePreview/downloadFile` 接口(纯复用)、后端、DESIGN、RUN(对外行为零变化,纯 UI 增量)。

  • task 级「宪法」文件 (spec) 命名约定 + spec_lockspec 简化:同 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_prompttask_id / today 注入 + 命名约定段 — 所有 skill 共享一份约定文本,SKILL.md 不再重复;proposal / ppt SKILL.md 阶段一加"先 glob 检测已有 spec → 询问沿用/重定调"分支。_lock 后缀无信息量去掉(templates/spec_lock.mdtemplates/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-collapsedapplyLeftCollapsed 里翻向(展开态 折叠态 )。彻底删 #hd-toggle-left + header .icon-btn CSS 块,header 不再背 expand 入口的债。② time-ago 加 flex-shrink: 0; text-align: right; min-width: 64px 锁宽,这才是真正解决跨行对齐的关键:此前 .num.right-groupmargin-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_agentworking_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/modelsconfig/models/*.yaml 列可选项(含 display_name / thinking_mode / is_default),ModelCapabilitiesdisplay_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_agentuser_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/downloadCache-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.pymain.py,原 main.pycore/agent_builder.py,删 CLI REPL,§7 E 撤:main.py 混三角色(装配 lib + utility + cli/web 共 import 的事实入口),按 SoC 拆。git mv 两次(覆盖)+ 5 处 from main importfrom 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 就够。tasksrun_status text default 'idle'(idle/running/cancelling/error,error 是唯一持久终态)+ run_error text。Broker 全 task_id 索引 + 加 start(task_id) 清上轮 done 标记。dev SPA:state.currentRunIdstate.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-14→05-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 + Probing。ppt 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/shellExecutor.run(...),本地保留 subprocess、SaaS 走 docker;api_key_envKeyProvider 运行时注入。多用户在线跑代码前置。
  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 并存不冲突。