initial open-source release

This commit is contained in:
minorQAQ committed 2026-08-01 11:15:19 +08:00
commit 01c4605498
224 files changed
+55124

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
// utils.js -- 纯工具函数
const $ = (id) => document.getElementById(id);
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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;
});
}
export {
$,
escapeHtml,
clamp, formatAudioDuration, getAudioBubbleWidth,
inferUploadKind,
getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage,
buildOptimisticUserContent, revokeBlobUrlStack,
makeId, codeBlockNode,
showToast,
dedupePendingFiles,
};