ChatGPT-Multimodal-Exporter

导出 ChatGPT 对话 json + 会话中的多模态文件(图片、音频、sandbox 文件等)

目前為 2025-12-05 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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);