# 实施进度 > 配合 `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.py`→`main.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//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////.{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=False` 或 `xml_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/tasks` 加 `run_status` 筛选**:用户问"task + working_dir 设计如何 / 同 wd 多 task 并发咋处理",评估了 γ(同 wd 单活 gate)/ short_id 全产物隔离 / clone task 三个方案均判定过度工程 —— dogfood 经验同 wd 基本不并发,定了 Claude Code 同款"信任 + 软警告 + 承认 limitation"。**后端**:`GET /v1/tasks` 加 `run_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//pdf/` 流式下载到 `/papers/.pdf`,已存在跳过,`has_fulltext_pdf=False` 抛 RuntimeError;`_resolve_to_id` DOI → id(`10.` 前缀启发式);base_url 默认 `http://paper.xxhhcty.xyz:8080` 可 `PAPER_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`:`PaperListSerializer` 加 `abstract = SerializerMethodField()` 从 O2O `paper.abstract.abstract` 取(无行 / has_abstract=False → 空串);**list 和 retrieve 共用一个 serializer**,不分 list/detail(原 handoff §A 想分两个,落地时砍掉降复杂度)。② `apps/resm/views.py`:`PaperViewSet` 加 `CustomRetrieveModelMixin`(原只挂 list,retrieve 端点根本不存在 → `/api/resm/paper//` 404 是个 bug);queryset 改 `Paper.objects.select_related("abstract").all()` 解决 N+1(list 20 条原本 21 次 query,现 1 次 LEFT JOIN);`filterset_fields` → `filterset_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_fields**。**Tradeoffs**:① 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 该留;② `febe04a` 的 `seenRels` 全局去重把"防同图被 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 维度解绑产物工具白名单 + `renderArtifactBarHtml` 加 `allowInlineMedia` 参数**:用户反馈"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)` 三元短路:① `renderMessages` 的 `role==="tool"` 历史卡(行 1395-1404)② `renderMessages` 的 assistant `tool_calls` args(行 1422-1437)③ SSE `handleSseEvent` 的 `tool_call`(行 1692-1707)④ SSE `tool_result`(行 1714-1729);**assistant 正文(行 1417)不门控**,沿用 seenRels 兜底(助手主动 echo "刚生成的 xxx.png" 仍能挂 chip,seenRels 防同图重复)。**对比方案**:② 目录限制(regex 只匹配 `/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);同位置加 ``(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 → 占位 ``,其他 → 沿用 `.art-chip`;新 `upgradeMediaArtifacts(root)` DOM walk 把占位异步换 ``/`