zcbot/web/static/js/media.js

354 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 对话内的工具活动标签 + artifact(产物)抽取与渲染:
// toolActivityLabel(工具调用→中文活动名)、extractArtifactRels(从结果文本/working_dir 提产物相对路径)、
// extractMediaBanner(seedream/seedance 媒体横幅)、renderArtifactBarHtml(产物 chip 条,图/视频/HTML 内联占位)、
// upgradeMediaArtifacts(可视区内异步填 <img>/<video>/<iframe>,带缓存)、downloadFile(blob 下载)。
// 供 main 的 renderMessages 及 preview/files(downloadFile)使用。
import { state } from "./state.js";
import { escapeHtml } from "./format.js";
import { _categorize } from "./preview.js";
import { configureHtmlPreviewFrame } from "./preview_content.js";
import { logout } from "./auth.js";
import { message } from "./dialog.js";
// ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ─────
// task.working_dir 在 DB 是 `workspace/users/<uuid>/<name>` 形态(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 "rename_working_dir": return `重命名工作目录: ${clip(a.new_name, 60)}`;
case "publish_artifacts": return `发布最终产物: ${clip(JSON.stringify(a.artifacts || []), 80)}`;
case "office_to_pdf": return `转换 PDF: ${clip(a.source, 80)}`;
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
case "gpt_image": 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", "gpt_image", "seedance"]);
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
// 从 seedream/seedance tool_result 第一行 banner 抽 model/size/cost/elapsed,
// 拼一行 .tool-banner HTML 挂在 details summary 旁。匹配失败返 ""(不渲染)。
// 协议:tool 返回串首行格式 `[<tool>] key=value · key=value · ...`
export function extractMediaBanner(toolName, resultText) {
if (!resultText) return "";
if (toolName !== "seedream" && toolName !== "gpt_image" && 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(`<span class="kv model">${escapeHtml(model)}</span>`);
if (kvs.size) parts.push(`<span class="kv">${escapeHtml(kvs.size)}</span>`);
if (kvs.cost) parts.push(`<span class="kv cost">${escapeHtml(kvs.cost)}</span>`);
if (kvs.elapsed) parts.push(`<span class="kv">${escapeHtml(kvs.elapsed)}</span>`);
return parts.length ? `<span class="tool-banner">${parts.join("")}</span>` : "";
}
// 路径 tail 排除集:空白/引号/括号类 + 常见中文标点 —— 中文正文里路径后常紧贴
// ",请查看"这类标点(无空格),不排除会把标点连后文一起吞进文件名,生成点开 404
// 的坏 chip。文件名几乎不含这些中文标点,排除的误伤面≈0。
// 注:间隔号 `·`(U+00B7)**不**排除 —— 它是文件名内部的合法分隔符(产品名
// "AI赋能·xxx.pptx" 常见),几乎不当句尾标点用;排除它会在 `·` 处截断路径,把
// 真产物 chip 吞没(踩过:含 `·` 的 pptx 不挂 chip)。尾部标点另有 strip 兜底。
// `*` 排除:助手常把路径写成 markdown 粗体 `**<wd>/x.md**`,前导 `**` 已被 lead
// 边界吃掉,但尾部 `**` 会被贪婪吞进 rel(星号既不在本集也不在尾部 strip 集),
// 生成 `x.md**` 这种点开 404 的坏 chip(踩过:task d9c74a5a)。文件名含 `*` 在
// Windows 非法、实际几乎不存在,排除误伤≈0,同反引号一样在 emphasis 处干净截断。
const _TAIL_CLS = "[^\\s\"'`*<>(){}\\[\\]|,。、;:!?…“”‘’《》〈〉【】()「」『』]+";
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 = [];
// 规范形式:<wdName>/<...>/<file>.<ext> —— 当前协议(system prompt 强约束助手照抄 tool `saved:` 行)
// lead 边界:行首或任意非 [A-Za-z0-9._-] 字符 —— 汉字也算边界(中文正文"已保存
// 到gpt生图/xxx.png"路径前紧贴汉字是常态),同时仍防 "mygpt生图/..." 前缀误挂;
// tail 截到空白/引号/括号/中文标点(见 _TAIL_CLS)
{
const re = new RegExp(
"(?:^|[^A-Za-z0-9._-])(" + wdEsc + "/" + _TAIL_CLS + ")",
"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
// <wdName> 把它们拼成 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 边界跟主规则一致但额外排 `/` —— 否则 <wd>/videos/xxx 里的 videos/xxx 会被重复
// 匹配(虽然 seen 去重,但浪费 cycles)
const re = new RegExp(
"(?:^|[^A-Za-z0-9._/-])(" + dirEsc + "/" + _TAIL_CLS + ")",
"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;
}
// 文件 chip 的内容结构保持纯前端生成:扩展名充当轻量类型图标,不额外请求
// 文件元数据;长文件名仍由 CSS 单行省略,完整路径保留在 button title 中。
export function renderArtifactChipContent(name, rel) {
const rawExt = String(rel || "").split("/").pop().split(".").pop();
const hasExt = rawExt && rawExt !== String(rel || "").split("/").pop();
const ext = hasExt ? rawExt.toUpperCase().slice(0, 4) : "FILE";
return `<span class="art-chip-icon" aria-hidden="true">${escapeHtml(ext)}</span>`
+ `<span class="art-chip-name">${escapeHtml(name)}</span>`
+ `<span class="art-chip-open">预览</span>`;
}
// inlineMode 控制升级范围:true=图片/视频/HTML"html"=仅 HTMLfalse=全走 chip。
// 产物工具传 trueassistant 正文传 "html",避免重复内联图片/视频但让通用工具生成的
// HTML 有展示位;普通工具结果传 false引用到的文件仍走 chip避免把旧产物铺满消息。
export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", legacy = false) {
if (!rels || !rels.length) return "";
const taskAttr = taskId ? ` data-task-id="${escapeHtml(taskId)}"` : "";
const legacyAttr = legacy ? ` data-legacy-path="1"` : "";
const items = rels.map((item) => {
const ref = (item && typeof item === "object") ? item : { path: item };
const rel = String(ref.path || "");
if (!rel) return "";
const name = String(ref.label || rel.split("/").pop() || rel);
const cat = _categorize(rel);
if ((inlineMode === true || inlineMode === "html") && cat === "html") {
return `<section class="art-html" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)}">
<div class="art-html-head"><span>${escapeHtml(name)}</span><button type="button" class="art-html-open" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}>放大</button></div>
<div class="art-html-viewport"><span class="art-media-loading">进入可视区域后加载…</span></div>
</section>`;
}
if (inlineMode === true && (cat === "image" || cat === "video")) {
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
}
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${renderArtifactChipContent(name, rel)}</button>`;
}).join("");
return `<div class="artifact-bar">${items}</div>`;
}
// rel → Promise<blob-url>。auth 是 Bearer header,不能直接 <img src=>,只能 fetch
// 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时
// _flushMediaArtifactCache 清掉旧 URL 防泄漏。
const _mediaArtifactCache = new Map();
const _htmlArtifactCache = new Map();
const INLINE_HTML_MAX = 2 * 1024 * 1024;
function _artifactDownloadUrl(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
function _fetchMediaBlobUrl(rel, taskId = "", legacy = false) {
const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
if (_mediaArtifactCache.has(key)) return _mediaArtifactCache.get(key);
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
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(key, p);
return p;
}
function _fetchHtmlArtifact(rel, taskId = "", legacy = false) {
const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
if (_htmlArtifactCache.has(key)) return _htmlArtifactCache.get(key);
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
if (blob.size > INLINE_HTML_MAX) throw new Error("文件过大,请点击放大预览");
return blob.text();
});
_htmlArtifactCache.set(key, p);
return p;
}
let _htmlArtifactObserver = null;
function _loadInlineHtml(node) {
if (!node || node.dataset.loaded) return;
node.dataset.loaded = "1";
const rel = node.dataset.rel;
const taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const viewport = node.querySelector(".art-html-viewport");
_fetchHtmlArtifact(rel, taskId, legacy).then((source) => {
if (!node.isConnected || !viewport) return;
viewport.innerHTML = "";
const frame = document.createElement("iframe");
frame.className = "art-html-frame";
configureHtmlPreviewFrame(frame, source, (rel.split("/").pop() || rel) + " 内嵌预览");
viewport.appendChild(frame);
}).catch((e) => {
if (!viewport) return;
viewport.innerHTML = `<span class="art-media-error">${escapeHtml(e.message || String(e))}</span>`;
});
}
function _observeInlineHtml(node) {
if (typeof IntersectionObserver !== "function") {
_loadInlineHtml(node);
return;
}
if (!_htmlArtifactObserver) {
_htmlArtifactObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
_htmlArtifactObserver.unobserve(entry.target);
_loadInlineHtml(entry.target);
}
}, { rootMargin: "240px 0px" });
}
_htmlArtifactObserver.observe(node);
}
export function _flushMediaArtifactCache() {
for (const p of _mediaArtifactCache.values()) {
p.then((u) => URL.revokeObjectURL(u)).catch(() => {});
}
_mediaArtifactCache.clear();
_htmlArtifactCache.clear();
if (_htmlArtifactObserver) _htmlArtifactObserver.disconnect();
_htmlArtifactObserver = null;
}
// DOM walk:把所有 .art-media[data-rel] 占位换成 <img> / <video>。
// 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 taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const cat = node.dataset.cat;
_fetchMediaBlobUrl(rel, taskId, legacy).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 = `<span class="art-media-error">${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}</span>`;
});
});
const htmlNodes = (root || document).querySelectorAll(".art-html[data-rel]:not([data-upgraded])");
htmlNodes.forEach((node) => {
node.dataset.upgraded = "1";
_observeInlineHtml(node);
});
}
export function downloadFile(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
fetch(base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : ""), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
if (!r.ok) { message("下载失败:" + r.status, "error"); 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);
});
}