271 lines
9.5 KiB
JavaScript
271 lines
9.5 KiB
JavaScript
// utils.js -- 纯工具函数
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function clamp(n, min, max) {
|
||
return Math.max(min, Math.min(max, n));
|
||
}
|
||
|
||
function formatAudioDuration(seconds) {
|
||
const n = Number(seconds);
|
||
if (!Number.isFinite(n) || n <= 0) return "";
|
||
if (n < 10) return `${n.toFixed(1)}″`;
|
||
return `${Math.round(n)}″`;
|
||
}
|
||
|
||
function getAudioBubbleWidth(seconds) {
|
||
const n = Number(seconds);
|
||
if (!Number.isFinite(n) || n <= 0) return null;
|
||
const width = 132 + Math.sqrt(n) * 46;
|
||
return `${Math.round(clamp(width, 148, 312))}px`;
|
||
}
|
||
|
||
function inferUploadKind(file) {
|
||
const name = String(file?.name || "").toLowerCase();
|
||
const type = String(file?.type || "").toLowerCase();
|
||
const filePath = String(file?.path || "").toLowerCase();
|
||
if (type.startsWith("folder/")) return "folder";
|
||
// SVG 不作为图片处理(按普通文本文件渲染);排除 image/svg+xml
|
||
const isSvg = /\.svg$/.test(name) || /\.svg$/.test(filePath) || type.indexOf("svg") >= 0;
|
||
if (!isSvg && (type.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|ico)$/.test(name) || /\.(png|jpe?g|gif|webp|bmp|ico)$/.test(filePath))) return "image";
|
||
if (type.startsWith("audio/") || /\.(wav|mp3|m4a|flac|ogg|webm|aac)$/.test(name)) return "audio";
|
||
return "file";
|
||
}
|
||
|
||
function getTextFromContent(content) {
|
||
if (typeof content === "string") return content;
|
||
if (!Array.isArray(content)) return "";
|
||
return content
|
||
.filter((part) => part && part.type === "text" && typeof part.text === "string")
|
||
.map((part) => part.text)
|
||
.join("\n")
|
||
.trim();
|
||
}
|
||
|
||
function getMediaParts(content) {
|
||
if (!Array.isArray(content)) return [];
|
||
return content.filter((part) => part && part.type !== "text");
|
||
}
|
||
|
||
function isAudioOnlyAssistantMessage(msg) {
|
||
if (!msg || msg.role !== "assistant") return false;
|
||
const text = getTextFromContent(msg.content);
|
||
const mediaParts = getMediaParts(msg.content);
|
||
const hasOnlyAudioMedia = mediaParts.length > 0 && mediaParts.every((part) => part.type === "audio" && part.url);
|
||
return hasOnlyAudioMedia && !text;
|
||
}
|
||
|
||
function buildOptimisticUserContent(text, fileParts) {
|
||
const parts = [];
|
||
(fileParts || []).forEach((filePart) => {
|
||
if (filePart && filePart.kind === "image" && filePart.url) {
|
||
parts.push({ type: "image", url: filePart.url, name: filePart.name || "图片" });
|
||
} else if (filePart && filePart.kind === "audio" && filePart.url) {
|
||
parts.push({ type: "audio", url: filePart.url, name: filePart.name || "音频" });
|
||
} else if (filePart && filePart.kind === "folder") {
|
||
parts.push({ type: "folder", name: filePart.name || "文件夹" });
|
||
} else if (filePart && filePart.name) {
|
||
parts.push({ type: "file", name: filePart.name });
|
||
}
|
||
});
|
||
const t = (text || "").trim();
|
||
if (t) parts.push({ type: "text", text: t });
|
||
if (parts.length === 0) return null;
|
||
if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
|
||
return parts;
|
||
}
|
||
|
||
function revokeBlobUrlStack(urlStack) {
|
||
(urlStack || []).forEach((u) => {
|
||
try { URL.revokeObjectURL(u); } catch { /* ignore */ }
|
||
});
|
||
urlStack.length = 0;
|
||
}
|
||
|
||
function makeId() {
|
||
return Math.random().toString(36).substring(2, 10);
|
||
}
|
||
|
||
function codeBlockNode(lang, code) {
|
||
const raw = code.replace(/\n$/, "");
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "code-block-wrap";
|
||
const head = document.createElement("div");
|
||
head.className = "code-block-head";
|
||
const langSpan = document.createElement("span");
|
||
langSpan.className = "code-block-lang";
|
||
langSpan.textContent = (lang && lang.trim()) || "text";
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "code-block-copy";
|
||
btn.textContent = "复制全部";
|
||
btn.addEventListener("click", () => {
|
||
navigator.clipboard.writeText(raw).catch(() => {});
|
||
});
|
||
head.appendChild(langSpan);
|
||
head.appendChild(btn);
|
||
const pre = document.createElement("pre");
|
||
const codeEl = document.createElement("code");
|
||
codeEl.textContent = raw;
|
||
pre.appendChild(codeEl);
|
||
wrap.appendChild(head);
|
||
wrap.appendChild(pre);
|
||
return wrap;
|
||
}
|
||
|
||
/**
|
||
* 显示一个 3 秒自动消失的浮窗通知。
|
||
* @param {string} text - 通知文字。
|
||
*/
|
||
function showToast(text) {
|
||
const container = document.getElementById("toastContainer");
|
||
if (!container) return;
|
||
const item = document.createElement("div");
|
||
item.className = "toast-item";
|
||
item.textContent = text;
|
||
container.appendChild(item);
|
||
setTimeout(() => {
|
||
if (item.parentNode) item.parentNode.removeChild(item);
|
||
}, 3100);
|
||
}
|
||
|
||
/**
|
||
* 为 pendingFiles 去重:
|
||
* - 普通 File 对象:按 name + size + lastModified 比较
|
||
* - __isRef 引用:按 refPath 比较
|
||
* - 文件夹占位:按 name + type 比较
|
||
*/
|
||
function dedupePendingFiles(files) {
|
||
const seen = new Set();
|
||
return files.filter((f) => {
|
||
let key;
|
||
if (f.__isRef) {
|
||
// 包含 type 以区分 ref/file、ref/image、ref/folder
|
||
key = 'ref:' + (f.type || '') + ':' + (f.refPath || '');
|
||
} else if (f.type && f.type.startsWith('folder/')) {
|
||
key = 'folder:' + (f.name || '');
|
||
} else if (f instanceof File) {
|
||
key = (f.name || '') + '|' + (f.size || 0);
|
||
} else {
|
||
key = 'other:' + JSON.stringify(f);
|
||
}
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 通用连击保护:在 timeWindow 毫秒内忽略重复触发。
|
||
* 用于按钮/选项等一次性交互,防止双击导致重复请求或重复弹窗。
|
||
* @param {Function} fn - 要执行的函数(可用 async)
|
||
* @param {number} ms - 保护时间窗(毫秒),默认 400
|
||
* @returns {Function} 包裹后的函数
|
||
*/
|
||
function withClickGuard(fn, ms = 400) {
|
||
let lastAt = 0;
|
||
let running = false;
|
||
return function (...args) {
|
||
const now = Date.now();
|
||
if (running || now - lastAt < ms) return;
|
||
lastAt = now;
|
||
const result = fn.apply(this, args);
|
||
if (result && typeof result.then === "function") {
|
||
running = true;
|
||
return result.finally(() => { running = false; });
|
||
}
|
||
return result;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 关闭除 keepEl 外的所有内联下拉面板(.inline-drop-panel),保证同时只开一个。
|
||
* @param {HTMLElement|null} keepEl - 保持打开的面板元素
|
||
*/
|
||
function closeInlinePanelsExcept(keepEl) {
|
||
document.querySelectorAll(".inline-drop-panel").forEach((p) => {
|
||
if (p === keepEl) return;
|
||
p.hidden = true;
|
||
if (p._trigger) p._trigger.classList.remove("is-open");
|
||
});
|
||
}
|
||
|
||
// ===== 附件图标分类(按扩展名映射) =====
|
||
// 说明:vedio 图标适用于音频文件(视频暂不支持);所有 UTF-8 类文本文件统一定义为 plaintext
|
||
const FILE_ICON_EXCEL = ["xls", "xlsx", "xlsm", "xltx", "csv"];
|
||
const FILE_ICON_WORD = ["doc", "docx", "docm", "dot", "dotx", "rtf"];
|
||
const FILE_ICON_PDF = ["pdf"];
|
||
const FILE_ICON_ARCHIVE = ["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "zst"];
|
||
const FILE_ICON_AUDIO = ["mp3", "wav", "flac", "aac", "ogg", "oga", "m4a", "wma", "opus", "amr", "ape", "mid", "midi"];
|
||
const FILE_ICON_TEXT = [
|
||
"txt", "md", "markdown", "json", "js", "mjs", "cjs", "ts", "tsx", "jsx", "vue", "svelte",
|
||
"py", "pyw", "html", "htm", "css", "scss", "less", "sass", "xml", "yaml", "yml", "toml",
|
||
"ini", "cfg", "conf", "log", "env", "sh", "bat", "cmd", "ps1", "sql", "r", "go", "rs", "rb",
|
||
"php", "swift", "kt", "scala", "cpp", "cc", "c", "h", "hpp", "java", "m", "svg",
|
||
];
|
||
|
||
/** 按文件名扩展名返回附件分类图标路径;未知类型(含视频)统一回退文本图标 */
|
||
function getFileIcon(name) {
|
||
const dot = String(name || "").lastIndexOf(".");
|
||
const ext = (dot >= 0 ? String(name).slice(dot + 1) : "").toLowerCase();
|
||
if (FILE_ICON_EXCEL.includes(ext)) return "/image/Excel.svg";
|
||
if (FILE_ICON_WORD.includes(ext)) return "/image/word.svg";
|
||
if (FILE_ICON_PDF.includes(ext)) return "/image/pdf.svg";
|
||
if (FILE_ICON_ARCHIVE.includes(ext)) return "/image/zip.svg";
|
||
if (FILE_ICON_AUDIO.includes(ext)) return "/image/vedio.svg";
|
||
return "/image/文本文件.svg";
|
||
}
|
||
|
||
// ===== 不支持上传/解读的文件类型 =====
|
||
// 视频:暂不支持;旧版 .doc / .rtf:无解析器,agent 无法解读
|
||
const UNSUPPORTED_FILE_EXTENSIONS = [
|
||
// 视频
|
||
"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg",
|
||
"ts", "m2ts", "mts", "3gp", "3g2", "rm", "rmvb", "vob",
|
||
// 旧版 Word / RTF
|
||
"doc", "rtf",
|
||
];
|
||
|
||
/** 判断文件是否为不支持的类型(视频 / 旧版 .doc / .rtf) */
|
||
function isUnsupportedFile(name) {
|
||
const dot = String(name || "").lastIndexOf(".");
|
||
const ext = (dot >= 0 ? String(name).slice(dot + 1) : "").toLowerCase();
|
||
return UNSUPPORTED_FILE_EXTENSIONS.includes(ext);
|
||
}
|
||
|
||
/** 过滤不支持的文件(视频 / 旧版 .doc / .rtf):返回过滤后的文件数组;有被拒文件时浮窗提示 */
|
||
function filterOutUnsupported(files) {
|
||
const list = Array.isArray(files) ? files : [];
|
||
const accepted = list.filter((f) => !isUnsupportedFile(f && (f.name || "")));
|
||
const rejected = list.length - accepted.length;
|
||
if (rejected > 0) {
|
||
showToast(`暂不支持 ${rejected} 个文件(视频 / 旧版 .doc / .rtf),已忽略`);
|
||
}
|
||
return accepted;
|
||
}
|
||
|
||
export {
|
||
$,
|
||
escapeHtml,
|
||
clamp, formatAudioDuration, getAudioBubbleWidth,
|
||
inferUploadKind,
|
||
getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage,
|
||
buildOptimisticUserContent, revokeBlobUrlStack,
|
||
makeId, codeBlockNode,
|
||
showToast,
|
||
dedupePendingFiles,
|
||
withClickGuard,
|
||
closeInlinePanelsExcept,
|
||
getFileIcon,
|
||
isUnsupportedFile,
|
||
filterOutUnsupported,
|
||
};
|