Chat Export Toolkit

Export/copy current Yuanbao conversation and export all conversations as ZIP (MD/JSON/DOCX)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Chat Export Toolkit
// @namespace    https://github.com/gandli/chat-export-toolkit
// @version      0.6.0
// @description  Export/copy current Yuanbao conversation and export all conversations as ZIP (MD/JSON/DOCX)
// @author       gandli
// @match        *://yuanbao.tencent.com/*
// @match        *://*.yuanbao.tencent.com/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // API 端点缓存(动态发现 + 回退)
  let API_ENDPOINTS = {
    detail: null,
    list: null,
    discovered: false,
  };

  // 正则表达式用于拦截响应(支持任意版本 v\d+ 和新结构)
  const YUANBAO_DETAIL_RE = /\/api\/(?:user\/agent\/)?conversation\/v?\d*\/?detail/;
  const YUANBAO_LIST_RE = /\/api\/(?:user\/agent\/)?conversation\/v?\d*\/?(?:list|page|list_page)/;

  const state = {
    current: null,
    captured: new Map(),
    listHints: new Map(),
    ui: {
      busy: false,
      panelOpen: false,
      progress: { show: false, text: '', percent: 0 },
    },
  };

  const Utils = {
    nowStamp() {
      const d = new Date();
      const offset = d.getTimezoneOffset() * 60 * 1000;
      return new Date(d.getTime() - offset)
        .toISOString()
        .slice(0, 19)
        .replace('T', '_')
        .replace(/:/g, '-');
    },

    formatTimestamp(ts) {
      if (!ts) return '';
      try {
        const n = typeof ts === 'string' ? Number.parseInt(ts, 10) : ts;
        const d = new Date(typeof n === 'number' && n < 1e12 ? n * 1000 : n);
        return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
      } catch {
        return '';
      }
    },

    sanitizeFilename(name) {
      return String(name || 'export').replace(/[\/\\?%*:|"<>]/g, '-').trim();
    },

    download(content, mime, filename) {
      const blob = content instanceof Blob ? content : new Blob([content], { type: mime });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 500);
    },

    async copyText(text) {
      const val = String(text || '');
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(val);
        return;
      }
      const ta = document.createElement('textarea');
      ta.value = val;
      ta.style.position = 'fixed';
      ta.style.opacity = '0';
      document.body.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      ta.remove();
    },

    xmlEscape(text) {
      return String(text)
        .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');
    },

    adjustHeaderLevels(text, increaseBy = 1) {
      if (!text) return '';
      return String(text).replace(/^(#+)(\s*)(.*?)\s*$/gm, (_m, hashes, _space, content) => {
        return '#'.repeat(hashes.length + increaseBy) + ' ' + String(content).trim();
      });
    },

    extractConversationId(url) {
      try {
        const u = new URL(url, window.location.href);
        return (
          u.searchParams.get('conversationId') ||
          u.searchParams.get('conversation_id') ||
          u.searchParams.get('id') ||
          ''
        );
      } catch {
        const m = String(url).match(/[?&](?:conversationId|conversation_id|id)=([^&]+)/);
        return m ? decodeURIComponent(m[1]) : '';
      }
    },
  };

  function mdToDocxParagraphs(markdown) {
    const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
    const out = [];
    let inCode = false;

    const p = (text, style = '') => {
      const s = style ? `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>` : '';
      return `<w:p>${s}<w:r><w:t xml:space="preserve">${Utils.xmlEscape(text)}</w:t></w:r></w:p>`;
    };

    for (const raw of lines) {
      const line = raw ?? '';

      if (/^```/.test(line.trim())) {
        inCode = !inCode;
        continue;
      }

      if (inCode) {
        out.push(p(line, 'CodeBlock'));
        continue;
      }

      const h = line.match(/^(#{1,6})\s+(.*)$/);
      if (h) {
        const level = Math.min(3, h[1].length);
        out.push(p(h[2], `Heading${level}`));
        continue;
      }

      const bullet = line.match(/^\s*[-*]\s+(.*)$/);
      if (bullet) {
        out.push(p(`• ${bullet[1]}`, 'Normal'));
        continue;
      }

      const quote = line.match(/^>\s?(.*)$/);
      if (quote) {
        out.push(p(`❝ ${quote[1]}`, 'Quote'));
        continue;
      }

      if (!line.trim()) {
        out.push('<w:p/>');
        continue;
      }

      out.push(p(line, 'Normal'));
    }

    return out;
  }

  async function buildDocxBlob(markdownText) {
    if (typeof JSZip === 'undefined') throw new Error('JSZip missing');

    const paragraphs = mdToDocxParagraphs(markdownText);

    const documentXml =
      `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
      `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
      `<w:body>` +
      paragraphs.join('') +
      `<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr>` +
      `</w:body></w:document>`;

    const stylesXml =
      `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
      `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
      `<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>` +
      `<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:sz w:val="40"/></w:rPr></w:style>` +
      `<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>` +
      `<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style>` +
      `<w:style w:type="paragraph" w:styleId="CodeBlock"><w:name w:val="Code Block"/><w:basedOn w:val="Normal"/><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="20"/></w:rPr></w:style>` +
      `<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="420"/></w:pPr><w:rPr><w:i/></w:rPr></w:style>` +
      `</w:styles>`;

    const contentTypesXml =
      `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
      `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
      `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
      `<Default Extension="xml" ContentType="application/xml"/>` +
      `<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
      `<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>` +
      `</Types>`;

    const relsXml =
      `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
      `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
      `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
      `</Relationships>`;

    const docRelsXml =
      `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
      `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
      `<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` +
      `</Relationships>`;

    const zip = new JSZip();
    zip.file('[Content_Types].xml', contentTypesXml);
    zip.folder('_rels').file('.rels', relsXml);
    const word = zip.folder('word');
    word.file('document.xml', documentXml);
    word.file('styles.xml', stylesXml);
    word.folder('_rels').file('document.xml.rels', docRelsXml);

    return zip.generateAsync({
      type: 'blob',
      mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    });
  }

  function yuanbaoToMarkdown(data) {
    const title = data?.sessionTitle || data?.title || 'Yuanbao Chat';
    const out = [];
    out.push(`# ${title}`);
    out.push('');
    out.push(`> Exported at: ${new Date().toLocaleString()}`);
    out.push('');

    const convs = Array.isArray(data?.convs) ? [...data.convs] : [];
    convs.sort((a, b) => (a?.index || 0) - (b?.index || 0));

    for (const turn of convs) {
      const speaker = String(turn?.speaker || '').toLowerCase();
      const role = speaker === 'ai' ? 'Assistant' : speaker === 'user' || speaker === 'human' ? 'User' : speaker || 'Unknown';
      const idx = turn?.index != null ? ` (Turn ${turn.index})` : '';
      const ts = Utils.formatTimestamp(turn?.createTime);

      out.push(`## ${role}${idx}`);
      if (ts) out.push(`*${ts}*`);
      out.push('');

      const blocks = [];
      const speeches = Array.isArray(turn?.speechesV2) ? turn.speechesV2 : [];
      for (const speech of speeches) {
        const content = Array.isArray(speech?.content) ? speech.content : [];
        for (const block of content) {
          if (block?.type === 'text') blocks.push(Utils.adjustHeaderLevels(block?.msg || '', 1));
          else if (block?.type === 'think') {
            const t = block?.title ? `> [Think] ${block.title}` : `> [Think]`;
            const body = String(block?.content || '').replace(/\n/g, '\n> ');
            blocks.push(`${t}\n> ${body}`);
          } else if (block?.msg) blocks.push(String(block.msg));
          else blocks.push('`[Unsupported block]`');
        }
      }

      const body = blocks.join('\n\n').trim();
      out.push(body || '_No content_');
      out.push('');
      out.push('---');
      out.push('');
    }

    return out.join('\n').trim() + '\n';
  }

  function getFilename(scope, format, title) {
    const safeTitle = Utils.sanitizeFilename(title || 'Yuanbao');
    const prefix = scope === 'all' ? 'ALL_' : 'CURRENT_';
    return `${prefix}Yuanbao_${safeTitle}_${Utils.nowStamp()}.${format}`;
  }

  async function fetchJson(url, options = {}) {
    const res = await fetch(url, {
      credentials: 'include',
      ...options,
      headers: { Accept: 'application/json, text/plain, */*', ...(options.headers || {}) },
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }

  function pickArray(obj, candidates) {
    for (const key of candidates) {
      const val = obj?.[key];
      if (Array.isArray(val)) return val;
    }
    return [];
  }

  function getConvId(item) {
    return (
      item?.conversationId ||
      item?.conversation_id ||
      item?.convId ||
      item?.conversationUuid ||
      item?.sessionId ||
      item?.chatId ||
      item?.id ||
      ''
    );
  }

  function getConvTitle(item) {
    return item?.title || item?.sessionTitle || item?.name || item?.conversationTitle || item?.summary || 'Yuanbao Chat';
  }

  function setBusy(v) {
    state.ui.busy = !!v;
    const panel = document.getElementById('cet-panel');
    if (panel) panel.classList.toggle('is-busy', state.ui.busy);
    updateUiState();
  }

  function setProgress(show, text = '', percent = 0) {
    state.ui.progress = { show, text, percent: Math.max(0, Math.min(100, percent || 0)) };
    const box = document.getElementById('cet-progress');
    const label = document.getElementById('cet-progress-text');
    const bar = document.getElementById('cet-progress-bar');
    if (!box || !label || !bar) return;
    box.style.display = show ? 'block' : 'none';
    label.textContent = text || '';
    bar.style.width = `${state.ui.progress.percent}%`;
  }

  function showToast(text) {
    const el = document.getElementById('cet-toast');
    if (!el) return;
    el.textContent = text;
    el.classList.add('show');
    clearTimeout(showToast._timer);
    showToast._timer = setTimeout(() => el.classList.remove('show'), 1500);
  }

  function updateUiState() {
    const count = state.captured.size;
    const status = document.getElementById('cet-status');
    if (status) {
      status.textContent = state.current ? `已就绪 · 已缓存 ${count} 个会话` : `等待对话数据 · 已缓存 ${count} 个会话`;
    }

    const badge = document.getElementById('cet-fab-badge');
    if (badge) badge.textContent = String(count);

    const hasCurrent = !!state.current;
    const disable = (id, cond) => {
      const el = document.getElementById(id);
      if (el) el.disabled = cond || state.ui.busy;
    };
    disable('cet-current-save-md', !hasCurrent);
    disable('cet-current-save-json', !hasCurrent);
    disable('cet-current-save-docx', !hasCurrent);
    disable('cet-current-copy-md', !hasCurrent);
    disable('cet-current-copy-json', !hasCurrent);
    disable('cet-all-md', false);
    disable('cet-all-json', false);
    disable('cet-all-docx', false);
  }

  function collectConversationMetasFromJson(node, out, seen) {
    if (!node || typeof node !== 'object') return;

    if (Array.isArray(node)) {
      for (const x of node) collectConversationMetasFromJson(x, out, seen);
      return;
    }

    const maybeId = String(getConvId(node) || '').trim();
    if (maybeId && !seen.has(maybeId)) {
      seen.add(maybeId);
      out.push({ id: maybeId, title: getConvTitle(node) });
    }

    for (const v of Object.values(node)) {
      if (v && typeof v === 'object') collectConversationMetasFromJson(v, out, seen);
    }
  }

  function collectConversationMetasFromDom(out, seen) {
    const nodes = document.querySelectorAll('a[href*="/chat/"]');
    for (const a of nodes) {
      const href = a.getAttribute('href') || '';
      const m = href.match(/\/chat\/([^/?#]+)/);
      if (!m) continue;
      const id = decodeURIComponent(m[1]);
      if (!id || seen.has(id)) continue;
      seen.add(id);
      const text = (a.textContent || '').trim();
      out.push({ id, title: text || 'Yuanbao Chat' });
    }
  }

  // 动态发现 API 端点
  async function discoverApiEndpoints() {
    if (API_ENDPOINTS.discovered) return API_ENDPOINTS;

    // 首先尝试从已拦截的请求中选择端点
    const endpoints = {
      detail: selectBestEndpoint('detail'),
      list: selectBestEndpoint('list'),
    };

    try {
      // 如果从拦截请求中没有找到端点,则尝试从页面 JS 资源中提取
      if (!endpoints.detail || !endpoints.list) {
        const scripts = Array.from(document.querySelectorAll('script[src]'));
        const jsUrls = scripts.map(s => s.src).filter(src => src.includes('.js'));

        // 检查内联脚本
        const inlineScripts = Array.from(document.querySelectorAll('script:not([src])'));
        const inlineContents = inlineScripts.map(s => s.textContent).filter(Boolean);

        // 从 JS 文件内容中提取 API 模式 - 使用更广泛的模式
        const apiPatterns = [
          /["'`]\/api\/[^"']*conversation[^"']*detail["'`]/gi,
          /["'`]\/api\/[^"']*conversation[^"']*\/(?:list|page|list_page)["'`]/gi,
          /["'`]\/api\/[^"']*\/detail["'`]/gi,
          /["'`]\/api\/[^"']*\/(?:list|page|list_page)["'`]/gi,
        ];

        // 尝试从内联脚本中提取
        for (const content of inlineContents) {
          for (const pattern of apiPatterns) {
            const matches = content.match(pattern);
            if (matches) {
              for (const match of matches) {
                const path = match.replace(/["'`]/g, '');
                if (path.includes('/detail') && !endpoints.detail) {
                  endpoints.detail = path;
                  console.log('[Chat Export] Found detail API in inline script:', path);
                } else if (path.includes('/list') || path.includes('/page') || path.includes('/list_page')) {
                  if (!endpoints.list || path.includes('/list')) {
                    endpoints.list = path;
                    console.log('[Chat Export] Found list API in inline script:', path);
                  }
                }
              }
            }
          }
        }

        // 如果内联脚本没有找到,尝试从外部 JS 文件提取(采样前 5 个)
        if (!endpoints.detail || !endpoints.list) {
          const sampleScripts = jsUrls.slice(0, 5);
          for (const scriptUrl of sampleScripts) {
            try {
              const response = await fetch(scriptUrl, { signal: AbortSignal.timeout(3000) });
              const text = await response.text();

              for (const pattern of apiPatterns) {
                const matches = text.match(pattern);
                if (matches) {
                  for (const match of matches) {
                    const path = match.replace(/["'`]/g, '');
                    if (path.includes('/detail') && !endpoints.detail) {
                      endpoints.detail = path;
                      console.log('[Chat Export] Found detail API in external script:', path);
                    } else if ((path.includes('/list') || path.includes('/page')) && !endpoints.list) {
                      endpoints.list = path;
                      console.log('[Chat Export] Found list API in external script:', path);
                    }
                  }
                }
              }

              if (endpoints.detail && endpoints.list) break;
            } catch (err) {
              console.log('[Chat Export] Failed to load JS file:', scriptUrl, err?.message);
            }
          }
        }
      }
      
      // 检查拦截到的API请求
      if (!endpoints.detail && state.captured.size > 0) {
        // 如果已经有捕获的对话,尝试从中推断API端点
        const capturedEntry = state.captured.entries().next().value;
        if (capturedEntry) {
          // 这里可以尝试反向推断API端点,但需要更多信息
        }
      }
      
      if (!endpoints.list && state.listHints.size > 0) {
        // 如果已经有列表提示,尝试从中推断API端点
      }
    } catch (err) {
      console.log('[Chat Export] API discovery error:', err?.message);
    }

    // 如果仍然没有找到端点,使用回退探测
    if (!endpoints.detail) {
      console.log('[Chat Export] Using fallback probe for detail API');
      endpoints.detail = await probeDetailApi();
    }
    if (!endpoints.list) {
      console.log('[Chat Export] Using fallback probe for list API');
      endpoints.list = await probeListApi();
    }

    console.log('[Chat Export] Discovered API endpoints:', endpoints);
    API_ENDPOINTS = { ...endpoints, discovered: true };
    return API_ENDPOINTS;
  }

  // 探测 detail API 端点
  async function probeDetailApi() {
    // 首先尝试使用已发现的端点
    const knownEndpoint = selectBestEndpoint('detail');
    if (knownEndpoint) {
      // 提取路径部分(去掉域名)
      const pathOnly = new URL(knownEndpoint, window.location.origin).pathname;
      console.log('[Chat Export] Using known detail endpoint:', pathOnly);
      return pathOnly;
    }

    // 如果没有已知端点,则尝试常见模式
    const candidates = [
      '/api/user/agent/conversation/v2/detail',
      '/api/user/agent/conversation/v1/detail',
      '/api/conversation/v2/detail',
      '/api/conversation/v1/detail',
      // 新增可能的端点模式
      '/api/user/agent/conversation/detail',
      '/api/conversation/detail',
      '/api/v1/user/agent/conversation/detail',
      '/api/v2/user/agent/conversation/detail',
      '/api/conversation/detail/v1',
      '/api/conversation/detail/v2',
    ];

    for (const endpoint of candidates) {
      try {
        const response = await fetch(endpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ conversationId: 'probe' }),
          signal: AbortSignal.timeout(5000),
        });
        // 404 表示端点存在但参数错误,非 404 表示可能是正确端点
        // 405 表示端点存在但方法不允许,也可能是正确端点
        if (response.status !== 404 && response.status !== 405) {
          return endpoint;
        }
      } catch {
        // 继续尝试下一个
      }
    }
    return candidates[0]; // 默认回退
  }

  // 探测 list API 端点
  async function probeListApi() {
    // 首先尝试使用已发现的端点
    const knownEndpoint = selectBestEndpoint('list');
    if (knownEndpoint) {
      // 提取路径部分(去掉域名)
      const pathOnly = new URL(knownEndpoint, window.location.origin).pathname;
      console.log('[Chat Export] Using known list endpoint:', pathOnly);
      return pathOnly;
    }

    const candidates = [
      '/api/user/agent/conversation/v2/list',
      '/api/user/agent/conversation/v2/page',
      '/api/user/agent/conversation/v1/list',
      '/api/user/agent/conversation/v1/page',
      '/api/conversation/v2/list',
      '/api/conversation/v1/list',
      // 新增可能的端点模式
      '/api/user/agent/conversation/list',
      '/api/user/agent/conversation/page',
      '/api/conversation/list',
      '/api/conversation/page',
      '/api/v1/user/agent/conversation/list',
      '/api/v2/user/agent/conversation/list',
      '/api/v1/user/agent/conversation/page',
      '/api/v2/user/agent/conversation/page',
      '/api/conversation/list/v1',
      '/api/conversation/list/v2',
      '/api/conversation/page/v1',
      '/api/conversation/page/v2',
    ];

    for (const endpoint of candidates) {
      try {
        const response = await fetch(`${endpoint}?page=1&pageSize=1`, {
          signal: AbortSignal.timeout(5000),
        });
        // 404 表示端点存在但参数错误,405 表示方法不允许但端点存在
        if (response.status !== 404 && response.status !== 405) {
          return endpoint;
        }
      } catch {
        // 继续尝试下一个
      }
    }
    return candidates[0]; // 默认回退
  }

  async function fetchAllConversationMetas() {
    const all = [];
    const seen = new Set();

    // 首先尝试动态发现 API 端点
    const apiEndpoints = await discoverApiEndpoints();
    const listEndpoint = apiEndpoints.list;

    // 构建候选端点列表:动态发现的端点优先
    const baseCandidates = [];
    if (listEndpoint) {
      // 从发现的路径中提取基础路径(去除查询参数)
      const basePath = listEndpoint.split('?')[0];
      baseCandidates.push(basePath);
    }
    // 添加回退端点
    baseCandidates.push(
      '/api/user/agent/conversation/v2/list',
      '/api/user/agent/conversation/v2/page',
      '/api/user/agent/conversation/v2/list_page',
      '/api/user/agent/conversation/v1/list',
      '/api/user/agent/conversation/v1/page',
      '/api/user/agent/conversation/v1/list_page',
      '/api/conversation/v2/list',
      '/api/conversation/v2/page',
      '/api/conversation/v1/list',
      '/api/conversation/v1/page',
      // 新增可能的端点
      '/api/user/agent/conversation/list',
      '/api/user/agent/conversation/page',
      '/api/conversation/list',
      '/api/conversation/page',
      '/api/v1/user/agent/conversation/list',
      '/api/v2/user/agent/conversation/list',
      '/api/v1/user/agent/conversation/page',
      '/api/v2/user/agent/conversation/page',
      '/api/conversation/list/v1',
      '/api/conversation/list/v2',
      '/api/conversation/page/v1',
      '/api/conversation/page/v2',
    );

    // 去重
    const uniqueCandidates = [...new Set(baseCandidates)];

    for (const base of uniqueCandidates) {
      let hit = false;
      // 增加分页上限到 200,但添加智能检测
      for (let page = 1; page <= 200; page += 1) {
        const postCandidates = [
          { page, pageSize: 50 },
          { pageNum: page, pageSize: 50 },
          { offset: (page - 1) * 50, limit: 50 },
          { cursor: String(page), size: 50 },
          // 新增可能的参数格式
          { page, size: 50 },
          { pageIndex: page, pageSize: 50 },
          { skip: (page - 1) * 50, limit: 50 },
          { start: (page - 1) * 50, count: 50 },
        ];

        let before = all.length;

        // 优先尝试POST方法(腾讯元宝API主要是POST)
        for (const body of postCandidates) {
          try {
            const json = await fetchJson(base, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify(body),
            });
            // 尝试更多可能的数据结构
            const conversations = 
              pickArray(json, ['conversations', 'data', 'result', 'response', 'payload']) ||
              pickArray(json?.data, ['conversations', 'result', 'response', 'payload']) ||
              pickArray(json?.result, ['conversations', 'data', 'response', 'payload']) ||
              pickArray(json?.response, ['conversations', 'data', 'result', 'payload']) ||
              pickArray(json?.payload, ['conversations', 'data', 'result', 'response']);
            
            if (conversations.length > 0) {
              collectConversationMetasFromJson(conversations, all, seen);
            } else {
              collectConversationMetasFromJson(json, all, seen);
            }
            hit = true;
            if (all.length > before) break;
          } catch (err) {
            // 如果POST失败,记录错误但继续尝试其他参数
            continue;
          }
        }

        // 如果POST成功获取到数据,跳出循环
        if (all.length > before) break;

        // 如果POST方法失败,再尝试GET方法作为备选
        const getCandidates = [
          `${base}?page=${page}&pageSize=50`,
          `${base}?pageNum=${page}&pageSize=50`,
          `${base}?offset=${(page - 1) * 50}&limit=50`,
          `${base}?cursor=${encodeURIComponent(String(page))}&size=50`,
          // 新增可能的参数格式
          `${base}?page=${page}&size=50`,
          `${base}?pageIndex=${page}&pageSize=50`,
          `${base}?skip=${(page - 1) * 50}&limit=50`,
          `${base}?start=${(page - 1) * 50}&count=50`,
        ];

        for (const u of getCandidates) {
          try {
            const json = await fetchJson(u);
            collectConversationMetasFromJson(json, all, seen);
            hit = true;
            // 如果此页没有新数据,可能已经到达最后一页
            if (all.length === before) {
              // 尝试其他参数格式
              continue;
            }
            break;
          } catch (err) {
            // 记录第一个页面的错误用于调试
            if (page === 1 && err?.message?.includes('404')) {
              console.log('[Chat Export] API endpoint failed (404):', u, err.message);
            }
          }
        }

        // 智能检测:如果连续 2 页没有新数据,停止
        if (all.length === before && page > 2) break;
        // 第一页没有数据且没有命中,停止
        if (all.length === before && page === 1 && !hit) break;
      }

      // 如果已经获取到数据,停止尝试其他端点
      if (all.length > 0) break;
    }

    // list intercept cache
    state.listHints.forEach((title, id) => {
      if (seen.has(id)) return;
      seen.add(id);
      all.push({ id, title });
    });

    // current captured cache
    state.captured.forEach((rec) => {
      if (seen.has(rec.id)) return;
      seen.add(rec.id);
      all.push({ id: rec.id, title: rec.title });
    });

    // DOM fallback for /chat page links
    collectConversationMetasFromDom(all, seen);

    return all;
  }

  async function fetchConversationDetailById(id) {
    // 首先尝试获取动态发现的 API 端点
    const apiEndpoints = await discoverApiEndpoints();
    const detailEndpoint = apiEndpoints.detail;

    const bodyCandidates = [
      { conversationId: id },
      { conversation_id: id },
      { sessionId: id },
      { chatId: id },
      { id },
      // 新增可能的参数格式
      { conversation_uuid: id },
      { conversationUuid: id },
      { session_id: id },
      { chat_id: id },
    ];

    // 构建候选端点列表:动态发现的端点优先
    const detailPaths = [];
    if (detailEndpoint) {
      const basePath = detailEndpoint.split('?')[0];
      detailPaths.push(basePath);
    }
    detailPaths.push(
      '/api/user/agent/conversation/v2/detail',
      '/api/user/agent/conversation/v1/detail',
      '/api/conversation/v2/detail',
      '/api/conversation/v1/detail',
      // 新增可能的端点
      '/api/user/agent/conversation/detail',
      '/api/conversation/detail',
      '/api/v1/user/agent/conversation/detail',
      '/api/v2/user/agent/conversation/detail',
      '/api/conversation/detail/v1',
      '/api/conversation/detail/v2',
    );

    // 去重
    const uniquePaths = [...new Set(detailPaths)];

    // 优先尝试POST方法(腾讯元宝API主要是POST)
    for (const path of uniquePaths) {
      for (const body of bodyCandidates) {
        try {
          const json = await fetchJson(path, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
          });
          // 检查更多可能的响应结构
          if (Array.isArray(json?.convs)) return json;
          if (Array.isArray(json?.data?.convs)) return json.data;
          if (Array.isArray(json?.result?.convs)) return json.result;
          if (Array.isArray(json?.response?.convs)) return json.response;
          if (Array.isArray(json?.payload?.convs)) return json.payload;
          if (Array.isArray(json?.data?.data?.convs)) return json.data.data;
          if (Array.isArray(json?.result?.result?.convs)) return json.result.result;
        } catch {
          // 继续尝试下一个参数组合
          continue;
        }
      }
    }

    // 如果POST方法失败,尝试GET方法作为回退
    const getBasePaths = [];
    if (detailEndpoint) {
      getBasePaths.push(detailEndpoint.split('?')[0]);
    }
    getBasePaths.push(
      '/api/user/agent/conversation/v2/detail',
      '/api/user/agent/conversation/v1/detail',
      '/api/conversation/v2/detail',
      '/api/conversation/v1/detail',
      // 新增可能的端点
      '/api/user/agent/conversation/detail',
      '/api/conversation/detail',
      '/api/v1/user/agent/conversation/detail',
      '/api/v2/user/agent/conversation/detail',
      '/api/conversation/detail/v1',
      '/api/conversation/detail/v2',
    );

    const uniqueGetPaths = [...new Set(getBasePaths)];
    const paramNames = ['conversationId', 'conversation_id', 'sessionId', 'chatId', 'id', 'conversation_uuid', 'conversationUuid', 'session_id', 'chat_id'];

    for (const basePath of uniqueGetPaths) {
      for (const paramName of paramNames) {
        const u = `${basePath}?${paramName}=${encodeURIComponent(id)}`;
        try {
          const json = await fetchJson(u);
          // 检查更多可能的响应结构
          if (Array.isArray(json?.convs)) return json;
          if (Array.isArray(json?.data?.convs)) return json.data;
          if (Array.isArray(json?.result?.convs)) return json.result;
          if (Array.isArray(json?.response?.convs)) return json.response;
          if (Array.isArray(json?.payload?.convs)) return json.payload;
          if (Array.isArray(json?.data?.data?.convs)) return json.data.data;
          if (Array.isArray(json?.result?.result?.convs)) return json.result.result;
        } catch {
          // next
        }
      }
    }

    throw new Error(`无法获取会话详情:${id}`);
  }

  async function ensureAllConversationsLoaded(onProgress) {
    const metas = await fetchAllConversationMetas();
    if (!metas.length) throw new Error('未发现任何会话(请先在左侧会话列表滚动加载更多)');

    let loaded = 0;
    const failed = [];

    for (let i = 0; i < metas.length; i += 1) {
      const meta = metas[i];
      const percent = Math.round(((i + 1) / metas.length) * 100);
      onProgress?.(`拉取会话 ${i + 1}/${metas.length}`, percent);

      if (state.captured.has(meta.id)) {
        loaded += 1;
        continue;
      }

      try {
        const detail = await fetchConversationDetailById(meta.id);
        const title = detail?.sessionTitle || detail?.title || meta.title || 'Yuanbao Chat';
        const md = yuanbaoToMarkdown(detail);
        state.captured.set(meta.id, {
          id: meta.id,
          title,
          md,
          jsonText: JSON.stringify(detail),
          capturedAt: new Date().toISOString(),
        });
        loaded += 1;
      } catch {
        failed.push(meta.id);
      }
      updateUiState();
    }

    if (!loaded) throw new Error('会话读取失败(可能需要重新登录)');
    return { total: metas.length, loaded, failed };
  }

  function buildAllConversationsDocMarkdown(list) {
    return list
      .map((c, idx) => {
        const cleaned = String(c.md || '')
          .replace(/^#\s+.*?\n+/m, '')
          .trim();
        return `# ${idx + 1}. ${c.title}\n\n${cleaned}`;
      })
      .join('\n\n---\n\n');
  }

  async function exportAll(format) {
    try {
      setBusy(true);

      // 如果缓存为空,先拉取
      if (state.captured.size === 0) {
        setProgress(true, '正在读取全部会话...', 2);
        await ensureAllConversationsLoaded((text, percent) => setProgress(true, text, percent));
      }

      const allList = Array.from(state.captured.values());
      const stats = { total: allList.length, loaded: allList.length, failed: [] };

      // ZIP 打包导出
      await exportAsZip(allList, format, stats);

      setProgress(true, '导出完成', 100);
      showToast(`全部对话 ${format.toUpperCase()} ZIP 已导出(${allList.length} 个会话)`);
      setTimeout(() => setProgress(false), 600);
    } catch (err) {
      setProgress(false);
      alert(`批量导出失败:${err?.message || err}`);
    } finally {
      setBusy(false);
    }
  }

  async function exportAsZip(allList, format, stats) {
    if (typeof JSZip === 'undefined') {
      throw new Error('JSZip 库未加载');
    }
    
    setProgress(true, format === 'docx' ? '正在生成 DOCX 并打包...' : '正在打包 ZIP...', 85);
    const zip = new JSZip();
    const folder = zip.folder('yuanbao-conversations');
    
    for (let i = 0; i < allList.length; i++) {
      const c = allList[i];
      const safeTitle = Utils.sanitizeFilename(c.title || `conversation-${i + 1}`);
      const filename = `${String(i + 1).padStart(3, '0')}_${safeTitle}`;
      
      // 更新进度
      const progressPercent = 85 + Math.round(((i + 1) / allList.length) * 10);
      setProgress(true, `正在处理 ${i + 1}/${allList.length}...`, progressPercent);
      
      if (format === 'md') {
        folder.file(`${filename}.md`, c.md);
      } else if (format === 'json') {
        folder.file(`${filename}.json`, c.jsonText);
      } else if (format === 'docx') {
        // 生成 DOCX blob
        const docText = `# ${c.title}\n\n> 导出时间: ${new Date().toLocaleString()}\n\n${String(c.md || '').replace(/^#\s+.*?\n+/m, '').trim()}`;
        const docxBlob = await buildDocxBlob(docText);
        const docxArrayBuffer = await docxBlob.arrayBuffer();
        folder.file(`${filename}.docx`, docxArrayBuffer);
      }
    }
    
    // 添加索引文件
    if (format === 'docx') {
      const indexContent = buildIndexDocx(allList, stats);
      const indexBlob = await buildDocxBlob(indexContent);
      const indexArrayBuffer = await indexBlob.arrayBuffer();
      folder.file('_index.docx', indexArrayBuffer);
    } else if (format === 'json') {
      const indexContent = JSON.stringify({
        exportedAt: new Date().toISOString(),
        total: allList.length,
        loaded: stats?.loaded,
        failed: stats?.failed?.length || 0,
        conversations: allList.map((c, i) => ({
          index: i + 1,
          id: c.id,
          title: c.title,
          filename: `${String(i + 1).padStart(3, '0')}_${Utils.sanitizeFilename(c.title || `conversation-${i + 1}`)}.json`
        }))
      }, null, 2);
      folder.file('_index.json', indexContent);
    } else {
      const indexContent = buildIndexMarkdown(allList, stats);
      folder.file('_index.md', indexContent);
    }
    
    setProgress(true, '正在生成 ZIP...', 97);
    const zipBlob = await zip.generateAsync({ 
      type: 'blob',
      compression: 'DEFLATE',
      compressionOptions: { level: 6 }
    });
    Utils.download(zipBlob, 'application/zip', `ALL_Yuanbao_Conversations_${format.toUpperCase()}_${Utils.nowStamp()}.zip`);
  }

  function buildIndexMarkdown(allList, stats) {
    const out = [];
    out.push('# 元宝会话导出索引');
    out.push('');
    out.push(`> 导出时间: ${new Date().toLocaleString()}`);
    out.push(`> 会话总数: ${allList.length}`);
    if (stats && stats.failed && stats.failed.length > 0) {
      out.push(`> 成功: ${stats.loaded}, 失败: ${stats.failed.length}`);
    }
    out.push('');
    out.push('## 会话列表');
    out.push('');
    
    for (let i = 0; i < allList.length; i++) {
      const c = allList[i];
      const safeTitle = Utils.sanitizeFilename(c.title || `conversation-${i + 1}`);
      out.push(`${i + 1}. [${c.title || '未命名会话'}](./${String(i + 1).padStart(3, '0')}_${safeTitle}.md)`);
    }
    
    out.push('');
    out.push('---');
    out.push('*由 Chat Export Toolkit 导出*');
    return out.join('\n');
  }

  function buildIndexDocx(allList, stats) {
    const out = [];
    out.push('# 元宝会话导出索引');
    out.push('');
    out.push(`导出时间: ${new Date().toLocaleString()}`);
    out.push(`会话总数: ${allList.length}`);
    if (stats && stats.failed && stats.failed.length > 0) {
      out.push(`成功: ${stats.loaded}, 失败: ${stats.failed.length}`);
    }
    out.push('');
    out.push('## 会话列表');
    out.push('');
    
    for (let i = 0; i < allList.length; i++) {
      const c = allList[i];
      out.push(`${i + 1}. ${c.title || '未命名会话'}`);
    }
    
    out.push('');
    out.push('---');
    out.push('由 Chat Export Toolkit 导出');
    return out.join('\n');
  }

  function ensureUi() {
    if (document.getElementById('cet-fab')) return;

    const style = document.createElement('style');
    style.textContent = `
      :root {
        --cet-green-primary: #01B259;
        --cet-green-hover: #4BC979;
        --cet-green-bg: #F5FBF5;
        --cet-black: #191919;
        --cet-gray-border: #F3F3F3;
        --cet-gray-text: #BABABA;
        --cet-white: #FFFFFF;
        --cet-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
        --cet-shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.12);
        --cet-radius: 12px;
      }

      /* 浮动按钮 - 更加精致的品牌感 */
      #cet-fab {
        position: fixed; right: 24px; bottom: 24px; z-index: 999999;
        height: 44px; min-width: 90px; border-radius: 22px; border: 1px solid var(--cet-gray-border);
        background: var(--cet-white);
        color: var(--cet-black); font-size: 14px; font-weight: 600; cursor: pointer; padding: 0 18px;
        box-shadow: var(--cet-shadow);
        display: flex; align-items: center; justify-content: center;
        transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
        font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
      }
      #cet-fab:hover {
        box-shadow: var(--cet-shadow-hover);
        transform: translateY(-2px);
        border-color: var(--cet-green-hover);
      }
      #cet-fab-badge {
        display: inline-block; margin-left: 8px; min-width: 18px; height: 18px; line-height: 18px; border-radius: 9px;
        font-size: 11px; text-align: center; background: var(--cet-green-primary); color: #fff; padding: 0 5px;
        font-weight: 700;
        box-shadow: 0 2px 4px rgba(1, 178, 89, 0.2);
      }

      /* 主面板 - 高级卡片感 */
      #cet-panel {
        position: fixed; right: 24px; bottom: 84px; z-index: 1000000;
        width: 300px; border-radius: 16px; color: var(--cet-black);
        background: var(--cet-white);
        border: 1px solid var(--cet-gray-border); box-shadow: var(--cet-shadow-hover);
        padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
        display: none;
        transform-origin: right bottom;
        animation: cet-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
      }
      @keyframes cet-pop-in {
        from { transform: scale(0.9); opacity: 0; }
        to { transform: scale(1); opacity: 1; }
      }
      #cet-panel.open { display: block; }
      #cet-title { font-weight: 700; font-size: 16px; margin-bottom: 6px; color: var(--cet-black); letter-spacing: -0.2px; }
      #cet-status { font-size: 12px; color: var(--cet-gray-text); margin-bottom: 16px; font-weight: 400; }

      /* 进度条 - 动态交互感 */
      #cet-progress { display: none; margin-bottom: 16px; background: var(--cet-green-bg); padding: 12px; border-radius: 10px; }
      #cet-progress-text { font-size: 12px; color: var(--cet-green-primary); margin-bottom: 8px; font-weight: 600; }
      #cet-progress-track { width: 100%; height: 6px; border-radius: 3px; background: #E8F5EE; overflow: hidden; }
      #cet-progress-bar { width: 0%; height: 100%; background: var(--cet-green-primary); transition: width 0.3s ease; }

      /* 分组 */
      .cet-group { margin-bottom: 20px; }
      .cet-group:last-child { margin-bottom: 0; }
      .cet-label { font-size: 12px; color: var(--cet-gray-text); margin-bottom: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }

      /* 分段控制器 - 软胶囊风格 */
      .cet-segmented {
        display: flex; background: var(--cet-gray-border); border-radius: 10px; padding: 4px; margin-bottom: 12px;
      }
      .cet-segmented-btn {
        flex: 1; border: none; border-radius: 7px; padding: 8px; background: transparent;
        color: #777; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.25s ease;
        font-family: inherit;
      }
      .cet-segmented-btn:hover:not(.active):not(:disabled) { color: var(--cet-black); }
      .cet-segmented-btn.active { background: var(--cet-white); color: var(--cet-green-primary); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); }
      .cet-segmented-btn:disabled { opacity: 0.4; cursor: not-allowed; }

      /* 操作按钮 - 品牌调色整合 */
      .cet-actions { display: flex; gap: 10px; }
      .cet-action-btn {
        flex: 1; border: 1px solid var(--cet-gray-border); border-radius: 10px;
        padding: 10px 12px; background: var(--cet-white); color: var(--cet-black); cursor: pointer;
        font-size: 13px; font-weight: 600; transition: all 0.2s ease;
        font-family: inherit;
        display: flex; align-items: center; justify-content: center;
      }
      .cet-action-btn:hover:not(:disabled) { 
        background: var(--cet-green-bg); 
        border-color: var(--cet-green-hover); 
        color: var(--cet-green-primary);
        transform: translateY(-1px);
      }
      .cet-action-btn:active:not(:disabled) { transform: translateY(0); }
      .cet-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
      .cet-action-btn.primary {
        background: var(--cet-green-primary);
        border-color: var(--cet-green-primary);
        color: var(--cet-white);
        box-shadow: 0 4px 12px rgba(1, 178, 89, 0.2);
      }
      .cet-action-btn.primary:hover:not(:disabled) {
        background: var(--cet-green-hover);
        border-color: var(--cet-green-hover);
        color: var(--cet-white);
        box-shadow: 0 6px 16px rgba(1, 178, 89, 0.3);
      }
      #cet-panel.is-busy .cet-action-btn { opacity: 0.5; pointer-events: none; }

      /* Toast 提示 - 磨砂玻璃质感 */
      #cet-toast {
        position: fixed; left: 50%; bottom: 100px; z-index: 1000001; color: var(--cet-white);
        background: rgba(25, 25, 25, 0.85);
        border-radius: 20px;
        padding: 8px 16px; font-size: 13px; font-weight: 500;
        opacity: 0; transform: translate(-50%, 12px); transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1); pointer-events: none;
        backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
        max-width: 80%; text-align: center;
      }
      #cet-toast.show { opacity: 1; transform: translate(-50%, 0); }
    `.trim();
    document.documentElement.appendChild(style);

    const fab = document.createElement('button');
    fab.id = 'cet-fab';
    fab.innerHTML = `导出 <span id="cet-fab-badge">0</span>`;
    document.body.appendChild(fab);

    const panel = document.createElement('div');
    panel.id = 'cet-panel';
    panel.innerHTML = `
      <div id="cet-title">Chat Export Toolkit</div>
      <div id="cet-status">等待对话数据 · 已缓存 0 个会话</div>
      <div id="cet-progress">
        <div id="cet-progress-text">准备中...</div>
        <div id="cet-progress-track"><div id="cet-progress-bar"></div></div>
      </div>
      
      <!-- 当前会话 -->
      <div class="cet-group">
        <div class="cet-label">当前会话</div>
        <div class="cet-segmented" id="cet-current-format">
          <button class="cet-segmented-btn active" data-format="md">Markdown</button>
          <button class="cet-segmented-btn" data-format="json">JSON</button>
          <button class="cet-segmented-btn" data-format="docx">DOCX</button>
        </div>
        <div class="cet-actions">
          <button class="cet-action-btn" id="cet-current-copy">复制</button>
          <button class="cet-action-btn primary" id="cet-current-save">保存</button>
        </div>
      </div>
      
      <!-- 全部会话 -->
      <div class="cet-group">
        <div class="cet-label">全部会话 · 导出为 ZIP</div>
        <div class="cet-segmented" id="cet-all-format">
          <button class="cet-segmented-btn active" data-format="md">Markdown</button>
          <button class="cet-segmented-btn" data-format="json">JSON</button>
          <button class="cet-segmented-btn" data-format="docx">DOCX</button>
        </div>
        <div class="cet-actions">
          <button class="cet-action-btn export" id="cet-all-export" style="width:100%;">导出 ZIP</button>
        </div>
      </div>
    `.trim();
    document.body.appendChild(panel);

    const toast = document.createElement('div');
    toast.id = 'cet-toast';
    document.body.appendChild(toast);

    fab.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      state.ui.panelOpen = !state.ui.panelOpen;
      panel.classList.toggle('open', state.ui.panelOpen);
    });

    document.addEventListener('click', (e) => {
      if (!state.ui.panelOpen) return;
      const t = e.target;
      if (panel.contains(t) || fab.contains(t)) return;
      state.ui.panelOpen = false;
      panel.classList.remove('open');
    });

    // 状态管理
    const uiState = {
      currentFormat: 'md',
      allFormat: 'md'
    };

    // 分段控制器切换
    const setupSegmented = (containerId, stateKey) => {
      const container = document.getElementById(containerId);
      if (!container) return;
      const buttons = container.querySelectorAll('.cet-segmented-btn');
      buttons.forEach(btn => {
        btn.addEventListener('click', (e) => {
          e.preventDefault();
          e.stopPropagation();
          buttons.forEach(b => b.classList.remove('active'));
          btn.classList.add('active');
          uiState[stateKey] = btn.dataset.format;
        });
      });
    };

    setupSegmented('cet-current-format', 'currentFormat');
    setupSegmented('cet-all-format', 'allFormat');

    const on = (id, fn) => {
      const el = document.getElementById(id);
      if (el) el.addEventListener('click', (e) => (e.preventDefault(), e.stopPropagation(), fn()));
    };

    // 当前会话 - 复制
    on('cet-current-copy', async () => {
      if (!state.current) return;
      const format = uiState.currentFormat;
      try {
        if (format === 'md') {
          await Utils.copyText(state.current.md);
          showToast('当前会话 MD 已复制');
        } else if (format === 'json') {
          await Utils.copyText(state.current.jsonText);
          showToast('当前会话 JSON 已复制');
        } else if (format === 'docx') {
          showToast('DOCX 不支持复制');
        }
      } catch (err) {
        alert(`复制失败: ${err?.message || err}`);
      }
    });

    // 当前会话 - 保存
    on('cet-current-save', async () => {
      if (!state.current) return;
      const format = uiState.currentFormat;
      
      if (format === 'md') {
        Utils.download(state.current.md, 'text/markdown', getFilename('current', 'md', state.current.title));
        showToast('当前会话 MD 已保存');
      } else if (format === 'json') {
        Utils.download(state.current.jsonText, 'application/json', getFilename('current', 'json', state.current.title));
        showToast('当前会话 JSON 已保存');
      } else if (format === 'docx') {
        try {
          setBusy(true);
          setProgress(true, '正在生成 DOCX...', 95);
          const docText = `# ${state.current.title}\n\n> 导出时间: ${new Date().toLocaleString()}\n\n${String(state.current.md || '').replace(/^#\s+.*?\n+/m, '').trim()}`;
          const blob = await buildDocxBlob(docText);
          Utils.download(blob, blob.type, getFilename('current', 'docx', state.current.title));
          setProgress(true, '保存完成', 100);
          showToast('当前会话 DOCX 已保存');
          setTimeout(() => setProgress(false), 500);
        } catch (err) {
          setProgress(false);
          alert(`DOCX 保存失败: ${err?.message || err}`);
        } finally {
          setBusy(false);
        }
      }
    });

    // 全部会话 - ZIP 导出
    on('cet-all-export', () => exportAll(uiState.allFormat));

    updateUiState();
  }

  function onReady(fn) {
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
    else fn();
  }

  function handleConversationListResponse(text) {
    let json;
    try {
      json = JSON.parse(text);
    } catch {
      return;
    }

    const tmp = [];
    const seen = new Set();
    
    // 尝试更多可能的数据结构
    const conversations = 
      pickArray(json, ['conversations', 'data', 'result', 'response', 'payload']) ||
      pickArray(json?.data, ['conversations', 'result', 'response', 'payload']) ||
      pickArray(json?.result, ['conversations', 'data', 'response', 'payload']) ||
      pickArray(json?.response, ['conversations', 'data', 'result', 'payload']) ||
      pickArray(json?.payload, ['conversations', 'data', 'result', 'response']);
    
    if (conversations.length > 0) {
      collectConversationMetasFromJson(conversations, tmp, seen);
    } else {
      collectConversationMetasFromJson(json, tmp, seen);
    }
    
    for (const row of tmp) {
      state.listHints.set(row.id, row.title);
    }
  }

  function handleYuanbaoResponse(text, url) {
    let json;
    try {
      json = JSON.parse(text);
    } catch {
      return;
    }

    // 检查更多可能的响应结构
    let convsData = null;
    if (Array.isArray(json.convs)) {
      convsData = json;
    } else if (Array.isArray(json?.data?.convs)) {
      convsData = json.data;
    } else if (Array.isArray(json?.result?.convs)) {
      convsData = json.result;
    } else if (Array.isArray(json?.response?.convs)) {
      convsData = json.response;
    } else if (Array.isArray(json?.payload?.convs)) {
      convsData = json.payload;
    } else if (Array.isArray(json?.data?.data?.convs)) {
      convsData = json.data.data;
    } else if (Array.isArray(json?.result?.result?.convs)) {
      convsData = json.result.result;
    }

    if (!convsData) return;

    const idFromUrl = Utils.extractConversationId(url);
    const title = convsData.sessionTitle || convsData.title || json.sessionTitle || json.title || 'Yuanbao Chat';
    const id = idFromUrl || `${Utils.sanitizeFilename(title)}_${Utils.nowStamp()}`;

    state.listHints.set(id, title);
    state.current = {
      id,
      title,
      md: yuanbaoToMarkdown(convsData),
      jsonText: text,
      capturedAt: new Date().toISOString(),
    };
    state.captured.set(id, state.current);
    updateUiState();
  }

  // 存储已发现的API端点
  let discoveredEndpoints = {
    detail: new Set(),
    list: new Set()
  };

  function installInterceptors() {
    const originalOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (...args) {
      const method = args[0];
      const url = args[1];
      
      this.addEventListener(
        'load',
        () => {
          try {
            const responseUrl = this.responseURL || url; // 使用原始URL或响应URL
            
            // 检查是否是腾讯元宝相关的API请求
            if (responseUrl.includes('/conversation/')) {
              // 记录所有conversation相关的端点,不管是否成功
              if (responseUrl.includes('/detail')) {
                discoveredEndpoints.detail.add(responseUrl);
                console.log('[Chat Export] Discovered detail API via XHR:', responseUrl, 'Status:', this.status);
              } else if (responseUrl.includes('/list') || responseUrl.includes('/page')) {
                discoveredEndpoints.list.add(responseUrl);
                console.log('[Chat Export] Discovered list API via XHR:', responseUrl, 'Status:', this.status);
              }
            }
            
            // 检查是否是我们已知的模式,只处理成功的请求
            if ((YUANBAO_DETAIL_RE.test(responseUrl) || discoveredEndpoints.detail.has(responseUrl)) && this.status === 200) {
              console.log('[Chat Export] Intercepted detail API:', responseUrl);
              handleYuanbaoResponse(this.responseText, responseUrl);
              return;
            }
            if ((YUANBAO_LIST_RE.test(responseUrl) || discoveredEndpoints.list.has(responseUrl)) && this.status === 200) {
              console.log('[Chat Export] Intercepted list API:', responseUrl);
              handleConversationListResponse(this.responseText);
            }
          } catch (err) {
            console.error('[Chat Export] XHR intercept error:', err);
          }
        },
        { once: true }
      );
      return originalOpen.apply(this, args);
    };

    const originalFetch = window.fetch;
    window.fetch = async (...args) => {
      const url = args[0] instanceof Request ? args[0].url : String(args[0] || '');
      const res = await originalFetch(...args);
      try {
        // 检查是否是腾讯元宝相关的API请求
        if (url.includes('/conversation/')) {
          // 记录所有conversation相关的端点,不管是否成功
          if (url.includes('/detail')) {
            discoveredEndpoints.detail.add(url);
            console.log('[Chat Export] Discovered detail API via fetch:', url, 'Status:', res.status);
          } else if (url.includes('/list') || url.includes('/page')) {
            discoveredEndpoints.list.add(url);
            console.log('[Chat Export] Discovered list API via fetch:', url, 'Status:', res.status);
          }
        }
        
        // 检查是否是我们已知的模式或已发现的端点,只处理成功的请求
        if (((YUANBAO_DETAIL_RE.test(url) || discoveredEndpoints.detail.has(url)) && res.status === 200)) {
          console.log('[Chat Export] Intercepted fetch detail API:', url);
          res
            .clone()
            .text()
            .then((t) => handleYuanbaoResponse(t, url))
            .catch(() => {});
        } else if (((YUANBAO_LIST_RE.test(url) || discoveredEndpoints.list.has(url)) && res.status === 200)) {
          console.log('[Chat Export] Intercepted fetch list API:', url);
          res
            .clone()
            .text()
            .then((t) => handleConversationListResponse(t))
            .catch(() => {});
        }
      } catch (err) {
        console.error('[Chat Export] Fetch intercept error:', err);
      }
      return res;
    };
  }

  // 从已发现的端点中选择最可能的API端点
  function selectBestEndpoint(type) {
    const candidates = Array.from(discoveredEndpoints[type]);
    if (candidates.length === 0) return null;
    
    // 优先选择最近被发现的成功的端点
    // 由于Set是有序的,最后添加的会在末尾
    return candidates[candidates.length - 1];
  }

  let autoFetchStarted = false;

  async function startAutoFetch() {
    if (autoFetchStarted) return;
    autoFetchStarted = true;

    // 延迟 3 秒启动,等待页面初始化
    await new Promise(resolve => setTimeout(resolve, 3000));

    // 静默拉取所有对话并缓存
    try {
      const metas = await fetchAllConversationMetas();
      if (!metas.length) return;

      let loaded = 0;
      for (let i = 0; i < metas.length; i += 1) {
        const meta = metas[i];
        if (state.captured.has(meta.id)) {
          loaded += 1;
          continue;
        }

        try {
          const detail = await fetchConversationDetailById(meta.id);
          const title = detail?.sessionTitle || detail?.title || meta.title || 'Yuanbao Chat';
          const md = yuanbaoToMarkdown(detail);
          state.captured.set(meta.id, {
            id: meta.id,
            title,
            md,
            jsonText: JSON.stringify(detail),
            capturedAt: new Date().toISOString(),
          });
          loaded += 1;
        } catch {
          // 静默失败
        }
        updateUiState();

        // 每 5 个会话暂停 100ms,避免请求过快
        if ((i + 1) % 5 === 0) {
          await new Promise(resolve => setTimeout(resolve, 100));
        }
      }
    } catch {
      // 静默失败
    }
  }

  function init() {
    installInterceptors();
    onReady(() => {
      ensureUi();
      // 页面加载后自动开始后台拉取
      startAutoFetch();
    });
  }

  init();
})();