ui(media): chip 三规则定型 — 工具 I/O 走产物白名单 + 助手正文无条件挂 chip 绕开 seenRels
修截图反馈"助手回复 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) <noreply@anthropic.com>
This commit is contained in:
parent
d402c8771c
commit
5f0f296a23
|
|
@ -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(无对外行为变化)。
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
|
||||
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
|
||||
${renderArtifactBarHtml(rels, inlineMedia)}
|
||||
${renderArtifactBarHtml(rels, isProducer)}
|
||||
`;
|
||||
wrap.appendChild(card);
|
||||
continue;
|
||||
|
|
@ -1410,10 +1412,12 @@ function renderMessages(msgs) {
|
|||
let html = `<div class="role">${roleLabel}</div>`;
|
||||
if (typeof p.content === "string" && p.content) {
|
||||
html += `<div class="body">${renderMd(p.content)}</div>`;
|
||||
// assistant 正文里 echo 的 <wd>/... 路径同样挂 chip 条(只对 assistant,user 输入不抽)
|
||||
// assistant 正文里 echo 的 <wd>/... 路径**永远**挂 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 += `
|
||||
<details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
|
||||
${renderArtifactBarHtml(rels, inlineMedia)}
|
||||
${renderArtifactBarHtml(rels, isProducer)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1695,13 +1699,15 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
|
||||
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 = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
|
||||
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] || "";
|
||||
}
|
||||
|
||||
// 产物工具白名单:控制图片/视频是否升级为内联 <img>/<video> 预览。其它
|
||||
// 类型(pdf/docx/zip/.py/.md...)以及非产物工具的图片/视频路径,仍走可点
|
||||
// chip 按钮(点开弹预览 modal)—— 即"路径都挂 chip,但只有产物工具才占
|
||||
// 屏 inline 大图"。chip 自身不受白名单限制,这样 grep/read/shell/glob 等
|
||||
// 通用工具结果里 echo 的路径也是可点的(避免"看到路径但不能直接点开"
|
||||
// 的反直觉),只是不会把无关老图自动 inline 出来占整屏。
|
||||
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若
|
||||
// 新增"生成 docx 的工具",入这里但不入 banner 白名单。
|
||||
// 产物工具白名单:**工具 I/O** 维度,只有这些工具的 tool_call args / tool_result
|
||||
// 里 echo 的路径才挂 chip 条 + 图片/视频 inline 大图;通用工具(grep/read/glob/
|
||||
// shell)echo 的路径是"引用"不是"产物",完全不挂(避免把 grep 命中的老 figures/
|
||||
// foo.png 当新产物展示)。**assistant 正文不受此限** —— 助手回复里任何 echo 的
|
||||
// 路径无条件挂 chip(`allowInlineMedia=false`,只 chip 不 inline,跟上面 tool 结果
|
||||
// 可能已 inline 的同图不冲突);用户视角"助手提到的文件理应能点开",chip 是
|
||||
// 可发现性入口,小图标无视觉污染。
|
||||
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增
|
||||
// "生成 docx 的工具",入这里但不入 banner 白名单。
|
||||
const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
|
||||
|
||||
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
|
||||
|
|
|
|||
Loading…
Reference in New Issue