# 实施进度 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 最后更新:2026-05-21(dev SPA chip 维度二次校准:工具 I/O 恢复产物白名单门控、助手正文无条件挂 chip 且绕开 seenRels —— 修截图反馈"助手回复 echo 的产物路径没挂 chip"的 bug,同时止 grep/glob/read 误把引用当产物展示) --- ## 状态 | 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 - **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(无对外行为变化)。 - **2026-05-20 / paths.py 砍 ROOT 外路径**:`to_db_path` 越界 → raise(原 `str(pp)` 静默存绝对),`from_db_path` 删 `is_absolute()` 分支(只 `ROOT / s`)。**理由**:写入入口(`web/app.py POST /v1/tasks` → `working_dir_from_name`)只接 simple name join workspace,DB 里只可能存相对串(0002 migration 已清理历史绝对);ROOT 外分支是防御性死代码,符合 CLAUDE.md「不留兼容层」。**没动**:`core/storage/utils.py` no-subtask(`from_db_path` 接口同语义)、alembic(无数据需迁)、调用方(`web/app.py` / `core/agent_builder.py` / `core/export_docx.py` 全不改)。DESIGN §7.4 注释同步。 - **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 把占位异步换 ``/`