Greasy Fork is available in English.
Export arena.ai, lmarena.ai, and legacy LMSYS Arena chats as JSON or TXT with list selection and ZIP packaging
// ==UserScript== // @name Arena.ai / LMSYS Arena Chat Exporter // @name:zh-CN Arena.ai / LMSYS Arena 聊天导出器 // @namespace http://tampermonkey.net/ // @version 2.0 // @description Export arena.ai, lmarena.ai, and legacy LMSYS Arena chats as JSON or TXT with list selection and ZIP packaging // @description:zh-CN 导出 arena.ai、lmarena.ai 与旧版 LMSYS Arena 聊天记录,支持 JSON、TXT、列表勾选与 ZIP 打包 // @match https://arena.ai/* // @match https://*.arena.ai/* // @match https://lmarena.ai/* // @match https://*.lmarena.ai/* // @match https://chat.lmsys.org/* // @match https://arena.lmsys.org/* // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.js // @run-at document-idle // @license GPLv3 // ==/UserScript== (function () { "use strict"; const LOG_TAG = "[arena-chat-export]"; const HISTORY_ENDPOINT = "/api/history/list"; const EVALUATION_ENDPOINT_PREFIX = "/api/evaluation/"; const DEFAULT_HISTORY_PAGE_SIZE = 20; const HISTORY_PAGE_GUARD = 200; const GUI_ROOT_ID = "arena-chat-export-gui-root"; const GUI_DOCK_BUTTON_ID = "arena-chat-export-dock-btn"; const GUI_PANEL_ID = "arena-chat-export-panel"; const GUI_STATUS_ID = "arena-chat-export-status"; const UUID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; const I18N = { en: { unknown_error: "Unknown error", request_failed: "Request failed {status} {statusText}{suffix}", invalid_session_id: "Invalid conversation identifier.", unexpected_evaluation_shape: "The API did not return the expected conversation shape.", unknown_export_format: "Unknown export format: {format}", current_page_not_chat: "The current page is not a chat detail page.", zip_lib_missing: "ZIP library is not loaded, ZIP packaging is unavailable.", no_selected_items: "Select at least one conversation first.", batch_all_failed: "All selected exports failed, ZIP was not generated.", zip_generation_failed: "ZIP generation failed. Try fewer chats or switch export format.", untitled: "(untitled)", empty_message: "(empty)", dock_primary: "Export", dock_secondary: "Chat", dock_aria_open: "Open chat export panel", panel_title: "arena chat export", close_aria: "Close", section_current: "Current chat", button_download_json: "Download JSON", button_download_txt: "Download TXT", section_history: "Conversation list", button_fetch_history: "Fetch list", helper_history: "The list starts empty. After loading, multi-select exports will be packed into one ZIP.", button_select_all: "Select all", button_clear_selection: "Clear", selected_count: "{count} selected", history_empty: "No conversations loaded yet", button_export_selected_json: "Export selected JSON", button_export_selected_txt: "Export selected TXT", status_prefix: "Status: {message}", status_ready: "Ready. Download the current chat or load the list for batch export.", status_running: "{label}...", status_done: "{label} completed.", status_failed: "{label} failed: {message}", label_fetch_history: "Loading list", label_download_current: "Downloading current {format}", label_export_selected: "Exporting selected {format}", fetch_page_request: "Loading list... page {page}, loaded {count}", fetch_page_loaded: "Loaded page {page}. Conversations: {count}", fetch_complete: "Loaded {count} conversations.", fetch_guard_hit: "Loaded {count} conversations. The safety guard was hit; increase the guard if you still expect more.", current_download_done: "Current chat was downloaded as {format}.", zip_packing: "Packing {format} ZIP...", zip_packing_progress: "Packing {format} ZIP... {percent}%", batch_done_packaged_success_failed: "Batch export finished. ZIP ready. Success: {success}. Failed: {failed}.", batch_done_success_failed: "Batch export finished. Success: {success}. Failed: {failed}.", batch_done_packaged_success: "Batch export finished. ZIP ready. Success: {success}.", batch_done_success: "Batch export finished. Success: {success}.", selected_all_done: "All loaded conversations are selected.", cleared_selection_done: "Selection cleared.", gui_init_failed: "GUI initialization failed: missing required nodes.", readonly_ui_comment: "Only human-readable fields are shown in the list. Internal UUIDs stay hidden in the GUI.", switched_language: "Language updated. Reloading...", attach_summary_one: "{count} file attached", attach_summary_many: "{count} files attached", txt_title_fallback: "(untitled)", }, "zh-CN": { unknown_error: "未知错误", request_failed: "请求失败 {status} {statusText}{suffix}", invalid_session_id: "会话标识无效。", unexpected_evaluation_shape: "接口返回不是预期的聊天详情结构。", unknown_export_format: "未知导出格式:{format}", current_page_not_chat: "当前页面不是聊天详情页,无法识别会话。", zip_lib_missing: "ZIP 打包库未加载,无法生成 ZIP。", no_selected_items: "请先勾选至少一条聊天记录。", batch_all_failed: "批量导出全部失败,未生成 ZIP。", zip_generation_failed: "ZIP 生成失败。请尝试减少勾选数量,或切换导出格式。", untitled: "(无标题)", empty_message: "(empty)", dock_primary: "导出", dock_secondary: "聊天", dock_aria_open: "打开聊天导出面板", panel_title: "arena 聊天导出", close_aria: "关闭", section_current: "当前聊天", button_download_json: "下载 JSON", button_download_txt: "下载 TXT", section_history: "聊天列表", button_fetch_history: "抓取列表", helper_history: "列表初始为空,抓取后可勾选导出;多选时会自动打包成一个 ZIP。", button_select_all: "全选", button_clear_selection: "清空选择", selected_count: "已选 {count} 条", history_empty: "尚未抓取聊天列表", button_export_selected_json: "导出勾选 JSON", button_export_selected_txt: "导出勾选 TXT", status_prefix: "状态:{message}", status_ready: "就绪。可下载当前聊天,或先抓取列表后批量导出。", status_running: "{label}执行中...", status_done: "{label}完成。", status_failed: "{label}失败:{message}", label_fetch_history: "抓取列表", label_download_current: "下载当前{format}", label_export_selected: "导出勾选{format}", fetch_page_request: "正在抓取第 {page} 页,当前已拿到 {count} 条", fetch_page_loaded: "已抓取到第 {page} 页,当前共 {count} 条", fetch_complete: "抓取完成,共 {count} 条。", fetch_guard_hit: "抓取完成,当前已拿到 {count} 条;若你仍怀疑没抓全,再把安全上限继续调大。", current_download_done: "当前聊天已下载为 {format}。", zip_packing: "正在打包 {format} ZIP...", zip_packing_progress: "正在打包 {format} ZIP... {percent}%", batch_done_packaged_success_failed: "批量导出完成:已打包 ZIP,成功 {success} 条,失败 {failed} 条。", batch_done_success_failed: "批量导出完成:成功 {success} 条,失败 {failed} 条。", batch_done_packaged_success: "批量导出完成:已打包 ZIP,共 {success} 条。", batch_done_success: "批量导出完成:成功 {success} 条。", selected_all_done: "已全选当前列表。", cleared_selection_done: "已清空选择。", gui_init_failed: "GUI 初始化失败:节点缺失。", readonly_ui_comment: "列表只展示可读信息,内部 UUID 不直接显示在 GUI 上。", switched_language: "语言已切换,正在刷新...", attach_summary_one: "附件 {count} 个", attach_summary_many: "附件 {count} 个", txt_title_fallback: "(untitled)", }, }; function normalizeLocale(locale) { const value = String(locale || "").trim().toLowerCase(); if (!value) { return ""; } if (value.startsWith("zh")) { return "zh-CN"; } return "en"; } function getInitialLocale() { const browserLocale = navigator.language || (Array.isArray(navigator.languages) ? navigator.languages[0] : ""); return normalizeLocale(browserLocale) || "en"; } let currentLocale = getInitialLocale(); function t(key, vars) { const table = I18N[currentLocale] || I18N.en; const fallback = I18N.en; let text = table[key] || fallback[key] || key; if (!vars) { return text; } return text.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, name) => { const value = vars[name]; return value == null ? "" : String(value); }); } function log(...args) { console.log(LOG_TAG, ...args); } function warn(...args) { console.warn(LOG_TAG, ...args); } function toErrorMessage(error) { if (!error) { return t("unknown_error"); } if (typeof error === "string") { return error; } if (error instanceof Error) { return error.message || String(error); } return String(error); } function extractConversationId(text) { const source = String(text || ""); const match = source.match(UUID_PATTERN); return match ? match[1].toLowerCase() : null; } function getConversationIdFromCurrentUrl() { const pathname = String(location.pathname || ""); const match = pathname.match( /\/(?:[a-z]{2}(?:-[A-Z]{2})?\/)?c\/([0-9a-f-]{36})(?:\/|$)/i ); return match ? match[1].toLowerCase() : null; } function sanitizeFileNamePart(text, maxLength) { const raw = String(text || "") .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") .replace(/\s+/g, " ") .trim(); if (!raw) { return "untitled"; } const compact = raw.replace(/[. ]+$/g, ""); return compact.slice(0, maxLength || 60) || "untitled"; } function truncateText(text, maxLength) { const source = String(text || ""); if (source.length <= maxLength) { return source; } return `${source.slice(0, Math.max(0, maxLength - 1))}...`; } function formatUiTime(text) { const value = String(text || "").trim(); if (!value) { return "-"; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } return date.toLocaleString(); } function pad2(value) { return String(value).padStart(2, "0"); } function formatTextTime(text) { const value = String(text || "").trim(); if (!value) { return "-"; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } return [ date.getFullYear(), pad2(date.getMonth() + 1), pad2(date.getDate()), ].join("-") + " " + [pad2(date.getHours()), pad2(date.getMinutes()), pad2(date.getSeconds())].join(":"); } function wait(ms) { return new Promise((resolve) => { window.setTimeout(resolve, ms); }); } async function safeReadText(response) { try { return await response.text(); } catch (_error) { return ""; } } async function fetchJson(url) { // HAR 已验证请求基于同域会话 Cookie,这里固定 credentials=include。 const response = await fetch(url, { method: "GET", credentials: "include", headers: { Accept: "application/json, text/plain, */*", }, }); if (!response.ok) { const responseText = truncateText(await safeReadText(response), 300); const suffix = responseText ? ` - ${responseText}` : ""; throw new Error( t("request_failed", { status: response.status, statusText: response.statusText, suffix, }) ); } return await response.json(); } async function fetchEvaluationById(conversationId) { const id = extractConversationId(conversationId); if (!id) { throw new Error(t("invalid_session_id")); } return await fetchJson(`${location.origin}${EVALUATION_ENDPOINT_PREFIX}${id}`); } async function fetchHistoryList(pageGuard, includeArchived, hooks) { const merged = []; let cursor = null; let currentPage = 0; while (currentPage < pageGuard) { const nextPageNumber = currentPage + 1; hooks?.onPageRequest?.({ page: nextPageNumber, totalCount: merged.length, }); const params = new URLSearchParams(); params.set("limit", String(DEFAULT_HISTORY_PAGE_SIZE)); params.set("includeArchived", includeArchived ? "true" : "false"); if (cursor) { params.set("cursor", cursor); } const requestUrl = `${location.origin}${HISTORY_ENDPOINT}?${params.toString()}`; const payload = await fetchJson(requestUrl); const batch = Array.isArray(payload?.history) ? payload.history : []; merged.push(...batch); const hasMore = Boolean(payload?.pagination?.hasMore); const nextCursor = typeof payload?.pagination?.cursor === "string" ? payload.pagination.cursor : null; currentPage += 1; hooks?.onPageLoaded?.({ page: currentPage, batchCount: batch.length, totalCount: merged.length, hasMore, }); if (!hasMore || !nextCursor) { break; } cursor = nextCursor; } return merged; } function normalizeContent(content) { if (content == null) { return ""; } if (typeof content === "string") { return content; } if (Array.isArray(content)) { return content .map((part) => { if (typeof part === "string") { return part; } if (part && typeof part === "object") { if (typeof part.text === "string") { return part.text; } if (part.type === "image_url") { return `[image] ${part?.image_url?.url || ""}`; } return JSON.stringify(part, null, 2); } return String(part); }) .join("\n"); } if (typeof content === "object") { if (typeof content.text === "string") { return content.text; } if (Array.isArray(content.parts)) { return normalizeContent(content.parts); } return JSON.stringify(content, null, 2); } return String(content); } function formatSpeakerName(message, evaluation) { const role = String(message?.role || "").toLowerCase(); if (role === "user") { return "User"; } if (role === "assistant") { const position = String(message?.participantPosition || "").trim().toUpperCase(); if (position === "A" || position === "B") { return `AI ${position}`; } if (evaluation?.mode === "battle") { return "AI"; } return "AI"; } if (role === "system") { return "System"; } return role || "Unknown"; } function formatAttachmentSummary(message) { const attachments = Array.isArray(message?.experimental_attachments) ? message.experimental_attachments : []; if (!attachments.length) { return ""; } return `${attachments.length} file${attachments.length > 1 ? "s" : ""} attached`; } function getMessageParentKey(message) { const parentIds = Array.isArray(message?.parentMessageIds) ? message.parentMessageIds : []; return parentIds.join("|"); } function getAssistantPositionRank(message) { const position = String(message?.participantPosition || "").trim().toUpperCase(); if (position === "A") { return 0; } if (position === "B") { return 1; } return 9; } function getReadableTextMessages(messages) { const source = Array.isArray(messages) ? messages : []; const ordered = []; // battle 模式里同一轮常会出现两个相邻 assistant 响应;这里只对这类并列响应做 A -> B 重排, // 其余消息保持接口原始顺序,避免误伤多轮对话或其他模式。 for (let index = 0; index < source.length; index += 1) { const current = source[index]; const currentRole = String(current?.role || "").toLowerCase(); if (currentRole !== "assistant") { ordered.push(current); continue; } const chunk = [current]; const parentKey = getMessageParentKey(current); while (index + 1 < source.length) { const next = source[index + 1]; const nextRole = String(next?.role || "").toLowerCase(); if (nextRole !== "assistant" || getMessageParentKey(next) !== parentKey) { break; } chunk.push(next); index += 1; } chunk.sort((left, right) => { const rankDiff = getAssistantPositionRank(left) - getAssistantPositionRank(right); if (rankDiff !== 0) { return rankDiff; } return 0; }); ordered.push(...chunk); } return ordered; } function formatEvaluationAsText(evaluation) { const messages = getReadableTextMessages(evaluation?.messages); const lines = []; lines.push("============================================================"); lines.push("arena.ai chat export"); lines.push("============================================================"); lines.push(`Title : ${evaluation?.title || "(untitled)"}`); lines.push(`Mode : ${evaluation?.mode || "unknown"}`); lines.push(`Started : ${formatTextTime(evaluation?.createdAt)}`); lines.push(`Updated : ${formatTextTime(evaluation?.updatedAt)}`); lines.push(`Messages : ${messages.length}`); lines.push(`URL : ${location.origin}/c/${evaluation?.id || ""}`); lines.push(""); messages.forEach((message, index) => { const speaker = formatSpeakerName(message, evaluation); const time = formatTextTime(message?.createdAt); const content = normalizeContent(message?.content) || t("empty_message"); const attachmentSummary = formatAttachmentSummary(message); lines.push("------------------------------------------------------------"); lines.push(`Message : ${index + 1}`); lines.push(`Speaker : ${speaker}`); lines.push(`Time : ${time}`); if (attachmentSummary) { lines.push(`Attach : ${attachmentSummary}`); } lines.push("------------------------------------------------------------"); lines.push(content); if (index !== messages.length - 1) { lines.push(""); } }); return `${lines.join("\n")}\n`; } function buildJsonExportObject(evaluation) { return { exportedAt: new Date().toISOString(), source: location.origin, conversationUrl: `${location.origin}/c/${evaluation?.id || ""}`, evaluation, }; } function buildFileName(evaluation, extension) { // UI 不直接展示 UUID,文件名也只保留标题与时间戳。 const title = sanitizeFileNamePart(evaluation?.title || "chat", 56); const time = new Date().toISOString().replace(/[:.]/g, "-"); return `arena-chat-${title}-${time}.${extension}`; } function downloadBlob(fileName, mimeType, content) { const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType }); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = fileName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); } function buildZipFileName(format) { const time = new Date().toISOString().replace(/[:.]/g, "-"); return `arena-chat-export-${format === "txt" ? "txt" : "json"}-${time}.zip`; } function getExportPayload(evaluation, format) { if (!evaluation || typeof evaluation !== "object" || !evaluation.id) { throw new Error(t("unexpected_evaluation_shape")); } if (format === "json") { const fileName = buildFileName(evaluation, "json"); const jsonText = `${JSON.stringify(buildJsonExportObject(evaluation), null, 2)}\n`; return { fileName, mimeType: "application/json;charset=utf-8", textContent: jsonText, }; } if (format === "txt") { const fileName = buildFileName(evaluation, "txt"); return { fileName, mimeType: "text/plain;charset=utf-8", textContent: formatEvaluationAsText(evaluation), }; } throw new Error(t("unknown_export_format", { format })); } async function exportConversation(conversationId, format) { const evaluation = await fetchEvaluationById(conversationId); const payload = getExportPayload(evaluation, format); downloadBlob(payload.fileName, payload.mimeType, payload.textContent); return payload.fileName; } async function exportCurrentConversation(format) { const id = getConversationIdFromCurrentUrl(); if (!id) { throw new Error(t("current_page_not_chat")); } return await exportConversation(id, format); } function getZipLib() { if (typeof fflate === "object" && fflate) { return fflate; } if (typeof window.fflate === "object" && window.fflate) { return window.fflate; } throw new Error(t("zip_lib_missing")); } async function exportBatchConversations(conversationIds, format, hooks) { const ids = []; const dedupe = new Set(); for (const value of conversationIds || []) { const id = extractConversationId(value); if (!id || dedupe.has(id)) { continue; } dedupe.add(id); ids.push(id); } if (!ids.length) { throw new Error(t("no_selected_items")); } if (ids.length === 1) { const fileName = await exportConversation(ids[0], format); return { total: 1, successCount: 1, failedCount: 0, packaged: false, fileName }; } // 多选时改为一次性打包 ZIP,避免浏览器连续触发多个下载弹窗。 const zipLib = getZipLib(); const usedNames = new Set(); const archiveEntries = {}; let successCount = 0; let failedCount = 0; for (let i = 0; i < ids.length; i += 1) { hooks?.onProgress?.({ index: i + 1, total: ids.length, successCount, failedCount }); try { const evaluation = await fetchEvaluationById(ids[i]); const payload = getExportPayload(evaluation, format); let nextName = payload.fileName; if (usedNames.has(nextName)) { const dotIndex = nextName.lastIndexOf("."); const baseName = dotIndex > 0 ? nextName.slice(0, dotIndex) : nextName; const extension = dotIndex > 0 ? nextName.slice(dotIndex) : ""; let suffix = 2; while (usedNames.has(`${baseName}-${suffix}${extension}`)) { suffix += 1; } nextName = `${baseName}-${suffix}${extension}`; } usedNames.add(nextName); archiveEntries[nextName] = zipLib.strToU8(payload.textContent); successCount += 1; } catch (error) { failedCount += 1; hooks?.onItemError?.({ index: i + 1, total: ids.length, error }); } await wait(160); } if (!successCount) { throw new Error(t("batch_all_failed")); } hooks?.onZipProgressStart?.({ total: ids.length, successCount, failedCount }); let zipBytes; try { // fflate.zipSync is fast enough for these text exports and avoids the JSZip hang seen in the browser. hooks?.onZipProgress?.({ percent: 15 }); zipBytes = zipLib.zipSync(archiveEntries, { level: 0 }); hooks?.onZipProgress?.({ percent: 100 }); } catch (error) { warn("zip generation failed", error); throw new Error(t("zip_generation_failed")); } const zipFileName = buildZipFileName(format); downloadBlob(zipFileName, "application/zip", zipBytes); return { total: ids.length, successCount, failedCount, packaged: true, fileName: zipFileName, }; } function installGui() { const mount = () => { if (!document.body || document.getElementById(GUI_ROOT_ID)) { return; } const style = document.createElement("style"); style.id = `${GUI_ROOT_ID}-style`; style.textContent = ` #${GUI_ROOT_ID}{ --ace-font-sans: var(--font-basel-grotesk), var(--font-inter), ui-sans-serif, system-ui, sans-serif; --ace-surface: hsl(var(--surface-secondary, 0 0% 100%)); --ace-surface-soft: hsl(var(--surface-tertiary, 33 31% 94%)); --ace-surface-raised: hsl(var(--surface-raised, 33 28% 92%)); --ace-surface-raised-alt: hsl(var(--surface-raised-alt, 33 20% 87%)); --ace-surface-floating: hsl(var(--surface-floating, 33 60% 96%)); --ace-text: hsl(var(--text-primary, 24 6% 17%)); --ace-text-secondary: hsl(var(--text-secondary, 30 7% 24%)); --ace-text-muted: hsl(var(--text-muted, 37 5% 52%)); --ace-border: hsl(var(--border-medium, 30 9% 87%)); --ace-border-faint: hsl(var(--border-faint, 30 5% 93%)); --ace-primary: hsl(var(--interactive-cta, 60 3% 14%)); --ace-primary-hover: hsl(var(--interactive-cta-hover, 24 6% 23%)); --ace-primary-text: hsl(var(--interactive-on-cta, 36 45% 98%)); --ace-link: hsl(var(--interactive-link, 208 77% 52%)); --ace-positive: hsl(var(--interactive-positive, 125 49% 43%)); --ace-negative: hsl(var(--interactive-negative, 2 63% 54%)); --ace-radius: calc(var(--radius, 0.75rem) + 0.2rem); --ace-glass: hsl(var(--background, 36 45% 98%) / .66); --ace-glass-strong: hsl(var(--background, 36 45% 98%) / .82); --ace-glass-soft: hsl(var(--background, 36 45% 98%) / .52); --ace-glass-hover: hsl(var(--foreground, 24 6% 17%) / .08); --ace-glass-border: hsl(var(--foreground, 24 6% 17%) / .12); --ace-glass-border-strong: hsl(var(--foreground, 24 6% 17%) / .18); --ace-shadow: 0 18px 48px hsl(var(--foreground, 24 6% 17%) / .12); --ace-blur: blur(22px) saturate(135%); } #${GUI_DOCK_BUTTON_ID}{ position:fixed;right:-34px;top:42vh;width:84px;height:118px;border:1px solid var(--ace-border);border-right:none;border-radius:var(--ace-radius) 0 0 var(--ace-radius); background:linear-gradient(180deg,var(--ace-glass-strong),var(--ace-glass)); color:var(--ace-text); z-index:2147483000;opacity:.72;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px; font-family:var(--ace-font-sans);box-shadow:0 10px 30px hsl(var(--foreground, 24 6% 17%) / .12);transition:right .2s,opacity .2s,transform .2s; backdrop-filter:var(--ace-blur);-webkit-backdrop-filter:var(--ace-blur);border-color:var(--ace-glass-border-strong) } #${GUI_DOCK_BUTTON_ID}[data-open="true"],#${GUI_DOCK_BUTTON_ID}:hover{right:0;opacity:.98} #${GUI_DOCK_BUTTON_ID} .t1{font-size:13px;font-weight:700} #${GUI_DOCK_BUTTON_ID} .t2{font-size:11px;opacity:.78} #${GUI_PANEL_ID}{ position:fixed;right:16px;top:70px;width:min(94vw,410px);max-height:min(84vh,800px);z-index:2147483001; background: linear-gradient(180deg,hsl(var(--background, 36 45% 98%) / .80),hsl(var(--background, 36 45% 98%) / .62)); border:1px solid var(--ace-glass-border-strong);border-radius:calc(var(--ace-radius) + 2px);color:var(--ace-text); box-shadow:var(--ace-shadow);transform:translateX(16px) scale(.985);opacity:0;pointer-events:none; transition:transform .2s,opacity .2s;font-family:var(--ace-font-sans);overflow:hidden; backdrop-filter:var(--ace-blur);-webkit-backdrop-filter:var(--ace-blur) } #${GUI_PANEL_ID}[data-open="true"]{transform:translateX(0) scale(1);opacity:1;pointer-events:auto} #${GUI_PANEL_ID} .head{ display:flex;justify-content:space-between;gap:8px;padding:12px 12px 8px; border-bottom:1px solid var(--ace-glass-border); background:linear-gradient(180deg,hsl(var(--background, 36 45% 98%) / .36),transparent) } #${GUI_PANEL_ID} .title{font-size:15px;font-weight:700;color:var(--ace-text)} #${GUI_PANEL_ID} .close{ width:26px;height:26px;border:1px solid var(--ace-glass-border);border-radius:calc(var(--ace-radius) - 4px); background:var(--ace-glass-soft);color:var(--ace-text-secondary);cursor:pointer;font-size:17px; backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px) } #${GUI_PANEL_ID} .close:hover{background:var(--ace-glass-hover)} #${GUI_PANEL_ID} .body{padding:10px 12px 12px;display:flex;flex-direction:column;gap:10px;max-height:calc(min(84vh,800px) - 58px);overflow-y:auto} #${GUI_PANEL_ID} .sec{ border:1px solid var(--ace-glass-border);border-radius:var(--ace-radius);background:var(--ace-glass-soft); padding:9px;display:flex;flex-direction:column;gap:8px; backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px) } #${GUI_PANEL_ID} .sec-h{display:flex;justify-content:space-between;align-items:center;gap:8px} #${GUI_PANEL_ID} .sec-t{font-size:13px;font-weight:700;color:var(--ace-text)} #${GUI_PANEL_ID} .helper{font-size:12px;color:var(--ace-text-muted)} #${GUI_PANEL_ID} .row2{display:grid;grid-template-columns:1fr 1fr;gap:8px} #${GUI_PANEL_ID} .btn{ border:1px solid var(--ace-glass-border);background:var(--ace-glass);color:var(--ace-text);border-radius:calc(var(--ace-radius) - 2px); padding:8px 10px;cursor:pointer;font-size:12px;line-height:1.2;white-space:nowrap; backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px) } #${GUI_PANEL_ID} .btn:hover{background:var(--ace-glass-hover);border-color:var(--ace-glass-border-strong)} #${GUI_PANEL_ID} .btn.em{ background:hsl(var(--foreground, 24 6% 17%) / .10);border-color:var(--ace-glass-border-strong);color:var(--ace-text);font-weight:700 } #${GUI_PANEL_ID} .btn.em:hover{background:hsl(var(--foreground, 24 6% 17%) / .16);border-color:hsl(var(--foreground, 24 6% 17%) / .24)} #${GUI_PANEL_ID} .sel-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} #${GUI_PANEL_ID} .sel-sum{margin-left:auto;font-size:12px;color:var(--ace-text-muted)} #${GUI_PANEL_ID} .list{ border:1px solid var(--ace-glass-border);border-radius:calc(var(--ace-radius) - 1px); background:hsl(var(--background, 36 45% 98%) / .40);max-height:280px;overflow-y:auto; backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px) } #${GUI_PANEL_ID} .empty{padding:12px;font-size:12px;color:var(--ace-text-muted)} #${GUI_PANEL_ID} .item{display:flex;gap:9px;align-items:flex-start;padding:9px 10px;border-top:1px solid var(--ace-glass-border);cursor:pointer} #${GUI_PANEL_ID} .item:first-child{border-top:none} #${GUI_PANEL_ID} .item:hover{background:hsl(var(--foreground, 24 6% 17%) / .06)} #${GUI_PANEL_ID} .item-main{min-width:0;flex:1} #${GUI_PANEL_ID} .item-title{font-size:13px;line-height:1.35;word-break:break-word;color:var(--ace-text)} #${GUI_PANEL_ID} .item-meta{margin-top:3px;font-size:11px;color:var(--ace-text-muted)} #${GUI_PANEL_ID} .status{ border-radius:calc(var(--ace-radius) - 2px);padding:8px 10px;font-size:12px;border:1px solid var(--ace-glass-border); background:var(--ace-glass-soft);color:var(--ace-text-secondary); backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px) } #${GUI_PANEL_ID} .status[data-type="success"]{border-color:hsl(var(--interactive-positive, 125 49% 43%) / .24);background:hsl(var(--interactive-positive, 125 49% 43%) / .10);color:var(--ace-positive)} #${GUI_PANEL_ID} .status[data-type="error"]{border-color:hsl(var(--interactive-negative, 2 63% 54%) / .24);background:hsl(var(--interactive-negative, 2 63% 54%) / .10);color:var(--ace-negative)} #${GUI_PANEL_ID} .status[data-type="loading"]{border-color:hsl(var(--foreground, 24 6% 17%) / .18);background:hsl(var(--foreground, 24 6% 17%) / .06);color:var(--ace-text-secondary)} #${GUI_PANEL_ID}[data-busy="true"] button,#${GUI_PANEL_ID}[data-busy="true"] input[type="checkbox"]{opacity:.64} `; document.head.appendChild(style); const root = document.createElement("div"); root.id = GUI_ROOT_ID; root.innerHTML = ` <button id="${GUI_DOCK_BUTTON_ID}" type="button" aria-label="${t("dock_aria_open")}"> <span class="t1">${t("dock_primary")}</span><span class="t2">${t("dock_secondary")}</span> </button> <section id="${GUI_PANEL_ID}" data-open="false" data-busy="false" aria-hidden="true"> <div class="head"> <div><div class="title">${t("panel_title")}</div></div> <button class="close" type="button" data-role="close" aria-label="${t("close_aria")}">×</button> </div> <div class="body"> <div class="sec"> <div class="sec-t">${t("section_current")}</div> <div class="row2"> <button class="btn em" type="button" data-role="export-current-json">${t("button_download_json")}</button> <button class="btn" type="button" data-role="export-current-txt">${t("button_download_txt")}</button> </div> </div> <div class="sec"> <div class="sec-h"> <div class="sec-t">${t("section_history")}</div> <button class="btn" type="button" data-role="fetch-history">${t("button_fetch_history")}</button> </div> <div class="helper">${t("helper_history")}</div> <div class="sel-row"> <button class="btn" type="button" data-role="select-all">${t("button_select_all")}</button> <button class="btn" type="button" data-role="clear-selection">${t("button_clear_selection")}</button> <span class="sel-sum" data-role="selection-summary">${t("selected_count", { count: 0 })}</span> </div> <div class="list" data-role="history-list"><div class="empty">${t("history_empty")}</div></div> <div class="row2"> <button class="btn em" type="button" data-role="export-selected-json">${t("button_export_selected_json")}</button> <button class="btn" type="button" data-role="export-selected-txt">${t("button_export_selected_txt")}</button> </div> </div> <div id="${GUI_STATUS_ID}" class="status" data-type="info">${t("status_prefix", { message: t("status_ready") })}</div> </div> </section> `; document.body.appendChild(root); const dockButton = document.getElementById(GUI_DOCK_BUTTON_ID); const panel = document.getElementById(GUI_PANEL_ID); const statusNode = document.getElementById(GUI_STATUS_ID); const closeNode = panel?.querySelector('[data-role="close"]'); const exportCurrentJsonNode = panel?.querySelector('[data-role="export-current-json"]'); const exportCurrentTxtNode = panel?.querySelector('[data-role="export-current-txt"]'); const fetchHistoryNode = panel?.querySelector('[data-role="fetch-history"]'); const selectAllNode = panel?.querySelector('[data-role="select-all"]'); const clearSelectionNode = panel?.querySelector('[data-role="clear-selection"]'); const exportSelectedJsonNode = panel?.querySelector('[data-role="export-selected-json"]'); const exportSelectedTxtNode = panel?.querySelector('[data-role="export-selected-txt"]'); const historyListNode = panel?.querySelector('[data-role="history-list"]'); const selectionSummaryNode = panel?.querySelector('[data-role="selection-summary"]'); if ( !dockButton || !panel || !statusNode || !exportCurrentJsonNode || !exportCurrentTxtNode || !fetchHistoryNode || !selectAllNode || !clearSelectionNode || !exportSelectedJsonNode || !exportSelectedTxtNode || !historyListNode || !selectionSummaryNode ) { warn(t("gui_init_failed")); return; } const state = { isBusy: false, historyItems: [], selectedIds: new Set(), }; function setStatus(message, type) { statusNode.textContent = t("status_prefix", { message }); statusNode.setAttribute("data-type", type || "info"); } function setBusy(nextBusy) { state.isBusy = Boolean(nextBusy); panel.setAttribute("data-busy", state.isBusy ? "true" : "false"); dockButton.setAttribute("data-busy", state.isBusy ? "true" : "false"); } function setPanelOpen(nextOpen) { const isOpen = Boolean(nextOpen); panel.setAttribute("data-open", isOpen ? "true" : "false"); panel.setAttribute("aria-hidden", isOpen ? "false" : "true"); dockButton.setAttribute("data-open", isOpen ? "true" : "false"); } function updateSelectionSummary() { selectionSummaryNode.textContent = t("selected_count", { count: state.selectedIds.size }); } // The list only shows readable business fields; internal UUIDs stay hidden from the GUI. function renderHistoryList() { historyListNode.innerHTML = ""; if (!state.historyItems.length) { historyListNode.innerHTML = `<div class="empty">${t("history_empty")}</div>`; updateSelectionSummary(); return; } const fragment = document.createDocumentFragment(); for (const item of state.historyItems) { const row = document.createElement("label"); row.className = "item"; row.innerHTML = ` <input type="checkbox" /> <div class="item-main"> <div class="item-title"></div> <div class="item-meta"></div> </div> `; const checkbox = row.querySelector('input[type="checkbox"]'); const titleNode = row.querySelector(".item-title"); const metaNode = row.querySelector(".item-meta"); if (!checkbox || !titleNode || !metaNode) { continue; } titleNode.textContent = truncateText(item.title || t("untitled"), 92); metaNode.textContent = `${item.mode || "unknown"} | ${formatUiTime(item.createdAt)}`; checkbox.checked = state.selectedIds.has(item.id); checkbox.addEventListener("change", () => { if (checkbox.checked) { state.selectedIds.add(item.id); } else { state.selectedIds.delete(item.id); } updateSelectionSummary(); }); fragment.appendChild(row); } historyListNode.appendChild(fragment); updateSelectionSummary(); } async function runGuiAction(label, task) { if (state.isBusy) { return; } setBusy(true); setStatus(t("status_running", { label }), "loading"); try { const message = await task(); setStatus(message || t("status_done", { label }), "success"); } catch (error) { const message = toErrorMessage(error); warn("gui action failed", label, message); setStatus(t("status_failed", { label, message }), "error"); } finally { setBusy(false); } } async function handleFetchHistory() { await runGuiAction(t("label_fetch_history"), async () => { const history = await fetchHistoryList(HISTORY_PAGE_GUARD, false, { onPageRequest(progress) { setStatus( t("fetch_page_request", { page: progress.page, count: progress.totalCount, }), "loading" ); }, onPageLoaded(progress) { setStatus( t("fetch_page_loaded", { page: progress.page, count: progress.totalCount, }), "loading" ); }, }); const mapped = history .filter((item) => extractConversationId(item?.id)) .map((item) => ({ id: extractConversationId(item.id), title: String(item?.title || t("untitled")), mode: String(item?.mode || "unknown"), createdAt: item?.createdAt || "", })); state.historyItems = mapped; state.selectedIds.clear(); renderHistoryList(); if (history.length >= HISTORY_PAGE_GUARD * DEFAULT_HISTORY_PAGE_SIZE) { return t("fetch_guard_hit", { count: state.historyItems.length }); } return t("fetch_complete", { count: state.historyItems.length }); }); } async function handleExportCurrent(format) { const formatLabel = format === "txt" ? "TXT" : "JSON"; await runGuiAction(t("label_download_current", { format: formatLabel }), async () => { await exportCurrentConversation(format); return t("current_download_done", { format: formatLabel }); }); } async function handleExportSelected(format) { const formatLabel = format === "txt" ? "TXT" : "JSON"; await runGuiAction(t("label_export_selected", { format: formatLabel }), async () => { const ids = Array.from(state.selectedIds); if (!ids.length) { throw new Error(t("no_selected_items")); } const result = await exportBatchConversations(ids, format, { onProgress(progress) { setStatus( t("status_running", { label: `${t("label_export_selected", { format: formatLabel })} ${progress.index}/${progress.total}`, }), "loading" ); }, onZipProgressStart() { setStatus(t("zip_packing", { format: formatLabel }), "loading"); }, onZipProgress(metadata) { const percent = Math.max(0, Math.min(100, Math.round(Number(metadata?.percent || 0)))); setStatus(t("zip_packing_progress", { format: formatLabel, percent }), "loading"); }, }); if (result.failedCount > 0) { if (result.packaged) { return t("batch_done_packaged_success_failed", { success: result.successCount, failed: result.failedCount, }); } return t("batch_done_success_failed", { success: result.successCount, failed: result.failedCount, }); } if (result.packaged) { return t("batch_done_packaged_success", { success: result.successCount }); } return t("batch_done_success", { success: result.successCount }); }); } dockButton.addEventListener("click", () => { const isOpen = panel.getAttribute("data-open") === "true"; setPanelOpen(!isOpen); }); closeNode?.addEventListener("click", () => setPanelOpen(false)); exportCurrentJsonNode.addEventListener("click", () => handleExportCurrent("json")); exportCurrentTxtNode.addEventListener("click", () => handleExportCurrent("txt")); fetchHistoryNode.addEventListener("click", () => handleFetchHistory()); selectAllNode.addEventListener("click", () => { for (const item of state.historyItems) { state.selectedIds.add(item.id); } renderHistoryList(); setStatus(t("selected_all_done"), "info"); }); clearSelectionNode.addEventListener("click", () => { state.selectedIds.clear(); renderHistoryList(); setStatus(t("cleared_selection_done"), "info"); }); exportSelectedJsonNode.addEventListener("click", () => handleExportSelected("json")); exportSelectedTxtNode.addEventListener("click", () => handleExportSelected("txt")); document.addEventListener("click", (event) => { if (panel.getAttribute("data-open") !== "true") { return; } const target = event.target; if (!(target instanceof Node)) { return; } if (panel.contains(target) || dockButton.contains(target)) { return; } setPanelOpen(false); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && panel.getAttribute("data-open") === "true") { setPanelOpen(false); } }); renderHistoryList(); setStatus(t("status_ready"), "info"); }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", mount, { once: true }); } else { mount(); } } installGui(); // 低层接口保留给控制台脚本调用;默认 GUI 不展示 UUID 等底层字段。 window.__arenaChatExport = async function __arenaChatExport(conversationId, format) { return await exportConversation(conversationId, format || "json"); }; log("ready"); })();