Chat Export Toolkit

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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