// 对话内的工具活动标签 + artifact(产物)抽取与渲染: // toolActivityLabel(工具调用→中文活动名)、extractArtifactRels(从结果文本/working_dir 提产物相对路径)、 // extractMediaBanner(seedream/seedance 媒体横幅)、renderArtifactBarHtml(产物 chip 条,图/视频内联占位)、 // upgradeMediaArtifacts(占位异步 fetch blob 填 /,带缓存)、downloadFile(blob 下载)。 // 供 main 的 renderMessages 及 preview/files(downloadFile)使用。 import { state } from "./state.js"; import { escapeHtml } from "./format.js"; import { _categorize } from "./preview.js"; import { logout } from "./auth.js"; // ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ───── // task.working_dir 在 DB 是 `workspace/users//` 形态(to_db_path), // 不是 user_root 相对。这里取最后一段作为 chip 抽取锚点 —— 等价于 user_root 下 // 一级子目录名(同 filesPath 的 wdName 语义)。外部 --working-dir 是绝对路径, // 文件不在 user_root,backend files API 拒访问 → 不挂 chip。 // 把一次 tool_call 翻成一句中文活动描述(展示在折叠标题行,不展开就能看懂在干啥)。 // args 是后端 _execute_tool_call 解析后的参数 dict;取每个工具最能代表"在干啥"的字段。 export function toolActivityLabel(name, args) { const a = (args && typeof args === "object") ? args : {}; const clip = (v, n) => { const s = String(v == null ? "" : v).replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n) + "…" : s; }; switch (name) { case "read": return `读取文件: ${clip(a.path, 80)}`; case "write": return `写入文件: ${clip(a.path, 80)}`; case "edit": return `编辑文件: ${clip(a.path, 80)}`; case "glob": return `查找文件: ${clip(a.pattern, 60)}`; case "grep": return `搜索内容: ${clip(a.pattern, 60)}`; case "shell": return `执行命令: ${clip(a.command, 80)}`; case "run_python": return `运行 Python: ${clip(a.code, 80)}`; case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`; case "web_search": return `联网搜索: ${clip(a.query, 60)}`; case "load_skill": return `加载技能: ${clip(a.name, 40)}`; case "seedream": return `生成图像: ${clip(a.prompt, 60)}`; case "seedance": return `生成视频: ${clip(a.prompt, 60)}`; default: { const p = clip(JSON.stringify(a), 80); return p && p !== "{}" ? `${name} ${p}` : `工具调用: ${name}`; } } } export function _workingDirName(workingDir) { if (!workingDir) return ""; const wd = String(workingDir).replace(/\\+/g, "/"); if (wd.startsWith("/") || /^[A-Za-z]:/.test(wd)) return ""; // 绝对 = 外部目录,跳过 const segs = wd.split("/").filter(Boolean); return segs[segs.length - 1] || ""; } // 产物工具白名单:**工具 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 白名单。 export 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, // 拼一行 .tool-banner HTML 挂在 details summary 旁。匹配失败返 ""(不渲染)。 // 协议:tool 返回串首行格式 `[] key=value · key=value · ...` export function extractMediaBanner(toolName, resultText) { if (!resultText) return ""; if (toolName !== "seedream" && toolName !== "seedance") return ""; const firstLine = String(resultText).split("\n", 1)[0] || ""; // 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束) const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g; const kvs = {}; let m; while ((m = re.exec(firstLine)) !== null) { kvs[m[1]] = m[2].trim(); } if (!kvs.model && !kvs.cost) return ""; // model 文本太长(`doubao-seedream-5-0-260128`)→ 截短易读形式 const model = (kvs.model || "").replace(/^doubao-/, "").replace(/-\d{6,}$/, ""); const parts = []; if (model) parts.push(`${escapeHtml(model)}`); if (kvs.size) parts.push(`${escapeHtml(kvs.size)}`); if (kvs.cost) parts.push(`${escapeHtml(kvs.cost)}`); if (kvs.elapsed) parts.push(`${escapeHtml(kvs.elapsed)}`); return parts.length ? `${parts.join("")}` : ""; } export function extractArtifactRels(text, workingDir) { if (!text || !workingDir) return []; const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, ""); if (!wd) return []; const norm = String(text).replace(/\\+/g, "/"); const wdEsc = wd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const seen = new Set(); const out = []; // 规范形式:/<...>/. —— 当前协议(system prompt 强约束助手照抄 tool `saved:` 行) // lead 边界:行首或非 path-字符;tail 截到空白/引号/括号等 { const re = new RegExp( "(?:^|[\\s\"'`/=:,()<>\\[\\]{}|])(" + wdEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)", "g" ); let m; while ((m = re.exec(norm)) !== null) { let rel = m[1]; rel = rel.replace(/[.,;:!?)\]}>。,;:!?)]+$/, ""); // 剥尾标点(中英) const tail = rel.slice(wd.length + 1); if (!tail) continue; const last = tail.split("/").pop() || ""; if (!last.includes(".")) continue; // 看着像目录的不挂 chip if (seen.has(rel)) continue; seen.add(rel); out.push(rel); } } // ───── 一次性兼容:协议刚性化前的历史简写消息 ───── // system prompt 改硬约束之前,助手按 SKILL 旧文案 echo 过 `videos/xxx.mp4` / // `figures/xxx.png` 这种裸形式 —— 那些消息已存 DB 改不动,前端这里 prepend // 把它们拼成 user_root rel 才能挂 chip。**白名单显式枚举不扩展**: // 新产物 skill 走 system 协议必出全形式,这一层只服务**历史消息**渲染。 // 长期(老消息归档/不再回看)整段可删。 const LEGACY_PRODUCT_DIRS = ["videos", "figures"]; for (const dir of LEGACY_PRODUCT_DIRS) { const dirEsc = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // lead 边界跟主规则一致但去掉 `/` —— 否则 /videos/xxx 里的 videos/xxx 会被重复 // 匹配(虽然 seen 去重,但浪费 cycles) const re = new RegExp( "(?:^|[\\s\"'`=:,()<>\\[\\]{}|])(" + dirEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)", "g" ); let m; while ((m = re.exec(norm)) !== null) { let tail = m[1]; tail = tail.replace(/[.,;:!?)\]}>。,;:!?)]+$/, ""); const last = tail.split("/").pop() || ""; if (!last.includes(".")) continue; const rel = wd + "/" + tail; if (seen.has(rel)) continue; seen.add(rel); out.push(rel); } } return out; } // allowInlineMedia 控制图片/视频是否升级为内联 /:产物工具 // (seedream/seedance)+ assistant 正文传 true,通用工具(grep/read/shell/glob) // 结果里 echo 的路径传 false → 图片/视频也走 chip 按钮(点开仍弹预览 modal), // 这样既不会把无关老图占整屏,又保留"路径可点"的可发现性。 export function renderArtifactBarHtml(rels, allowInlineMedia = true) { if (!rels || !rels.length) return ""; const items = rels.map((rel) => { const name = rel.split("/").pop() || rel; const cat = _categorize(rel); if (allowInlineMedia && (cat === "image" || cat === "video")) { // 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 /。 // 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。 return `${escapeHtml(name)} 加载中…`; } return `${escapeHtml(name)}`; }).join(""); return `${items}`; } // rel → Promise。auth 是 Bearer header,不能直接 ,只能 fetch // 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时 // _flushMediaArtifactCache 清掉旧 URL 防泄漏。 const _mediaArtifactCache = new Map(); function _fetchMediaBlobUrl(rel) { if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel); const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), { headers: { "Authorization": "Bearer " + state.token }, }).then(async (r) => { if (!r.ok) throw new Error("HTTP " + r.status); const blob = await r.blob(); return URL.createObjectURL(blob); }); _mediaArtifactCache.set(rel, p); return p; } export function _flushMediaArtifactCache() { for (const p of _mediaArtifactCache.values()) { p.then((u) => URL.revokeObjectURL(u)).catch(() => {}); } _mediaArtifactCache.clear(); } // DOM walk:把所有 .art-media[data-rel] 占位换成 / 。 // renderMessages / SSE 插入完后调一次;重复调用幂等(已 upgrade 过的 set data-upgraded 跳过)。 export function upgradeMediaArtifacts(root) { const nodes = (root || document).querySelectorAll(".art-media[data-rel]:not([data-upgraded])"); nodes.forEach((node) => { node.dataset.upgraded = "1"; const rel = node.dataset.rel; const cat = node.dataset.cat; _fetchMediaBlobUrl(rel).then((url) => { node.innerHTML = ""; if (cat === "image") { const img = document.createElement("img"); img.src = url; img.alt = rel.split("/").pop() || rel; img.loading = "lazy"; // 浏览器懒解码(已在 viewport 内立即可见,远处暂不解) node.appendChild(img); } else if (cat === "video") { const v = document.createElement("video"); v.src = url; v.controls = true; v.preload = "metadata"; node.appendChild(v); } }).catch((e) => { node.innerHTML = `${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}`; }); }); } export function downloadFile(rel) { fetch("/v1/files/download?path=" + encodeURIComponent(rel), { headers: { "Authorization": "Bearer " + state.token }, }).then(async (r) => { if (!r.ok) { alert("下载失败:" + r.status); return; } const blob = await r.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = rel.split("/").pop() || "file"; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); }); }