导出 ChatGPT 对话 json + 会话中的多模态文件(图片、音频、sandbox 文件等)
目前為
// ==UserScript== // @name ChatGPT-Multimodal-Exporter // @namespace chatgpt-multimodal-exporter // @version 0.6.0 // @author ha0xin // @description 导出 ChatGPT 对话 json + 会话中的多模态文件(图片、音频、sandbox 文件等) // @license MIT // @icon https://chat.openai.com/favicon.ico // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/preact.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js // @grant GM_addStyle // @grant GM_download // @grant GM_xmlhttpRequest // @run-at document-end // ==/UserScript== (function (preact, JSZip) { 'use strict'; const d$1=new Set;const importCSS = async e=>{d$1.has(e)||(d$1.add(e),(t=>{typeof GM_addStyle=="function"?GM_addStyle(t):(document.head||document.documentElement).appendChild(document.createElement("style")).append(t);})(e));}; var _GM_download = (() => typeof GM_download != "undefined" ? GM_download : void 0)(); var _GM_xmlhttpRequest = (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)(); const sanitize = (s2) => (s2 || "").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 80); const isInlinePointer = (p2) => { if (!p2) return false; const prefixes = [ "https://cdn.oaistatic.com/", "https://oaidalleapiprodscus.blob.core.windows.net/" ]; return prefixes.some((x) => p2.startsWith(x)); }; const pointerToFileId = (p2) => { if (!p2) return ""; if (isInlinePointer(p2)) return p2; const m2 = p2.match(/file[-_][0-9a-f]+/i); return m2 ? m2[0] : p2; }; const fileExtFromMime = (mime) => { if (!mime) return ""; const map = { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif", "application/pdf": ".pdf", "text/plain": ".txt", "text/markdown": ".md" }; if (map[mime]) return map[mime]; if (mime.includes("/")) return `.${mime.split("/")[1]}`; return ""; }; const formatBytes = (n) => { if (!n || isNaN(n)) return ""; const units = ["B", "KB", "MB", "GB"]; let v2 = n; let i2 = 0; while (v2 >= 1024 && i2 < units.length - 1) { v2 /= 1024; i2++; } return `${v2.toFixed(v2 >= 10 || v2 % 1 === 0 ? 0 : 1)}${units[i2]}`; }; const sleep = (ms) => new Promise((r2) => setTimeout(r2, ms)); const convId = () => { const p2 = location.pathname; let m2 = p2.match(/^\/c\/([0-9a-f-]+)$/i); if (m2) return m2[1]; m2 = p2.match(/^\/g\/[^/]+\/c\/([0-9a-f-]+)$/i); return m2 ? m2[1] : ""; }; const projectId = () => { const p2 = location.pathname; const m2 = p2.match(/^\/g\/([^/]+)\/c\/[0-9a-f-]+$/i); return m2 ? m2[1] : ""; }; const isHostOK = () => location.host.endsWith("chatgpt.com") || location.host.endsWith("chat.openai.com"); const BATCH_CONCURRENCY = 4; function saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const a2 = document.createElement("a"); a2.href = url; a2.download = filename; document.body.appendChild(a2); a2.click(); setTimeout(() => URL.revokeObjectURL(url), 3e3); a2.remove(); } function saveJSON(obj, filename) { const blob = new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" }); saveBlob(blob, filename); } function gmDownload(url, filename) { return new Promise((resolve, reject) => { _GM_download({ url, name: filename || "", onload: () => resolve(), onerror: (err) => reject(err), ontimeout: () => reject(new Error("timeout")) }); }); } function parseMimeFromHeaders(raw) { if (!raw) return ""; const m2 = raw.match(/content-type:\s*([^\r\n;]+)/i); return m2 ? m2[1].trim() : ""; } function gmFetchBlob(url, headers) { return new Promise((resolve, reject) => { _GM_xmlhttpRequest({ url, method: "GET", headers: headers || {}, responseType: "arraybuffer", onload: (res) => { const mime = parseMimeFromHeaders(res.responseHeaders || "") || ""; const buf = res.response || res.responseText; resolve({ blob: new Blob([buf], { type: mime }), mime }); }, onerror: (err) => reject(new Error(err && err.error ? err.error : "gm_fetch_error")), ontimeout: () => reject(new Error("gm_fetch_timeout")) }); }); } const HAS_EXT_RE = /\.[^./\\]+$/; function inferFilename(name, fallbackId, mime) { const base = sanitize(name || "") || sanitize(fallbackId || "") || "untitled"; const ext = fileExtFromMime(mime || ""); if (!ext || HAS_EXT_RE.test(base)) return base; return `${base}${ext}`; } const styleCss = ".cgptx-mini-wrap{position:fixed;right:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;align-items:flex-end;gap:8px;font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.cgptx-mini-badge{font-size:12px;padding:4px 8px;border-radius:999px;background:#fff;color:#374151;border:1px solid #e5e7eb;max-width:260px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;box-shadow:0 2px 5px #0000000d}.cgptx-mini-badge.ok{background:#ecfdf5;border-color:#a7f3d0;color:#047857}.cgptx-mini-badge.bad{background:#fef2f2;border-color:#fecaca;color:#b91c1c}.cgptx-mini-btn-row{display:flex;gap:8px}.cgptx-mini-btn{width:48px;height:48px;border-radius:50%;border:1px solid #e5e7eb;cursor:pointer;background:#fff;color:#4b5563;box-shadow:0 4px 12px #00000014;display:flex;align-items:center;justify-content:center;font-size:22px;transition:all .2s ease}.cgptx-mini-btn:hover{transform:translateY(-2px);box-shadow:0 6px 16px #0000001f;color:#2563eb;border-color:#bfdbfe}.cgptx-mini-btn:disabled{opacity:.6;cursor:not-allowed;transform:none;box-shadow:none}.cgptx-modal{position:fixed;inset:0;background:#00000080;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:2147483647}.cgptx-modal-box{width:min(840px,94vw);max-height:85vh;background:#fff;color:#1f2937;border:1px solid #e5e7eb;border-radius:16px;box-shadow:0 20px 50px #0000001a;padding:24px;display:flex;flex-direction:column;gap:16px;overflow:hidden;font-size:14px}.cgptx-modal-header{display:flex;justify-content:space-between;align-items:center;gap:12px;padding-bottom:12px;border-bottom:1px solid #f3f4f6}.cgptx-modal-title{font-weight:700;font-size:18px;color:#111827}.cgptx-modal-actions{display:flex;gap:10px;align-items:center}.cgptx-chip{padding:6px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#f9fafb;color:#4b5563;font-size:13px}.cgptx-list{flex:1;overflow:auto;border:1px solid #e5e7eb;border-radius:12px;background:#f9fafb}.cgptx-item{display:grid;grid-template-columns:24px 20px 1fr;gap:12px;padding:10px 14px;border-bottom:1px solid #e5e7eb;align-items:center;background:#fff;transition:background .15s}.cgptx-item:hover{background:#f3f4f6}.cgptx-item:last-child{border-bottom:none}.cgptx-item .title{font-weight:500;color:#1f2937;line-height:1.4}.cgptx-group{border-bottom:1px solid #e5e7eb;background:#fff}.cgptx-group:last-child{border-bottom:none}.cgptx-group-header{display:grid;grid-template-columns:24px 20px 1fr auto;align-items:center;gap:10px;padding:10px 14px;background:#f3f4f6;cursor:pointer;-webkit-user-select:none;user-select:none}.cgptx-group-header:hover{background:#e5e7eb}.cgptx-group-list{border-top:1px solid #e5e7eb}.cgptx-arrow{font-size:12px;color:#6b7280;transition:transform .2s}.group-title{font-weight:600;color:#374151}.group-count{color:#6b7280;font-size:12px;background:#e5e7eb;padding:2px 6px;border-radius:4px}.cgptx-item .meta{color:#6b7280;font-size:12px;display:flex;gap:8px;flex-wrap:wrap;margin-top:2px}.cgptx-btn{border:1px solid #d1d5db;background:#fff;color:#374151;padding:8px 16px;border-radius:8px;cursor:pointer;font-weight:500;transition:all .15s;box-shadow:0 1px 2px #0000000d}.cgptx-btn:hover{background:#f9fafb;border-color:#9ca3af;color:#111827}.cgptx-btn.primary{background:#3b82f6;border-color:#2563eb;color:#fff;box-shadow:0 2px 4px #3b82f64d}.cgptx-btn.primary:hover{background:#2563eb}.cgptx-btn:disabled{opacity:.5;cursor:not-allowed;box-shadow:none}.cgptx-progress-wrap{display:flex;flex-direction:column;gap:6px;margin-top:4px}.cgptx-progress-track{height:8px;background:#e5e7eb;border-radius:4px;overflow:hidden}.cgptx-progress-bar{height:100%;background:#3b82f6;width:0%;transition:width .3s ease}.cgptx-progress-text{font-size:12px;color:#6b7280;text-align:right}input[type=checkbox]{accent-color:#3b82f6;width:16px;height:16px;cursor:pointer}"; importCSS(styleCss); var f$1 = 0; function u$1(e2, t2, n, o2, i2, u2) { t2 || (t2 = {}); var a2, c2, p2 = t2; if ("ref" in p2) for (c2 in p2 = {}, t2) "ref" == c2 ? a2 = t2[c2] : p2[c2] = t2[c2]; var l2 = { type: e2, props: p2, key: n, ref: a2, __k: null, __: null, __b: 0, __e: null, __c: null, constructor: void 0, __v: --f$1, __i: -1, __u: 0, __source: i2, __self: u2 }; if ("function" == typeof e2 && (a2 = e2.defaultProps)) for (c2 in a2) void 0 === p2[c2] && (p2[c2] = a2[c2]); return preact.options.vnode && preact.options.vnode(l2), l2; } var t, r, u, i, o = 0, f = [], c = preact.options, e = c.__b, a = c.__r, v = c.diffed, l = c.__c, m = c.unmount, s = c.__; function p(n, t2) { c.__h && c.__h(r, n, o || t2), o = 0; var u2 = r.__H || (r.__H = { __: [], __h: [] }); return n >= u2.__.length && u2.__.push({}), u2.__[n]; } function d(n) { return o = 1, h(D, n); } function h(n, u2, i2) { var o2 = p(t++, 2); if (o2.t = n, !o2.__c && (o2.__ = [D(void 0, u2), function(n2) { var t2 = o2.__N ? o2.__N[0] : o2.__[0], r2 = o2.t(t2, n2); t2 !== r2 && (o2.__N = [r2, o2.__[1]], o2.__c.setState({})); }], o2.__c = r, !r.__f)) { var f2 = function(n2, t2, r2) { if (!o2.__c.__H) return true; var u3 = o2.__c.__H.__.filter(function(n3) { return !!n3.__c; }); if (u3.every(function(n3) { return !n3.__N; })) return !c2 || c2.call(this, n2, t2, r2); var i3 = o2.__c.props !== n2; return u3.forEach(function(n3) { if (n3.__N) { var t3 = n3.__[0]; n3.__ = n3.__N, n3.__N = void 0, t3 !== n3.__[0] && (i3 = true); } }), c2 && c2.call(this, n2, t2, r2) || i3; }; r.__f = true; var c2 = r.shouldComponentUpdate, e2 = r.componentWillUpdate; r.componentWillUpdate = function(n2, t2, r2) { if (this.__e) { var u3 = c2; c2 = void 0, f2(n2, t2, r2), c2 = u3; } e2 && e2.call(this, n2, t2, r2); }, r.shouldComponentUpdate = f2; } return o2.__N || o2.__; } function y(n, u2) { var i2 = p(t++, 3); !c.__s && C(i2.__H, u2) && (i2.__ = n, i2.u = u2, r.__H.__h.push(i2)); } function A(n) { return o = 5, T(function() { return { current: n }; }, []); } function T(n, r2) { var u2 = p(t++, 7); return C(u2.__H, r2) && (u2.__ = n(), u2.__H = r2, u2.__h = n), u2.__; } function j() { for (var n; n = f.shift(); ) if (n.__P && n.__H) try { n.__H.__h.forEach(z), n.__H.__h.forEach(B), n.__H.__h = []; } catch (t2) { n.__H.__h = [], c.__e(t2, n.__v); } } c.__b = function(n) { r = null, e && e(n); }, c.__ = function(n, t2) { n && t2.__k && t2.__k.__m && (n.__m = t2.__k.__m), s && s(n, t2); }, c.__r = function(n) { a && a(n), t = 0; var i2 = (r = n.__c).__H; i2 && (u === r ? (i2.__h = [], r.__h = [], i2.__.forEach(function(n2) { n2.__N && (n2.__ = n2.__N), n2.u = n2.__N = void 0; })) : (i2.__h.forEach(z), i2.__h.forEach(B), i2.__h = [], t = 0)), u = r; }, c.diffed = function(n) { v && v(n); var t2 = n.__c; t2 && t2.__H && (t2.__H.__h.length && (1 !== f.push(t2) && i === c.requestAnimationFrame || ((i = c.requestAnimationFrame) || w)(j)), t2.__H.__.forEach(function(n2) { n2.u && (n2.__H = n2.u), n2.u = void 0; })), u = r = null; }, c.__c = function(n, t2) { t2.some(function(n2) { try { n2.__h.forEach(z), n2.__h = n2.__h.filter(function(n3) { return !n3.__ || B(n3); }); } catch (r2) { t2.some(function(n3) { n3.__h && (n3.__h = []); }), t2 = [], c.__e(r2, n2.__v); } }), l && l(n, t2); }, c.unmount = function(n) { m && m(n); var t2, r2 = n.__c; r2 && r2.__H && (r2.__H.__.forEach(function(n2) { try { z(n2); } catch (n3) { t2 = n3; } }), r2.__H = void 0, t2 && c.__e(t2, r2.__v)); }; var k = "function" == typeof requestAnimationFrame; function w(n) { var t2, r2 = function() { clearTimeout(u2), k && cancelAnimationFrame(t2), setTimeout(n); }, u2 = setTimeout(r2, 35); k && (t2 = requestAnimationFrame(r2)); } function z(n) { var t2 = r, u2 = n.__c; "function" == typeof u2 && (n.__c = void 0, u2()), r = t2; } function B(n) { var t2 = r; n.__c = n.__(), r = t2; } function C(n, t2) { return !n || n.length !== t2.length || t2.some(function(t3, r2) { return t3 !== n[r2]; }); } function D(n, t2) { return "function" == typeof t2 ? t2(n) : t2; } const Cred = (() => { let token = null; let accountId = null; let lastErr = ""; let triedAccountApi = false; const mask = (s2, keepL = 8, keepR = 4) => { if (!s2) return ""; if (s2.length <= keepL + keepR) return s2; return `${s2.slice(0, keepL)}…${s2.slice(-keepR)}`; }; const parseAccountCookie = () => { const m2 = document.cookie.match(/(?:^|;\s*)_account=([^;]+)/); if (!m2) return ""; const val = decodeURIComponent(m2[1] || "").trim(); if (!val || val === "x" || val === "undefined" || val === "null") return ""; return val; }; const getAuthHeaders = () => { const h2 = new Headers(); if (token) h2.set("authorization", `Bearer ${token}`); if (accountId) h2.set("chatgpt-account-id", accountId); return h2; }; const fetchAccountFromApi = async () => { if (!token) return ""; const url = `${location.origin}/backend-api/accounts/check/v4-2023-04-27`; const resp = await fetch(url, { headers: getAuthHeaders(), credentials: "include" }).catch(() => null); if (!resp || !resp.ok) return ""; const data = await resp.json().catch(() => null); if (!data || typeof data !== "object") return ""; const accounts = data.accounts || {}; const ordering = Array.isArray(data.account_ordering) ? data.account_ordering : []; for (const key of ordering) { const a2 = accounts[key]; const aid = a2?.account?.account_id; if (aid) return aid; } const first = Object.values(accounts).find((a2) => a2?.account?.account_id); return first ? first.account.account_id : ""; }; const ensureViaSession = async (tries = 3) => { for (let i2 = 0; i2 < tries; i2++) { try { const resp = await fetch("/api/auth/session", { credentials: "include" }); if (!resp.ok) { lastErr = `session ${resp.status}`; } else { const j2 = await resp.json().catch(() => ({})); if (j2 && j2.accessToken) { token = j2.accessToken; lastErr = ""; } } if (!accountId) { const fromCookie = parseAccountCookie(); if (fromCookie) accountId = fromCookie; } if (token) return true; } catch (e2) { lastErr = e2 && e2.message ? e2.message : "session_error"; } await new Promise((r2) => setTimeout(r2, 300 * (i2 + 1))); } return !!token; }; const ensureAccountId = async () => { if (accountId && accountId !== "x") return accountId; const fromCookie = parseAccountCookie(); if (fromCookie) { accountId = fromCookie; return accountId; } if (triedAccountApi) return accountId || ""; triedAccountApi = true; const apiId = await fetchAccountFromApi(); if (apiId) accountId = apiId; return accountId || ""; }; const debugText = () => { const tok = token ? mask(token) : "未获取"; const acc = accountId || "未获取"; const err = lastErr ? ` 错误:${lastErr}` : ""; return `Token:${tok} Account:${acc}${err}`; }; return { ensureViaSession, ensureAccountId, getAuthHeaders, get token() { return token; }, get accountId() { return accountId; }, get debug() { return debugText(); } }; })(); async function fetchConversation(id, projectId2) { if (!Cred.token) { const ok = await Cred.ensureViaSession(); if (!ok) throw new Error("无法获取登录凭证(accessToken)"); } const headers = Cred.getAuthHeaders(); if (projectId2) headers.set("chatgpt-project-id", projectId2); const url = `${location.origin}/backend-api/conversation/${id}`; const init = { method: "GET", credentials: "include", headers }; let resp = await fetch(url, init).catch(() => null); if (!resp) throw new Error("网络错误"); if (resp.status === 401) { const ok = await Cred.ensureViaSession(); if (!ok) throw new Error("401:重新获取凭证失败"); const h2 = Cred.getAuthHeaders(); if (projectId2) h2.set("chatgpt-project-id", projectId2); init.headers = h2; resp = await fetch(url, init).catch(() => null); if (!resp) throw new Error("网络错误(重试)"); } if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`HTTP ${resp.status}: ${txt.slice(0, 200)}`); } return resp.json(); } async function downloadSandboxFile({ conversationId, messageId, sandboxPath }) { if (!Cred.token) { const ok = await Cred.ensureViaSession(); if (!ok) throw new Error("没有 accessToken,无法下载 sandbox 文件"); } const headers = Cred.getAuthHeaders(); const pid = projectId(); if (pid) headers.set("chatgpt-project-id", pid); const params = new URLSearchParams({ message_id: messageId, sandbox_path: sandboxPath.replace(/^sandbox:/, "") }); const url = `${location.origin}/backend-api/conversation/${conversationId}/interpreter/download?${params.toString()}`; const resp = await fetch(url, { headers, credentials: "include" }); if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`sandbox download meta ${resp.status}: ${txt.slice(0, 200)}`); } let j2; try { j2 = await resp.json(); } catch (e2) { throw new Error("sandbox download meta 非 JSON"); } const dl = j2.download_url; if (!dl) throw new Error(`sandbox download_url 缺失: ${JSON.stringify(j2).slice(0, 200)}`); const fname = sanitize(j2.file_name || sandboxPath.split("/").pop() || "sandbox_file"); await gmDownload(dl, fname); } async function downloadSandboxFileBlob({ conversationId, messageId, sandboxPath }) { if (!Cred.token) { const ok = await Cred.ensureViaSession(); if (!ok) throw new Error("没有 accessToken,无法下载 sandbox 文件"); } const headers = Cred.getAuthHeaders(); const pid = projectId(); if (pid) headers.set("chatgpt-project-id", pid); const params = new URLSearchParams({ message_id: messageId, sandbox_path: sandboxPath.replace(/^sandbox:/, "") }); const url = `${location.origin}/backend-api/conversation/${conversationId}/interpreter/download?${params.toString()}`; const resp = await fetch(url, { headers, credentials: "include" }); if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`sandbox download meta ${resp.status}: ${txt.slice(0, 200)}`); } let j2; try { j2 = await resp.json(); } catch (e2) { throw new Error("sandbox download meta 非 JSON"); } const dl = j2.download_url; if (!dl) throw new Error(`sandbox download_url 缺失: ${JSON.stringify(j2).slice(0, 200)}`); const gmHeaders = {}; const res = await gmFetchBlob(dl, gmHeaders); const fname = inferFilename( j2.file_name || sandboxPath.split("/").pop() || "sandbox_file", sandboxPath, res.mime || "" ); return { blob: res.blob, mime: res.mime || "", filename: fname }; } async function fetchDownloadUrlOrResponse(fileId, headers) { const url = `${location.origin}/backend-api/files/download/${fileId}?inline=false`; const resp = await fetch(url, { method: "GET", headers, credentials: "include" }); if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`download meta ${resp.status}: ${txt.slice(0, 200)}`); } const ct = resp.headers.get("content-type") || ""; if (ct.includes("json")) { const j2 = await resp.json(); if (!j2.download_url && !j2.url) { throw new Error(`download meta missing url: ${JSON.stringify(j2).slice(0, 200)}`); } return j2.download_url || j2.url; } return resp; } function collectFileCandidates(conv) { const mapping = conv && conv.mapping || {}; const out = new Map(); const convId2 = conv?.conversation_id || ""; const add = (fileId, info) => { if (!fileId) return; if (out.has(fileId)) return; out.set(fileId, { file_id: fileId, conversation_id: convId2, ...info }); }; for (const key in mapping) { const node = mapping[key]; if (!node || !node.message) continue; const msg = node.message; const meta = msg.metadata || {}; const c2 = msg.content || {}; (meta.attachments || []).forEach((att) => { if (!att || !att.id) return; add(att.id, { source: "attachment", meta: att }); }); const crefByFile = meta.content_references_by_file || {}; Object.values(crefByFile).flat().forEach((ref) => { if (ref?.file_id) add(ref.file_id, { source: "cref", meta: ref, message_id: msg.id }); if (ref?.asset_pointer) { const fid = pointerToFileId(ref.asset_pointer); add(fid, { source: "cref-pointer", pointer: ref.asset_pointer, meta: ref, message_id: msg.id }); } }); const n7 = meta.n7jupd_crefs_by_file || meta.n7jupd_crefs || {}; const n7list = Array.isArray(n7) ? n7 : Object.values(n7).flat(); n7list.forEach((ref) => { if (ref?.file_id) add(ref.file_id, { source: "n7jupd-cref", meta: ref, message_id: msg.id }); }); if (Array.isArray(c2.parts)) { c2.parts.forEach((part) => { if (part && typeof part === "object" && part.content_type && part.asset_pointer) { const fid = pointerToFileId(part.asset_pointer); add(fid, { source: part.content_type, pointer: part.asset_pointer, meta: part, message_id: msg.id }); } if (part && typeof part === "object" && part.content_type === "real_time_user_audio_video_asset_pointer" && part.audio_asset_pointer && part.audio_asset_pointer.asset_pointer) { const ap = part.audio_asset_pointer; const fid = pointerToFileId(ap.asset_pointer); add(fid, { source: "voice-audio", pointer: ap.asset_pointer, meta: ap, message_id: msg.id }); } if (part && typeof part === "object" && part.audio_asset_pointer && part.audio_asset_pointer.asset_pointer) { const ap = part.audio_asset_pointer; const fid = pointerToFileId(ap.asset_pointer); add(fid, { source: "voice-audio", pointer: ap.asset_pointer, meta: ap, message_id: msg.id }); } }); } if (c2.content_type === "text" && Array.isArray(c2.parts)) { c2.parts.forEach((txt) => { if (typeof txt !== "string") return; const matches = txt.match(/\{\{file:([^}]+)\}\}/g) || []; matches.forEach((tok) => { const fid = tok.slice(7, -2); add(fid, { source: "inline-placeholder", message_id: msg.id }); }); const sandboxLinks = txt.match(/sandbox:[^\s\)\]]+/g) || []; sandboxLinks.forEach((s2) => { add(s2, { source: "sandbox-link", pointer: s2, message_id: msg.id }); }); }); } } return [...out.values()]; } function extractImages(conv) { const mapping = conv && conv.mapping ? conv.mapping : {}; const images = []; const seen = new Set(); for (const key in mapping) { const node = mapping[key]; if (!node || !node.message) continue; const msg = node.message; const role = msg.author && msg.author.role; const msgId = msg.id; const meta = msg.metadata || {}; if (Array.isArray(meta.attachments)) { for (const att of meta.attachments) { if (!att || !att.id) continue; const fileId = att.id; if (seen.has(fileId)) continue; seen.add(fileId); images.push({ kind: "attachment", file_id: fileId, name: att.name || "", mime_type: att.mime_type || "", size_bytes: att.size || att.size_bytes || void 0, message_id: msgId, role, source: "upload" }); } } const c2 = msg.content; if (c2 && c2.content_type === "multimodal_text" && Array.isArray(c2.parts)) { for (const part of c2.parts) { if (part && typeof part === "object" && part.content_type === "image_asset_pointer") { const pointer = part.asset_pointer || ""; let fileId = ""; const m2 = pointer.match(/file_[0-9a-f]+/i); if (m2) fileId = m2[0]; const keyId = fileId || pointer; if (seen.has(keyId)) continue; seen.add(keyId); images.push({ kind: "asset_pointer", file_id: fileId, pointer, width: part.width, height: part.height, size_bytes: part.size_bytes, message_id: msgId, role, source: "asset_pointer" }); } } } } console.log("[ChatGPT-Multimodal-Exporter] 找到的图片信息:", images); return images; } async function downloadPointerOrFile(fileInfo) { const fileId = fileInfo.file_id; const pointer = fileInfo.pointer || ""; const convId2 = fileInfo.conversation_id || ""; const messageId = fileInfo.message_id || ""; if (isInlinePointer(fileId) || isInlinePointer(pointer)) { const url = isInlinePointer(pointer) ? pointer : fileId; const name2 = inferFilename( fileInfo.meta && (fileInfo.meta.name || fileInfo.meta.file_name) || "", fileId || pointer, "" ); await gmDownload(url, name2); return; } if (pointer && pointer.startsWith("sandbox:")) { if (!convId2 || !messageId) { console.warn("[ChatGPT-Multimodal-Exporter] sandbox pointer缺少 conversation/message id", pointer); return; } await downloadSandboxFile({ conversationId: convId2, messageId, sandboxPath: pointer }); return; } if (!Cred.token) { const ok = await Cred.ensureViaSession(); if (!ok) throw new Error("没有 accessToken,无法下载文件"); } const headers = Cred.getAuthHeaders(); const pid = projectId(); if (pid) headers.set("chatgpt-project-id", pid); const downloadResult = await fetchDownloadUrlOrResponse(fileId, headers); let resp; if (downloadResult instanceof Response) { resp = downloadResult; } else if (typeof downloadResult === "string") { const fname = fileInfo.meta && (fileInfo.meta.name || fileInfo.meta.file_name) || `${fileId}${fileExtFromMime("") || ""}`; await gmDownload(downloadResult, fname); return; } else { throw new Error(`无法获取 download_url,如果file-id正确,可能是链接过期 (file_id: ${fileId})`); } if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`下载失败 ${resp.status}: ${txt.slice(0, 120)}`); } const blob = await resp.blob(); const cd = resp.headers.get("Content-Disposition") || ""; const m2 = cd.match(/filename\*?=(?:UTF-8''|")?([^\";]+)/i); const mime = fileInfo.meta && (fileInfo.meta.mime_type || fileInfo.meta.file_type) || resp.headers.get("Content-Type") || ""; const ext = fileExtFromMime(mime) || ".bin"; let name = fileInfo.meta && (fileInfo.meta.name || fileInfo.meta.file_name) || m2 && decodeURIComponent(m2[1]) || `${fileId}${ext}`; name = sanitize(name); saveBlob(blob, name); } async function downloadSelectedFiles(list) { let okCount = 0; for (const info of list) { try { await downloadPointerOrFile(info); okCount++; } catch (e2) { console.error("[ChatGPT-Multimodal-Exporter] 下载失败", info, e2); } } return { ok: okCount, total: list.length }; } async function downloadPointerOrFileAsBlob(fileInfo) { const fileId = fileInfo.file_id; const pointer = fileInfo.pointer || ""; const convId2 = fileInfo.conversation_id || ""; const projectId2 = fileInfo.project_id || ""; const messageId = fileInfo.message_id || ""; if (isInlinePointer(fileId) || isInlinePointer(pointer)) { const url = isInlinePointer(pointer) ? pointer : fileId; const res = await gmFetchBlob(url); const mime2 = res.mime || fileInfo.meta?.mime_type || fileInfo.meta?.mime || ""; const filename = inferFilename( fileInfo.meta && (fileInfo.meta.name || fileInfo.meta.file_name) || "", fileId || pointer, mime2 ); return { blob: res.blob, mime: mime2, filename }; } if (pointer && pointer.startsWith("sandbox:")) { if (!convId2 || !messageId) throw new Error("sandbox pointer 缺少 conversation/message id"); return downloadSandboxFileBlob({ conversationId: convId2, messageId, sandboxPath: pointer }); } if (!Cred.token) { const ok = await Cred.ensureViaSession(); if (!ok) throw new Error("没有 accessToken,无法下载文件"); } const headers = Cred.getAuthHeaders(); if (projectId2) headers.set("chatgpt-project-id", projectId2); const downloadResult = await fetchDownloadUrlOrResponse(fileId, headers); let resp; if (downloadResult instanceof Response) { resp = downloadResult; } else if (typeof downloadResult === "string") { const res = await gmFetchBlob(downloadResult); const mime2 = res.mime || fileInfo.meta?.mime_type || fileInfo.meta?.mime || ""; const fname = inferFilename( fileInfo.meta && (fileInfo.meta.name || fileInfo.meta.file_name) || "", fileId, mime2 ); return { blob: res.blob, mime: mime2, filename: fname }; } else { throw new Error(`无法获取 download_url,如果file-id正确,可能是链接过期 (file_id: ${fileId})`); } if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`下载失败 ${resp.status}: ${txt.slice(0, 120)}`); } const blob = await resp.blob(); const cd = resp.headers.get("Content-Disposition") || ""; const m2 = cd.match(/filename\*?=(?:UTF-8''|")?([^\";]+)/i); const mime = fileInfo.meta && (fileInfo.meta.mime_type || fileInfo.meta.file_type) || resp.headers.get("Content-Type") || ""; const name = inferFilename( fileInfo.meta && (fileInfo.meta.name || fileInfo.meta.file_name) || m2 && decodeURIComponent(m2[1]) || "", fileId, mime ); return { blob, mime, filename: name }; } async function listConversationsPage({ offset = 0, limit = 100, is_archived, is_starred, order }) { if (!Cred.token) await Cred.ensureViaSession(); const headers = Cred.getAuthHeaders(); const qs = new URLSearchParams({ offset: String(offset), limit: String(limit) }); if (typeof is_archived === "boolean") qs.set("is_archived", String(is_archived)); if (typeof is_starred === "boolean") qs.set("is_starred", String(is_starred)); if (order) qs.set("order", order); const url = `${location.origin}/backend-api/conversations?${qs.toString()}`; const resp = await fetch(url, { headers, credentials: "include" }); if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`list convs ${resp.status}: ${txt.slice(0, 120)}`); } return resp.json(); } async function listProjectConversations({ projectId: projectId2, cursor = 0, limit = 50 }) { if (!Cred.token) await Cred.ensureViaSession(); const headers = Cred.getAuthHeaders(); const url = `${location.origin}/backend-api/gizmos/${projectId2}/conversations?cursor=${cursor}&limit=${limit}`; const resp = await fetch(url, { headers, credentials: "include" }); if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`project convs ${resp.status}: ${txt.slice(0, 120)}`); } return resp.json(); } async function listGizmosSidebar(cursor) { if (!Cred.token) await Cred.ensureViaSession(); const headers = Cred.getAuthHeaders(); const url = new URL(`${location.origin}/backend-api/gizmos/snorlax/sidebar`); url.searchParams.set("conversations_per_gizmo", "0"); if (cursor) url.searchParams.set("cursor", cursor); const resp = await fetch(url.toString(), { headers, credentials: "include" }); if (!resp.ok) { const txt = await resp.text().catch(() => ""); throw new Error(`gizmos sidebar ${resp.status}: ${txt.slice(0, 120)}`); } return resp.json(); } async function collectAllConversationTasks(progressCb) { const rootSet = new Set(); const rootInfo = new Map(); const projectMap = new Map(); const addRoot = (id, title) => { if (!id) return; rootSet.add(id); if (!rootInfo.has(id)) rootInfo.set(id, { id, title: title || "" }); }; const ensureProject = (projectId2, projectName) => { if (!projectId2) return null; let rec = projectMap.get(projectId2); if (!rec) { rec = { projectId: projectId2, projectName: projectName || "", convs: [] }; projectMap.set(projectId2, rec); } else if (projectName && !rec.projectName) { rec.projectName = projectName; } return rec; }; const addProjectConv = (projectId2, id, title, projectName) => { if (!projectId2 || !id) return; const rec = ensureProject(projectId2, projectName); if (!rec) return; if (!rec.convs.some((x) => x.id === id)) { rec.convs.push({ id, title: title || "" }); } if (rootSet.has(id)) { rootSet.delete(id); rootInfo.delete(id); } }; const fetchRootBasic = async () => { const limit = 100; let offset = 0; while (true) { const page = await listConversationsPage({ offset, limit }).catch((e2) => { console.warn("[ChatGPT-Multimodal-Exporter] list conversations failed", e2); return null; }); const arr = Array.isArray(page?.items) ? page.items : []; arr.forEach((it) => { if (!it || !it.id) return; const id = it.id; const projId = it.conversation_template_id || it.gizmo_id || null; if (projId) addProjectConv(projId, id, it.title || ""); else addRoot(id, it.title || ""); }); if (progressCb) progressCb(3, `个人会话:${offset + arr.length}${page?.total ? `/${page.total}` : ""}`); if (!arr.length || arr.length < limit || page && page.total !== null && offset + limit >= page.total) break; offset += limit; await sleep(120); } }; await fetchRootBasic(); try { const projectIds = new Set(); let cursor = null; do { const sidebar = await listGizmosSidebar(cursor).catch((e2) => { console.warn("[ChatGPT-Multimodal-Exporter] gizmos sidebar failed", e2); return null; }); const gizmosRaw = Array.isArray(sidebar?.gizmos) ? sidebar.gizmos : []; const itemsRaw = Array.isArray(sidebar?.items) ? sidebar.items : []; const pushGizmo = (g) => { if (!g || !g.id) return; projectIds.add(g.id); ensureProject(g.id, g.display?.name || g.name || ""); const convs = Array.isArray(g.conversations) ? g.conversations : []; convs.forEach((c2) => addProjectConv(g.id, c2.id, c2.title, g.display?.name || g.name)); }; gizmosRaw.forEach((g) => pushGizmo(g)); itemsRaw.forEach((it) => { const g = it?.gizmo?.gizmo || it?.gizmo || null; if (!g || !g.id) return; pushGizmo(g); const convs = it?.conversations?.items; if (Array.isArray(convs)) convs.forEach((c2) => addProjectConv(g.id, c2.id, c2.title, g.display?.name || g.name)); }); cursor = sidebar && sidebar.cursor ? sidebar.cursor : null; } while (cursor); for (const pid of projectIds) { let cursor2 = 0; const limit = 50; while (true) { const page = await listProjectConversations({ projectId: pid, cursor: cursor2, limit }).catch((e2) => { console.warn("[ChatGPT-Multimodal-Exporter] project conversations failed", e2); return null; }); const arr = Array.isArray(page?.items) ? page.items : []; arr.forEach((it) => { if (!it || !it.id) return; addProjectConv(pid, it.id, it.title || ""); }); if (progressCb) progressCb(5, `项目 ${pid}:${cursor2 + arr.length}${page?.total ? `/${page.total}` : ""}`); if (!arr.length || arr.length < limit || page && page.total !== null && cursor2 + limit >= page.total) break; cursor2 += limit; await sleep(120); } } } catch (e2) { console.warn("[ChatGPT-Multimodal-Exporter] project list error", e2); } const rootIds = Array.from(rootSet); const roots = Array.from(rootInfo.values()); const projects = Array.from(projectMap.values()); return { rootIds, roots, projects }; } async function fetchConvWithRetry(id, projectId2, retries = 2) { let attempt = 0; let lastErr = null; while (attempt <= retries) { try { return await fetchConversation(id, projectId2 || void 0); } catch (e2) { lastErr = e2; attempt++; const delay = 400 * Math.pow(2, attempt - 1); await sleep(delay); } } throw lastErr || new Error("fetch_failed"); } async function fetchConversationsBatch(tasks, concurrency, progressCb, cancelRef) { const total = tasks.length; if (!total) return []; const results = new Array(total); let done = 0; let index = 0; let fatalErr = null; const worker = async () => { while (true) { if (cancelRef && cancelRef.cancel) return; if (fatalErr) return; const i2 = index++; if (i2 >= total) return; const t2 = tasks[i2]; try { const data = await fetchConvWithRetry(t2.id, t2.projectId, 2); results[i2] = data; done++; const pct = total ? Math.round(done / total * 60) + 10 : 10; if (progressCb) progressCb(pct, `导出 JSON:${done}/${total}`); } catch (e2) { fatalErr = e2; return; } } }; const n = Math.max(1, Math.min(concurrency || 1, total)); const workers = []; for (let i2 = 0; i2 < n; i2++) workers.push(worker()); await Promise.all(workers); if (fatalErr) throw fatalErr; return results; } function buildProjectFolderNames(projects) { const map = new Map(); const counts = {}; projects.forEach((p2) => { const base = sanitize(p2.projectName || p2.projectId || "project"); counts[base] = (counts[base] || 0) + 1; }); projects.forEach((p2) => { let baseName = sanitize(p2.projectName || p2.projectId || "project"); if (counts[baseName] > 1) { const stamp = p2.createdAt ? p2.createdAt.replace(/[^\d]/g, "").slice(0, 14) : ""; if (stamp) { const raw = p2.projectName || baseName; baseName = sanitize(`${raw}_${stamp}`); } } map.set(p2.projectId, baseName || "project"); }); return map; } async function runBatchExport({ tasks, projects, rootIds, includeAttachments = true, concurrency = BATCH_CONCURRENCY, progressCb, cancelRef }) { if (!tasks || !tasks.length) throw new Error("任务列表为空"); if (typeof JSZip === "undefined") throw new Error("JSZip 未加载"); const zip = new JSZip(); const summary = { exported_at: ( new Date()).toISOString(), total_conversations: tasks.length, root: { count: rootIds.length, ids: rootIds }, projects: (projects || []).map((p2) => ({ projectId: p2.projectId, projectName: p2.projectName || "", createdAt: p2.createdAt || "", count: Array.isArray(p2.convs) ? p2.convs.length : 0 })), failed: { conversations: [], attachments: [] } }; const folderNameByProjectId = buildProjectFolderNames(projects || []); const projCache = new Map(); const results = await fetchConversationsBatch(tasks, concurrency, progressCb, cancelRef); if (cancelRef && cancelRef.cancel) throw new Error("用户已取消"); let idxRoot = 0; const projSeq = {}; for (let i2 = 0; i2 < tasks.length; i2++) { if (cancelRef && cancelRef.cancel) throw new Error("用户已取消"); const t2 = tasks[i2]; const data = results[i2]; if (!data) { summary.failed.conversations.push({ id: t2.id, projectId: t2.projectId || "", reason: "为空" }); continue; } const isProject = !!t2.projectId; let baseFolder = zip; let seq = ""; if (isProject && t2.projectId) { const fname = folderNameByProjectId.get(t2.projectId) || sanitize(t2.projectId || "project"); let cache = projCache.get(t2.projectId); if (!cache) { cache = zip.folder(fname); projCache.set(t2.projectId, cache); } baseFolder = cache || zip; projSeq[t2.projectId] = (projSeq[t2.projectId] || 0) + 1; seq = String(projSeq[t2.projectId]).padStart(3, "0"); } else { idxRoot++; seq = String(idxRoot).padStart(3, "0"); } const title = sanitize(data?.title || ""); const convFolderName = `${seq}_${title || "chat"}_${t2.id}`; const convFolder = baseFolder ? baseFolder.folder(convFolderName) : null; if (!convFolder) { continue; } convFolder.file("conversation.json", JSON.stringify(data, null, 2)); const convMeta = { id: data.conversation_id || t2.id, title: data.title || "", create_time: data.create_time, update_time: data.update_time, model_slug: data.default_model_slug, attachments: [], failed_attachments: [] }; if (includeAttachments) { const candidates = collectFileCandidates(data).map((x) => ({ ...x, project_id: t2.projectId || "" })); if (candidates.length > 0) { const attFolder = convFolder.folder("attachments"); const usedNames = new Set(); for (const c2 of candidates) { if (cancelRef && cancelRef.cancel) throw new Error("用户已取消"); const pointerKey = c2.pointer || c2.file_id || ""; const originalName = c2.meta && (c2.meta.name || c2.meta.file_name) || ""; let finalName = ""; try { const res = await downloadPointerOrFileAsBlob(c2); finalName = res.filename || `${sanitize(pointerKey) || "file"}.bin`; if (usedNames.has(finalName)) { let cnt = 2; while (usedNames.has(`${cnt}_${finalName}`)) cnt++; finalName = `${cnt}_${finalName}`; } usedNames.add(finalName); if (attFolder) attFolder.file(finalName, res.blob); convMeta.attachments.push({ pointer: c2.pointer || "", file_id: c2.file_id || "", original_name: originalName, saved_as: finalName, size_bytes: c2.meta?.size_bytes || c2.meta?.size || c2.meta?.file_size || c2.meta?.file_size_bytes || null, mime: res.mime || c2.meta?.mime_type || "", source: c2.source || "" }); } catch (e2) { const errorMsg = e2 && e2.message ? e2.message : String(e2); convMeta.failed_attachments.push({ pointer: c2.pointer || "", file_id: c2.file_id || "", error: errorMsg }); summary.failed.attachments.push({ conversation_id: data.conversation_id || t2.id, project_id: t2.projectId || "", pointer: c2.pointer || c2.file_id || "", error: errorMsg }); } } } } convFolder.file("metadata.json", JSON.stringify(convMeta, null, 2)); if (progressCb) progressCb(80 + Math.round((i2 + 1) / tasks.length * 15), `处理:${i2 + 1}/${tasks.length}`); } zip.file("summary.json", JSON.stringify(summary, null, 2)); if (progressCb) progressCb(98, "压缩中…"); const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 7 } }); return blob; } function BatchExportDialog({ onClose }) { const [loading, setLoading] = d(true); const [error, setError] = d(null); const [listData, setListData] = d(null); const [groups, setGroups] = d([]); const [selectedSet, setSelectedSet] = d( new Set()); const [includeAttachments, setIncludeAttachments] = d(true); const [exporting, setExporting] = d(false); const [progress, setProgress] = d(null); const [statusText, setStatusText] = d("加载会话列表…"); const cancelRef = A({ cancel: false }); const makeKey = (projectId2, id) => `${projectId2 || "root"}::${id}`; const parseKey = (key) => { const idx = key.indexOf("::"); const pid = key.slice(0, idx); const id = key.slice(idx + 2); return { id, projectId: pid === "root" ? null : pid }; }; y(() => { loadData(); }, []); const loadData = async () => { try { const res = await collectAllConversationTasks((pct, text) => { setProgress({ pct, text }); }); setListData(res); const newGroups = []; const rootsList = getRootsList(res); if (rootsList.length) { newGroups.push({ label: "无项目(个人会话)", projectId: null, items: rootsList, collapsed: false }); } (res.projects || []).forEach((p2) => { const convs = Array.isArray(p2.convs) ? p2.convs : []; newGroups.push({ label: p2.projectName || p2.projectId || "未命名项目", projectId: p2.projectId, items: convs, collapsed: false }); }); setGroups(newGroups); const initialSet = new Set(); rootsList.forEach((it) => initialSet.add(makeKey(null, it.id))); (res.projects || []).forEach((p2) => { (p2.convs || []).forEach((c2) => initialSet.add(makeKey(p2.projectId, c2.id))); }); setSelectedSet(initialSet); setLoading(false); setProgress(null); setStatusText(`共 ${newGroups.reduce((n, g) => n + g.items.length, 0)} 条,已选 ${initialSet.size}`); } catch (e2) { console.error("[ChatGPT-Multimodal-Exporter] 拉取列表失败", e2); setError(e2.message || String(e2)); setStatusText("拉取列表失败"); } }; const getRootsList = (data) => { if (data && Array.isArray(data.roots) && data.roots.length) return data.roots; if (data && Array.isArray(data.rootIds) && data.rootIds.length) return data.rootIds.map((id) => ({ id, title: id })); return []; }; const toggleGroupCollapse = (idx) => { const newGroups = [...groups]; newGroups[idx].collapsed = !newGroups[idx].collapsed; setGroups(newGroups); }; const toggleGroupSelect = (group, checked) => { const newSet = new Set(selectedSet); const keys = group.items.map((it) => makeKey(group.projectId, it.id)); if (checked) { keys.forEach((k2) => newSet.add(k2)); } else { keys.forEach((k2) => newSet.delete(k2)); } setSelectedSet(newSet); setStatusText(`已选 ${newSet.size} 条`); }; const toggleItemSelect = (key) => { const newSet = new Set(selectedSet); if (newSet.has(key)) newSet.delete(key); else newSet.add(key); setSelectedSet(newSet); setStatusText(`已选 ${newSet.size} 条`); }; const toggleAll = () => { if (!listData) return; const allKeys = []; groups.forEach((g) => g.items.forEach((it) => allKeys.push(makeKey(g.projectId, it.id)))); const allChecked = allKeys.every((k2) => selectedSet.has(k2)); const newSet = new Set(selectedSet); if (allChecked) { allKeys.forEach((k2) => newSet.delete(k2)); } else { allKeys.forEach((k2) => newSet.add(k2)); } setSelectedSet(newSet); setStatusText(`已选 ${newSet.size} 条`); }; const startExport = async () => { if (!listData) return; const tasks = Array.from(selectedSet).map((k2) => parseKey(k2)).filter((t2) => !!t2.id); if (!tasks.length) { alert("请至少选择一条会话"); return; } cancelRef.current.cancel = false; setExporting(true); setStatusText("准备导出…"); setProgress({ pct: 0, text: "准备中" }); const projectMapForTasks = new Map(); (listData.projects || []).forEach((p2) => projectMapForTasks.set(p2.projectId, p2)); const seenProj = new Set(); const selectedProjects = []; tasks.forEach((t2) => { if (!t2.projectId) return; if (seenProj.has(t2.projectId)) return; seenProj.add(t2.projectId); const p2 = projectMapForTasks.get(t2.projectId); if (p2) selectedProjects.push(p2); }); const selectedRootIds = tasks.filter((t2) => !t2.projectId).map((t2) => t2.id); try { const blob = await runBatchExport({ tasks, projects: selectedProjects, rootIds: selectedRootIds, includeAttachments, concurrency: BATCH_CONCURRENCY, progressCb: (pct, txt) => setProgress({ pct, text: txt }), cancelRef: cancelRef.current }); if (cancelRef.current.cancel) { setStatusText("已取消"); return; } const ts = ( new Date()).toISOString().replace(/[:.]/g, "-"); saveBlob(blob, `chatgpt-batch-${ts}.zip`); setProgress({ pct: 100, text: "完成" }); setStatusText("完成 ✅(已下载 ZIP)"); } catch (e2) { console.error("[ChatGPT-Multimodal-Exporter] 批量导出失败", e2); alert("批量导出失败:" + (e2 && e2.message ? e2.message : e2)); setStatusText("失败"); } finally { setExporting(false); cancelRef.current.cancel = false; } }; const handleStop = () => { cancelRef.current.cancel = true; setStatusText("请求取消中…"); }; return u$1("div", { className: "cgptx-modal", onClick: (e2) => e2.target === e2.currentTarget && onClose(), children: u$1("div", { className: "cgptx-modal-box", children: [ u$1("div", { className: "cgptx-modal-header", children: [ u$1("div", { className: "cgptx-modal-title", children: "批量导出对话(JSON + 附件)" }), u$1("div", { className: "cgptx-modal-actions", children: [ u$1("button", { className: "cgptx-btn", onClick: toggleAll, disabled: exporting || loading, children: "全选/反选" }), u$1("button", { className: "cgptx-btn primary", onClick: startExport, disabled: exporting || loading, children: "开始导出" }), u$1("button", { className: "cgptx-btn", onClick: handleStop, disabled: !exporting, children: "停止" }), u$1("button", { className: "cgptx-btn", onClick: onClose, children: "关闭" }) ] }) ] }), u$1("div", { className: "cgptx-chip", children: statusText }), u$1("div", { className: "cgptx-modal-actions", style: { justifyContent: "flex-start", alignItems: "center", flexWrap: "wrap", gap: "10px" }, children: u$1("label", { style: { display: "flex", alignItems: "center", gap: "6px" }, children: [ u$1( "input", { type: "checkbox", checked: includeAttachments, onChange: (e2) => setIncludeAttachments(e2.currentTarget.checked), disabled: exporting } ), u$1("span", { children: "包含附件(ZIP)" }) ] }) }), u$1("div", { className: "cgptx-list", style: { maxHeight: "46vh", overflow: "auto", border: "1px solid #e5e7eb", borderRadius: "10px" }, children: [ loading && u$1("div", { className: "cgptx-item", style: { display: "flex", justifyContent: "center", padding: "20px" }, children: progress ? u$1("div", { style: { width: "100%", textAlign: "center" }, children: [ u$1("div", { children: [ progress.text, " (", Math.round(progress.pct), "%)" ] }), u$1("div", { className: "cgptx-progress-track", style: { marginTop: "10px" }, children: u$1("div", { className: "cgptx-progress-bar", style: { width: `${progress.pct}%` } }) }) ] }) : u$1("div", { children: "加载中..." }) }), error && u$1("div", { className: "cgptx-item", style: { color: "red" }, children: error }), !loading && !error && groups.map((group, gIdx) => { const groupKeys = group.items.map((it) => makeKey(group.projectId, it.id)); const checkedCount = groupKeys.filter((k2) => selectedSet.has(k2)).length; const isAll = checkedCount === groupKeys.length && groupKeys.length > 0; const isIndeterminate = checkedCount > 0 && checkedCount < groupKeys.length; return u$1("div", { className: "cgptx-group", children: [ u$1("div", { className: "cgptx-group-header", children: [ u$1( "input", { type: "checkbox", checked: isAll, ref: (el) => { if (el) el.indeterminate = isIndeterminate; }, onChange: (e2) => toggleGroupSelect(group, e2.currentTarget.checked) } ), u$1( "span", { className: "cgptx-arrow", onClick: () => toggleGroupCollapse(gIdx), children: group.collapsed ? "▶" : "▼" } ), u$1("div", { className: "group-title", onClick: () => toggleGroupCollapse(gIdx), children: group.label }), u$1("div", { className: "group-count", children: [ group.items.length, " 条" ] }) ] }), u$1("div", { className: "cgptx-group-list", style: { display: group.collapsed ? "none" : "block" }, children: group.items.map((item) => { const key = makeKey(group.projectId, item.id); return u$1("div", { className: "cgptx-item", children: [ u$1( "input", { type: "checkbox", checked: selectedSet.has(key), onChange: () => toggleItemSelect(key) } ), u$1("div", {}), u$1("div", { children: u$1("div", { className: "title", children: item.title || item.id }) }) ] }, key); }) }) ] }, gIdx); }) ] }), !loading && progress && u$1("div", { className: "cgptx-progress-wrap", style: { display: "flex" }, children: [ u$1("div", { className: "cgptx-progress-track", children: u$1("div", { className: "cgptx-progress-bar", style: { width: `${progress.pct}%` } }) }), u$1("div", { className: "cgptx-progress-text", children: [ progress.text, " (", Math.round(progress.pct), "%)" ] }) ] }) ] }) }); } function showBatchExportDialog() { const root = document.createElement("div"); document.body.appendChild(root); const close = () => { preact.render(null, root); root.remove(); }; preact.render(preact.h(BatchExportDialog, { onClose: close }), root); } function FilePreviewDialog({ candidates, onConfirm, onClose }) { const [selectedIndices, setSelectedIndices] = d( new Set(candidates.map((_, i2) => i2)) ); const toggleSelect = (idx) => { const next = new Set(selectedIndices); if (next.has(idx)) next.delete(idx); else next.add(idx); setSelectedIndices(next); }; const toggleAll = () => { if (selectedIndices.size === candidates.length) { setSelectedIndices( new Set()); } else { setSelectedIndices(new Set(candidates.map((_, i2) => i2))); } }; const handleConfirm = () => { const selected = candidates.filter((_, i2) => selectedIndices.has(i2)); if (selected.length === 0) { alert("请至少选择一个文件"); return; } onConfirm(selected); onClose(); }; return u$1("div", { className: "cgptx-modal", onClick: (e2) => e2.target === e2.currentTarget && onClose(), children: u$1("div", { className: "cgptx-modal-box", children: [ u$1("div", { className: "cgptx-modal-header", children: [ u$1("div", { className: "cgptx-modal-title", children: [ "可下载文件 (", candidates.length, ")" ] }), u$1("div", { className: "cgptx-modal-actions", children: [ u$1("button", { className: "cgptx-btn", onClick: toggleAll, children: "全选/反选" }), u$1("button", { className: "cgptx-btn primary", onClick: handleConfirm, children: "下载选中" }), u$1("button", { className: "cgptx-btn", onClick: onClose, children: "关闭" }) ] }) ] }), u$1("div", { className: "cgptx-list", children: candidates.map((info, idx) => { const name = info.meta && (info.meta.name || info.meta.file_name) || info.file_id || info.pointer || "未命名"; const mime = info.meta && (info.meta.mime_type || info.meta.file_type) || info.meta && info.meta.mime || ""; const size = info.meta?.size_bytes || info.meta?.size || info.meta?.file_size || info.meta?.file_size_bytes || null; const metaParts = []; metaParts.push(`来源: ${info.source || "未知"}`); if (info.file_id) metaParts.push(`file_id: ${info.file_id}`); if (info.pointer && info.pointer !== info.file_id) metaParts.push(`pointer: ${info.pointer}`); if (mime) metaParts.push(`mime: ${mime}`); if (size) metaParts.push(`大小: ${formatBytes(size)}`); return u$1("div", { className: "cgptx-item", children: [ u$1( "input", { type: "checkbox", checked: selectedIndices.has(idx), onChange: () => toggleSelect(idx) } ), u$1("div", {}), u$1("div", { children: [ u$1("div", { className: "title", children: name }), u$1("div", { className: "meta", children: metaParts.join(" • ") }) ] }) ] }, idx); }) }), u$1("div", { className: "cgptx-modal-actions", style: { justifyContent: "flex-end" }, children: u$1("div", { className: "cgptx-chip", children: "点击“下载选中”将按列表顺序依次下载(含 /files 和 CDN 指针)" }) }) ] }) }); } function showFilePreviewDialog(candidates, onConfirm) { const root = document.createElement("div"); document.body.appendChild(root); const close = () => { preact.render(null, root); root.remove(); }; preact.render(preact.h(FilePreviewDialog, { candidates, onConfirm, onClose: close }), root); } function FloatingEntry() { const [status, setStatus] = d({ hasToken: false, hasAcc: false, debug: "" }); const [jsonBusy, setJsonBusy] = d(false); const [filesBusy, setFilesBusy] = d(false); const [jsonTitle, setJsonTitle] = d("导出当前对话 JSON"); const [filesTitle, setFilesTitle] = d("下载当前对话中可识别的文件/指针"); const lastConvData = A(null); const refreshCredStatus = async () => { await Cred.ensureViaSession(); await Cred.ensureAccountId(); setStatus({ hasToken: !!Cred.token, hasAcc: !!Cred.accountId, debug: Cred.debug }); }; y(() => { refreshCredStatus(); const timer = setInterval(refreshCredStatus, 60 * 1e3); return () => clearInterval(timer); }, []); const handleJsonExport = async () => { const id = convId(); const pid = projectId(); if (!id) { alert("未检测到会话 ID,请在具体对话页面使用(URL 中应包含 /c/xxxx)。"); return; } setJsonBusy(true); setJsonTitle("导出中…"); try { await refreshCredStatus(); if (!Cred.token) throw new Error("没有有效的 accessToken"); const data = await fetchConversation(id, pid || void 0); lastConvData.current = data; extractImages(data); const title = sanitize(data?.title || ""); const filename = `${title || "chat"}_${id}.json`; saveJSON(data, filename); setJsonTitle("导出完成 ✅(点击可重新导出)"); } catch (e2) { console.error("[ChatGPT-Multimodal-Exporter] 导出失败:", e2); alert("导出失败: " + (e2 && e2.message ? e2.message : e2)); setJsonTitle("导出失败 ❌(点击重试)"); } finally { setJsonBusy(false); } }; const handleFilesDownload = async () => { const id = convId(); const pid = projectId(); if (!id) { alert("未检测到会话 ID,请在具体对话页面使用(URL 中应包含 /c/xxxx)。"); return; } setFilesBusy(true); setFilesTitle("下载文件中…"); try { await refreshCredStatus(); if (!Cred.token) throw new Error("没有有效的 accessToken"); let data = lastConvData.current; if (!data || data.conversation_id !== id) { data = await fetchConversation(id, pid || void 0); lastConvData.current = data; } const cands = collectFileCandidates(data); if (!cands.length) { alert("未找到可下载的文件/指针。"); setFilesTitle("未找到文件"); return; } showFilePreviewDialog(cands, async (selected) => { setFilesBusy(true); setFilesTitle(`下载中 (${selected.length})…`); try { const res = await downloadSelectedFiles(selected); setFilesTitle(`完成 ${res.ok}/${res.total}(可再次点击)`); alert(`文件下载完成,成功 ${res.ok}/${res.total},详情见控制台。`); } catch (e2) { console.error("[ChatGPT-Multimodal-Exporter] 下载失败:", e2); alert("下载失败: " + (e2 && e2.message ? e2.message : e2)); setFilesTitle("下载失败 ❌"); } finally { setFilesBusy(false); } }); setFilesBusy(false); } catch (e2) { console.error("[ChatGPT-Multimodal-Exporter] 下载失败:", e2); alert("下载失败: " + (e2 && e2.message ? e2.message : e2)); setFilesTitle("下载失败 ❌"); setFilesBusy(false); } }; const handleBatchExport = () => { showBatchExportDialog(); }; const isOk = status.hasToken && status.hasAcc; return u$1("div", { className: "cgptx-mini-wrap", children: [ u$1( "div", { className: `cgptx-mini-badge ${isOk ? "ok" : "bad"}`, id: "cgptx-mini-badge", title: status.debug, children: `Token: ${status.hasToken ? "✔" : "✖"} / Account: ${status.hasAcc ? "✔" : "✖"}` } ), u$1("div", { className: "cgptx-mini-btn-row", children: [ u$1( "button", { id: "cgptx-mini-btn", className: "cgptx-mini-btn", title: jsonTitle, onClick: handleJsonExport, disabled: jsonBusy, children: u$1("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", children: [ u$1("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), u$1("polyline", { points: "7 10 12 15 17 10" }), u$1("line", { x1: "12", y1: "15", x2: "12", y2: "3" }) ] }) } ), u$1( "button", { id: "cgptx-mini-btn-files", className: "cgptx-mini-btn", title: filesTitle, onClick: handleFilesDownload, disabled: filesBusy, children: u$1("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", children: [ u$1("path", { d: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" }), u$1("polyline", { points: "3.27 6.96 12 12.01 20.73 6.96" }), u$1("line", { x1: "12", y1: "22.08", x2: "12", y2: "12" }) ] }) } ), u$1( "button", { id: "cgptx-mini-btn-batch", className: "cgptx-mini-btn", title: "批量导出 JSON + 附件(可勾选)", onClick: handleBatchExport, children: u$1("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", children: u$1("path", { d: "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" }) }) } ) ] }) ] }); } function mountUI() { if (!isHostOK()) return; if (document.querySelector(".cgptx-mini-wrap")) return; const root = document.createElement("div"); document.body.appendChild(root); preact.render(preact.h(FloatingEntry, null), root); } function boot() { if (!isHostOK()) return; if (document.readyState === "complete" || document.readyState === "interactive") { mountUI(); } else { document.addEventListener("DOMContentLoaded", mountUI); } } boot(); })(preact, JSZip);