From 5f0f296a2363b810fcb307d6c5120256172223ee Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 21 May 2026 08:44:36 +0800 Subject: [PATCH] =?UTF-8?q?ui(media):=20chip=20=E4=B8=89=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=AE=9A=E5=9E=8B=20=E2=80=94=20=E5=B7=A5=E5=85=B7=20I/O=20?= =?UTF-8?q?=E8=B5=B0=E4=BA=A7=E7=89=A9=E7=99=BD=E5=90=8D=E5=8D=95=20+=20?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E6=AD=A3=E6=96=87=E6=97=A0=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E6=8C=82=20chip=20=E7=BB=95=E5=BC=80=20seenRels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修截图反馈"助手回复 echo 的产物路径没挂 chip"。① 工具 I/O(args/result):chip 抽取只对产物工具(seedream/seedance),通用工具 echo 是引用不该挂;② 产物图/视频:inline 大图;③ 助手正文:永远挂 chip 且 allowInlineMedia=false,只小按钮不重复 inline 大图。SSE 处 upgradeMediaArtifacts 同步 gate 到 isProducer 下。 Co-Authored-By: Claude Opus 4.7 (1M context) --- PROGRESS.md | 4 +++- web/static/dev.html | 57 ++++++++++++++++++++++++++------------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 7b226fb..7385b7b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-21(dev SPA chip 维度解绑产物白名单 → 通用工具结果里 echo 的路径也挂 chip,但图片/视频 inline 仍只对产物工具开;`renderArtifactBarHtml` 加 `allowInlineMedia` 参数) +最后更新:2026-05-21(dev SPA chip 维度二次校准:工具 I/O 恢复产物白名单门控、助手正文无条件挂 chip 且绕开 seenRels —— 修截图反馈"助手回复 echo 的产物路径没挂 chip"的 bug,同时止 grep/glob/read 误把引用当产物展示) --- @@ -23,6 +23,8 @@ ### 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(无对外行为变化)。 diff --git a/web/static/dev.html b/web/static/dev.html index 672c429..2359210 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1394,12 +1394,14 @@ 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 = pickFresh(extractArtifactRels(txt || "", wd)); - const inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(p.name || ""); + // 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具 + // (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。 + const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || ""); + const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : []; card.innerHTML = `
工具调用 · ${escapeHtml(p.name || "")}
结果(${(txt || "").length} 字符)${banner}
${escapeHtml(txt || "")}
- ${renderArtifactBarHtml(rels, inlineMedia)} + ${renderArtifactBarHtml(rels, isProducer)} `; wrap.appendChild(card); continue; @@ -1410,10 +1412,12 @@ function renderMessages(msgs) { let html = `
${roleLabel}
`; if (typeof p.content === "string" && p.content) { html += `
${renderMd(p.content)}
`; - // assistant 正文里 echo 的 /... 路径同样挂 chip 条(只对 assistant,user 输入不抽) + // assistant 正文里 echo 的 /... 路径**永远**挂 chip(绕开 seenRels —— 上面 + // tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可 + // 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline。 if (role === "assistant") { const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); - html += renderArtifactBarHtml(pickFresh(extractArtifactRels(p.content, wd))); + html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), false); } } if (Array.isArray(p.tool_calls) && p.tool_calls.length) { @@ -1424,11 +1428,11 @@ 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 = pickFresh(extractArtifactRels(args, wd)); - const inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(fn); + const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn); + const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : []; html += `
工具调用:${escapeHtml(fn)}
${escapeHtml(args)}
- ${renderArtifactBarHtml(rels, inlineMedia)} + ${renderArtifactBarHtml(rels, isProducer)} `; } } @@ -1695,13 +1699,15 @@ 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 isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn); + const fresh = isProducer + ? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r)) + : []; fresh.forEach(r => ctx.seenRels.add(r)); - const inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(fn); - const barHtml = renderArtifactBarHtml(fresh, inlineMedia); + const barHtml = renderArtifactBarHtml(fresh, isProducer); if (barHtml) { asstCard.insertAdjacentHTML("beforeend", barHtml); - if (inlineMedia) upgradeMediaArtifacts(asstCard); + if (isProducer) upgradeMediaArtifacts(asstCard); } } else if (t === "tool_result") { const txt = (ev.data && ev.data.result) || ""; @@ -1713,13 +1719,15 @@ 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 isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName); + const fresh = isProducer + ? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r)) + : []; fresh.forEach(r => ctx.seenRels.add(r)); - const inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(toolName); - const barHtml = renderArtifactBarHtml(fresh, inlineMedia); + const barHtml = renderArtifactBarHtml(fresh, isProducer); if (barHtml) { asstCard.insertAdjacentHTML("beforeend", barHtml); - if (inlineMedia) upgradeMediaArtifacts(asstCard); + if (isProducer) upgradeMediaArtifacts(asstCard); } scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧 } else if (t === "cancelled") { @@ -2165,14 +2173,15 @@ function _workingDirName(workingDir) { return segs[segs.length - 1] || ""; } -// 产物工具白名单:控制图片/视频是否升级为内联 /