diff --git a/PROGRESS.md b/PROGRESS.md index 212305d..0c500dd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,8 @@ ### 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(无对外行为变化)。 diff --git a/web/static/dev.html b/web/static/dev.html index 8cca52a..a290d85 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1394,10 +1394,13 @@ function renderMessages(msgs) { const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const banner = extractMediaBanner(p.name || "", txt || ""); + const rels = ARTIFACT_PRODUCING_TOOLS.has(p.name || "") + ? pickFresh(extractArtifactRels(txt || "", wd)) + : []; card.innerHTML = `
工具调用 · ${escapeHtml(p.name || "")}
结果(${(txt || "").length} 字符)${banner}
${escapeHtml(txt || "")}
- ${renderArtifactBarHtml(pickFresh(extractArtifactRels(txt || "", wd)))} + ${renderArtifactBarHtml(rels)} `; wrap.appendChild(card); continue; @@ -1422,9 +1425,12 @@ function renderMessages(msgs) { try { args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2); } catch (e) { args = (tc.function && tc.function.arguments) || ""; } + const rels = ARTIFACT_PRODUCING_TOOLS.has(fn) + ? pickFresh(extractArtifactRels(args, wd)) + : []; html += `
工具调用:${escapeHtml(fn)}
${escapeHtml(args)}
- ${renderArtifactBarHtml(pickFresh(extractArtifactRels(args, wd)))} + ${renderArtifactBarHtml(rels)} `; } } @@ -1691,7 +1697,9 @@ function handleSseEvent(ev, asstCard, ctx) { det.innerHTML = `工具调用:${escapeHtml(fn)}
${escapeHtml(argsStr)}
`; asstCard.appendChild(det); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); - const fresh = extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r)); + const fresh = ARTIFACT_PRODUCING_TOOLS.has(fn) + ? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r)) + : []; fresh.forEach(r => ctx.seenRels.add(r)); const barHtml = renderArtifactBarHtml(fresh); if (barHtml) { @@ -1708,7 +1716,9 @@ function handleSseEvent(ev, asstCard, ctx) { det.innerHTML = `工具结果${banner}
${escapeHtml(txtStr)}
`; asstCard.appendChild(det); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); - const fresh = extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r)); + const fresh = ARTIFACT_PRODUCING_TOOLS.has(toolName) + ? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r)) + : []; fresh.forEach(r => ctx.seenRels.add(r)); const barHtml = renderArtifactBarHtml(fresh); if (barHtml) { @@ -2159,6 +2169,15 @@ function _workingDirName(workingDir) { return segs[segs.length - 1] || ""; } +// 产物工具白名单:只有这些工具的 tool_call args / tool_result 里 echo 的 /... +// 路径才挂 chip(图片/视频 inline 预览,其它类型为可点按钮)。通用工具 +// (grep/read/shell/glob 等)的 I/O 里出现的路径是引用而非新产物,放开会把命中 +// 的无关老文件全展示成"产物"。assistant 正文不受此限 —— 助手主动提到的路径仍 +// 挂 chip,seenRels 兜底防同文件重复。 +// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增 +// "生成 docx 的工具",入这里但不入 banner 白名单。 +const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]); + // 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。 // 启发式:把 \ 一律归 /,然后找以 `/` 打头的串,要求最后一段含 . (像文件)。 // 从 seedream/seedance tool_result 第一行 banner 抽 model/size/cost/elapsed,