ui(media): chip 解绑产物白名单 — 通用工具 echo 路径也挂 chip,图片/视频 inline 仍只对产物开
renderArtifactBarHtml 加 allowInlineMedia 参,false 时图片/视频也走 .art-chip 按钮(点开仍弹预览 modal);4 处 tool 调用点解绑 ARTIFACT_PRODUCING_TOOLS chip gate,只透传给第二参控制 inline;SSE 两处 upgradeMediaArtifacts 同步 gate 到 if (inlineMedia);assistant 正文默认 true 不变。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
972f36db20
commit
d402c8771c
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
|
||||||
|
|
||||||
最后更新:2026-05-21(loop.py tool message append 补 `name` 字段 + 一次性 backfill 脚本回填历史 17 条 tool 消息 → 修历史 task 重开后 seedream banner/chip 不显示的 bug)
|
最后更新:2026-05-21(dev SPA chip 维度解绑产物白名单 → 通用工具结果里 echo 的路径也挂 chip,但图片/视频 inline 仍只对产物工具开;`renderArtifactBarHtml` 加 `allowInlineMedia` 参数)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
|
|
||||||
### 2026-05-21
|
### 2026-05-21
|
||||||
|
|
||||||
|
- **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(无对外行为变化)。
|
- **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
|
### 2026-05-20
|
||||||
|
|
|
||||||
|
|
@ -1394,13 +1394,12 @@ function renderMessages(msgs) {
|
||||||
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
||||||
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
||||||
const banner = extractMediaBanner(p.name || "", txt || "");
|
const banner = extractMediaBanner(p.name || "", txt || "");
|
||||||
const rels = ARTIFACT_PRODUCING_TOOLS.has(p.name || "")
|
const rels = pickFresh(extractArtifactRels(txt || "", wd));
|
||||||
? pickFresh(extractArtifactRels(txt || "", wd))
|
const inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(p.name || "");
|
||||||
: [];
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
|
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
|
||||||
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
|
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
|
||||||
${renderArtifactBarHtml(rels)}
|
${renderArtifactBarHtml(rels, inlineMedia)}
|
||||||
`;
|
`;
|
||||||
wrap.appendChild(card);
|
wrap.appendChild(card);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1425,12 +1424,11 @@ function renderMessages(msgs) {
|
||||||
try {
|
try {
|
||||||
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
|
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
|
||||||
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
|
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
|
||||||
const rels = ARTIFACT_PRODUCING_TOOLS.has(fn)
|
const rels = pickFresh(extractArtifactRels(args, wd));
|
||||||
? pickFresh(extractArtifactRels(args, wd))
|
const inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(fn);
|
||||||
: [];
|
|
||||||
html += `
|
html += `
|
||||||
<details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
|
<details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
|
||||||
${renderArtifactBarHtml(rels)}
|
${renderArtifactBarHtml(rels, inlineMedia)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1697,14 +1695,13 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
|
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
|
||||||
asstCard.appendChild(det);
|
asstCard.appendChild(det);
|
||||||
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
||||||
const fresh = ARTIFACT_PRODUCING_TOOLS.has(fn)
|
const fresh = extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r));
|
||||||
? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r))
|
|
||||||
: [];
|
|
||||||
fresh.forEach(r => ctx.seenRels.add(r));
|
fresh.forEach(r => ctx.seenRels.add(r));
|
||||||
const barHtml = renderArtifactBarHtml(fresh);
|
const inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(fn);
|
||||||
|
const barHtml = renderArtifactBarHtml(fresh, inlineMedia);
|
||||||
if (barHtml) {
|
if (barHtml) {
|
||||||
asstCard.insertAdjacentHTML("beforeend", barHtml);
|
asstCard.insertAdjacentHTML("beforeend", barHtml);
|
||||||
upgradeMediaArtifacts(asstCard);
|
if (inlineMedia) upgradeMediaArtifacts(asstCard);
|
||||||
}
|
}
|
||||||
} else if (t === "tool_result") {
|
} else if (t === "tool_result") {
|
||||||
const txt = (ev.data && ev.data.result) || "";
|
const txt = (ev.data && ev.data.result) || "";
|
||||||
|
|
@ -1716,14 +1713,13 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
|
det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
|
||||||
asstCard.appendChild(det);
|
asstCard.appendChild(det);
|
||||||
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
||||||
const fresh = ARTIFACT_PRODUCING_TOOLS.has(toolName)
|
const fresh = extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r));
|
||||||
? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r))
|
|
||||||
: [];
|
|
||||||
fresh.forEach(r => ctx.seenRels.add(r));
|
fresh.forEach(r => ctx.seenRels.add(r));
|
||||||
const barHtml = renderArtifactBarHtml(fresh);
|
const inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(toolName);
|
||||||
|
const barHtml = renderArtifactBarHtml(fresh, inlineMedia);
|
||||||
if (barHtml) {
|
if (barHtml) {
|
||||||
asstCard.insertAdjacentHTML("beforeend", barHtml);
|
asstCard.insertAdjacentHTML("beforeend", barHtml);
|
||||||
upgradeMediaArtifacts(asstCard);
|
if (inlineMedia) upgradeMediaArtifacts(asstCard);
|
||||||
}
|
}
|
||||||
scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
|
scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
|
||||||
} else if (t === "cancelled") {
|
} else if (t === "cancelled") {
|
||||||
|
|
@ -2169,13 +2165,14 @@ function _workingDirName(workingDir) {
|
||||||
return segs[segs.length - 1] || "";
|
return segs[segs.length - 1] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 产物工具白名单:只有这些工具的 tool_call args / tool_result 里 echo 的 <wd>/...
|
// 产物工具白名单:控制图片/视频是否升级为内联 <img>/<video> 预览。其它
|
||||||
// 路径才挂 chip(图片/视频 inline 预览,其它类型为可点按钮)。通用工具
|
// 类型(pdf/docx/zip/.py/.md...)以及非产物工具的图片/视频路径,仍走可点
|
||||||
// (grep/read/shell/glob 等)的 I/O 里出现的路径是引用而非新产物,放开会把命中
|
// chip 按钮(点开弹预览 modal)—— 即"路径都挂 chip,但只有产物工具才占
|
||||||
// 的无关老文件全展示成"产物"。assistant 正文不受此限 —— 助手主动提到的路径仍
|
// 屏 inline 大图"。chip 自身不受白名单限制,这样 grep/read/shell/glob 等
|
||||||
// 挂 chip,seenRels 兜底防同文件重复。
|
// 通用工具结果里 echo 的路径也是可点的(避免"看到路径但不能直接点开"
|
||||||
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增
|
// 的反直觉),只是不会把无关老图自动 inline 出来占整屏。
|
||||||
// "生成 docx 的工具",入这里但不入 banner 白名单。
|
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若
|
||||||
|
// 新增"生成 docx 的工具",入这里但不入 banner 白名单。
|
||||||
const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
|
const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
|
||||||
|
|
||||||
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
|
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
|
||||||
|
|
@ -2233,12 +2230,16 @@ function extractArtifactRels(text, workingDir) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderArtifactBarHtml(rels) {
|
// allowInlineMedia 控制图片/视频是否升级为内联 <img>/<video>:产物工具
|
||||||
|
// (seedream/seedance)+ assistant 正文传 true,通用工具(grep/read/shell/glob)
|
||||||
|
// 结果里 echo 的路径传 false → 图片/视频也走 chip 按钮(点开仍弹预览 modal),
|
||||||
|
// 这样既不会把无关老图占整屏,又保留"路径可点"的可发现性。
|
||||||
|
function renderArtifactBarHtml(rels, allowInlineMedia = true) {
|
||||||
if (!rels || !rels.length) return "";
|
if (!rels || !rels.length) return "";
|
||||||
const items = rels.map((rel) => {
|
const items = rels.map((rel) => {
|
||||||
const name = rel.split("/").pop() || rel;
|
const name = rel.split("/").pop() || rel;
|
||||||
const cat = _categorize(rel);
|
const cat = _categorize(rel);
|
||||||
if (cat === "image" || cat === "video") {
|
if (allowInlineMedia && (cat === "image" || cat === "video")) {
|
||||||
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
|
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
|
||||||
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
|
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
|
||||||
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
|
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue