Google AI Studio 对话导出器

完美导出 Google AI Studio 对话记录。具备自动滚动加载、精准去重、防抖动、User/Model角色区分,以及全平台响应式优化。支持 PC、平板、手机全平台。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Google AI Studio Exporter
// @name:zh-CN   Google AI Studio 对话导出器
// @namespace    https://github.com/GhostXia/Google-AI-Studio-Exporter
// @version      1.5.0
// @description  Export your Gemini chat history from Google AI Studio to a text file. Features: Auto-scrolling, User/Model role differentiation, clean output, and full mobile optimization.
// @description:zh-CN 完美导出 Google AI Studio 对话记录。具备自动滚动加载、精准去重、防抖动、User/Model角色区分,以及全平台响应式优化。支持 PC、平板、手机全平台。
// @author       GhostXia
// @license      AGPL-3.0
// @match        https://aistudio.google.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=google.com
// @homepageURL  https://github.com/GhostXia/Google-AI-Studio-Exporter
// @supportURL   https://github.com/GhostXia/Google-AI-Studio-Exporter/issues
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      cdnjs.cloudflare.com
// @connect      cdn.jsdelivr.net
// @connect      unpkg.com
// @connect      lh3.googleusercontent.com
// @connect      googleusercontent.com
// @connect      storage.googleapis.com
// @connect      gstatic.com
// ==/UserScript==

// 在 IIFE 外部捕获 @require 加载的 JSZip(避免沙盒作用域问题)
/* global JSZip */
const _JSZipRef = (typeof JSZip !== 'undefined') ? JSZip : null;

    (function () {
        'use strict';

        const DEBUG = false;
        const dlog = (...args) => { if (DEBUG) console.log(...args); };
        dlog('[AI Studio Exporter] Script started');
        dlog('[AI Studio Exporter] _JSZipRef:', _JSZipRef);
        dlog('[AI Studio Exporter] typeof JSZip:', typeof JSZip);
        dlog('[AI Studio Exporter] unsafeWindow.JSZip:', typeof unsafeWindow !== 'undefined' ? unsafeWindow.JSZip : 'unsafeWindow not available');

    // ==========================================
    // 0. 国际化 (i18n)
    // ==========================================
    const lang = navigator.language.startsWith('zh') ? 'zh' : 'en';
        const translations = {
            'zh': {
                'btn_export': '🚀 导出',
                'title_ready': '准备就绪',
                'status_init': '初始化中...',
            'btn_save': '💾 保存',
            'btn_close': '关闭',
            'title_countdown': '准备开始',
            'status_countdown': '请松开鼠标,不要操作!<br><span class="ai-red">{s} 秒后开始自动滚动</span>',
            'title_scrolling': '正在采集...',
            'status_scrolling': '正在向下滚动并抓取内容。<br>按 <b>ESC</b> 键可强制停止并保存。',
            'title_finished': '🎉 导出成功',
            'status_finished': '文件已生成。<br>请检查下载栏。',
            'title_error': '❌ 出错了',
            'title_mode_select': '选择导出模式',
            'status_mode_select': '请选择导出格式',
            'btn_mode_full': '📦 包含附件',
            'btn_mode_text': '📄 纯文本',
            'file_header': 'Google AI Studio 完整对话记录',
            'file_time': '时间',
            'file_count': '条数',
            'file_turns': '回合数',
            'file_paragraphs': '输出段落数',
            'role_user': 'User',
            'role_gemini': 'Gemini',
            'role_thoughts': '思考',
            'err_no_scroller': '未找到滚动容器。请尝试刷新页面或手动滚动一下再试。',
            'err_no_data': '未采集到任何对话数据。请检查页面是否有对话内容。',
            'err_runtime': '运行错误: ',
            'status_packaging_images': '正在打包 {n} 张图片...',
            'status_packaging_images_progress': '打包图片: {c}/{t}',
            'status_packaging_files': '正在打包 {n} 个文件...',
            'status_packaging_files_progress': '打包文件: {c}/{t}',
            'ui_turns': '回合数',
            'ui_paragraphs': '输出段落数',
            'title_zip_missing': 'JSZip 加载失败',
            'status_zip_missing': '无法加载附件打包库。是否回退到纯文本?',
            'btn_retry': '重试',
            'btn_cancel': '取消',
            'status_esc_hint': '按 <b>ESC</b> 可取消并选择保存方式',
            'title_cancel': '已取消导出',
            'status_cancel': '请选择继续打包附件或改为纯文本保存',
            'banner_top': '📎 附件已合并为 Markdown 链接(纯文本导出)',
            'attachments_section': '附件',
            'attachments_link_unavailable': '链接不可用'
            },
            'en': {
                'btn_export': '🚀 Export',
                'title_ready': 'Ready',
                'status_init': 'Initializing...',
            'btn_save': '💾 Save',
            'btn_close': 'Close',
            'title_countdown': 'Get Ready',
            'status_countdown': 'Please release mouse!<br><span class="ai-red">Auto-scroll starts in {s}s</span>',
            'title_scrolling': 'Exporting...',
            'status_scrolling': 'Scrolling down and capturing content.<br>Press <b>ESC</b> to stop and save.',
            'title_finished': '🎉 Finished',
            'status_finished': 'File generated.<br>Check your downloads.',
            'title_error': '❌ Error',
            'title_mode_select': 'Select Export Mode',
            'status_mode_select': 'Choose export format',
            'btn_mode_full': '📦 With Attachments',
            'btn_mode_text': '📄 Text Only',
            'file_header': 'Google AI Studio Chat History',
            'file_time': 'Time',
            'file_count': 'Count',
            'file_turns': 'Turns',
            'file_paragraphs': 'Output paragraphs',
            'role_user': 'User',
            'role_gemini': 'Gemini',
            'role_thoughts': 'Thoughts',
            'err_no_scroller': 'Scroll container not found. Try refreshing or scrolling manually.',
            'err_no_data': 'No conversation data was collected. Please check if the page has any chat content.',
            'err_runtime': 'Runtime Error: ',
            'status_packaging_images': 'Packaging {n} images...',
            'status_packaging_images_progress': 'Packaging images: {c}/{t}',
            'status_packaging_files': 'Packaging {n} files...',
            'status_packaging_files_progress': 'Packaging files: {c}/{t}',
            'ui_turns': 'Turns',
            'ui_paragraphs': 'Output paragraphs',
            'title_zip_missing': 'JSZip load failed',
            'status_zip_missing': 'Could not load ZIP library. Fallback to text?',
            'btn_retry': 'Retry',
            'btn_cancel': 'Cancel',
            'status_esc_hint': 'Press <b>ESC</b> to cancel and choose how to save',
            'title_cancel': 'Export cancelled',
            'status_cancel': 'Choose to continue attachments or save as text',
            'banner_top': '📎 Attachments merged as Markdown links (Text-only export)',
            'attachments_section': 'Attachments',
            'attachments_link_unavailable': 'link unavailable'
            }
        };

    function t(key, params = {}) {
        let str = translations[lang][key] || key;
        // Legacy support for single parameter
        if (typeof params !== 'object' || params === null) {
            str = str.replace(/{s}/g, params);
            return str;
        }
        for (const pKey in params) {
            str = str.replace(new RegExp(`\\{${pKey}\\}`, 'g'), params[pKey]);
        }
        return str;
    }

    // ==========================================
    // 1. 样式与 UI (全平台响应式优化版)
    // ==========================================
    const style = document.createElement('style');
    style.textContent = `
        /* 全局遮罩层 */
        #ai-overlay-v14 {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0, 0, 0, 0.85); z-index: 2147483647;
            display: flex; justify-content: center; align-items: center;
            font-family: 'Google Sans', Roboto, -apple-system, sans-serif;
            backdrop-filter: blur(6px);
            -webkit-backdrop-filter: blur(6px);
            animation: ai-fade-in 0.2s ease-out;
        }
        
        @keyframes ai-fade-in {
            from { opacity: 0; }
            to { opacity: 1; }
        }

        /* 主弹窗 */
        #ai-box {
            background: white; 
            padding: 32px; 
            border-radius: 20px;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
            width: 92%; 
            max-width: 560px;
            text-align: center; 
            position: relative;
            animation: ai-slide-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
        }
        
        @keyframes ai-slide-up {
            from { transform: translateY(30px); opacity: 0; }
            to { transform: translateY(0); opacity: 1; }
        }

        .ai-title { 
            font-size: 26px; 
            font-weight: 700; 
            margin-bottom: 16px; 
            color: #202124;
            letter-spacing: -0.5px;
        }
        .ai-banner {
            background: #fff7cd;
            color: #5f6368;
            padding: 10px 12px;
            border-radius: 10px;
            margin-bottom: 14px;
            font-size: 13px;
        }
        
        .ai-status { 
            font-size: 15px; 
            margin-bottom: 24px; 
            line-height: 1.7; 
            color: #5f6368; 
            word-break: break-word; 
            white-space: pre-wrap;
        }
        
        .ai-count { 
            font-size: 14px; 
            font-weight: 600; 
            color: #5f6368; 
            margin-top: 8px;
            line-height: 1.6;
            white-space: pre-line;
        }
        
        .ai-btn-container {
            display: flex;
            gap: 12px;
            justify-content: center;
            margin-top: 20px;
        }
        
        .ai-btn {
            background: linear-gradient(135deg, #1a73e8 0%, #1557b0 100%);
            color: white; 
            border: none; 
            padding: 14px 32px;
            border-radius: 12px; 
            cursor: pointer; 
            font-size: 16px; 
            font-weight: 600;
            display: inline-block;
            box-shadow: 0 4px 12px rgba(26, 115, 232, 0.3);
            transition: all 0.2s ease;
            flex: 1;
            max-width: 150px;
        }
        .ai-btn[disabled] {
            opacity: 0.6;
            cursor: not-allowed;
            pointer-events: none;
        }
        
        .ai-btn-secondary {
            background: linear-gradient(135deg, #5f6368 0%, #3c4043 100%);
        }
        
        .ai-btn-secondary:hover {
            background: linear-gradient(135deg, #4a4d51 0%, #2d3033 100%);
        }
        
        .ai-btn:hover { 
            transform: translateY(-2px);
            box-shadow: 0 6px 16px rgba(26, 115, 232, 0.4);
        }
        
        .ai-btn:active {
            transform: translateY(0);
        }
        
        .ai-red { 
            color: #d93025; 
            font-weight: 700; 
        }
        .ai-hint {
            color: #5f6368;
            font-size: 13px;
            align-self: center;
        }

        /* 悬浮按钮 - PC 默认样式 */
        .ai-entry {
            position: fixed; 
            z-index: 2147483646;
            padding: 14px 28px;
            background: linear-gradient(135deg, #1a73e8 0%, #1557b0 100%);
            color: white;
            border: none;
            border-radius: 50px; 
            cursor: pointer;
            box-shadow: 0 6px 20px rgba(26, 115, 232, 0.4);
            font-weight: 700;
            font-size: 15px;
            transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
            top: 80px; 
            right: 28px;
            letter-spacing: -0.3px;
            user-select: none;
            -webkit-user-select: none;
            -webkit-tap-highlight-color: transparent;
        }
        
        .ai-entry:hover { 
            transform: scale(1.08) translateY(-2px);
            box-shadow: 0 8px 24px rgba(26, 115, 232, 0.5);
        }
        
        .ai-entry:active {
            transform: scale(1.02);
        }

        /* ========================================== */
        /* 平板适配 (600px - 900px) */
        /* ========================================== */
        @media (max-width: 900px) and (min-width: 601px) {
            .ai-entry {
                top: 70px;
                right: 24px;
                padding: 12px 24px;
                font-size: 14px;
            }
            #ai-box {
                max-width: 420px;
                padding: 28px;
            }
            .ai-title { font-size: 22px; }
            .ai-count { font-size: 14px; }
        }

        /* ========================================== */
        /* 手机适配 (最大 600px) */
        /* ========================================== */
        @media (max-width: 600px) {
            .ai-entry {
                /* 移动端:右下角悬浮球 */
                top: auto; 
                bottom: 140px; 
                right: 16px;
                padding: 16px 20px;
                font-size: 14px;
                min-width: 56px;
                min-height: 56px; /* 符合移动端 44-56px 最小触控标准 */
                display: flex;
                align-items: center;
                justify-content: center;
                box-shadow: 0 8px 24px rgba(26, 115, 232, 0.6);
            }
            
            #ai-box {
                padding: 24px 20px;
                border-radius: 16px;
                width: 92%;
                max-width: none;
            }
            
            .ai-title { 
                font-size: 20px;
                margin-bottom: 12px;
            }
            
            .ai-status {
                font-size: 14px;
                margin-bottom: 20px;
            }
            
            .ai-count { 
                font-size: 14px;
                margin-top: 8px;
            }
            
            .ai-btn {
                padding: 12px 28px;
                font-size: 15px;
                border-radius: 10px;
                width: 100%;
                max-width: 200px;
            }
        }

        /* ========================================== */
        /* 超小屏幕适配 (最大 360px) */
        /* ========================================== */
        @media (max-width: 360px) {
            .ai-entry {
                bottom: 130px;
                right: 12px;
                padding: 14px 16px;
                font-size: 13px;
            }
            
            #ai-box {
                padding: 20px 16px;
            }
            
            .ai-title { font-size: 18px; }
            .ai-count { font-size: 13px; }
            .ai-status { font-size: 13px; }
        }

        /* 深色模式适配 */
        @media (prefers-color-scheme: dark) {
            #ai-overlay-v14 {
                background: rgba(0, 0, 0, 0.92);
            }
            #ai-box {
                background: #202124;
                box-shadow: 0 20px 60px rgba(0,0,0,0.8);
            }
            .ai-title { color: #e8eaed; }
            .ai-status { color: #9aa0a6; }
            .ai-count { color: #9aa0a6; }
        }
        
    `;
    document.head.appendChild(style);

    // ==========================================
    // 2. 状态管理
    // ==========================================
    let isRunning = false;
    let hasFinished = false;
    let collectedData = new Map();
    let turnOrder = []; // Array to store turn IDs in the correct order
    let processedTurnIds = new Set();
    let overlay, titleEl, statusEl, countEl, closeBtn;
    let exportMode = null; // 'full' or 'text'
    let cachedExportBlob = null;
    let cancelRequested = false;
    let isHandlingEscape = false;
    const EMBED_JSZIP_BASE64 = '';
    const DISABLE_SCRIPT_INJECTION = true;
    const ATTACHMENT_COMBINED_FALLBACK = true;
    const ATTACHMENT_MAX_DIST = 160;
    const scannedAttachmentTurns = new Set();
    const ATTACHMENT_SCAN_CONCURRENCY = 3;
    const JSZIP_URLS = [
        'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js',
        'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.js',
        'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js',
        'https://unpkg.com/[email protected]/dist/jszip.min.js'
    ];

    // ==========================================
    // 3. UI 逻辑
    // ==========================================
    function createEntryButton() {
        if (document.getElementById('ai-entry-btn-v14')) return;
        const btn = document.createElement('button');
        btn.id = 'ai-entry-btn-v14';
        btn.className = 'ai-entry';
        btn.innerHTML = t('btn_export');
        btn.onclick = startProcess;
        document.body.appendChild(btn);
    }

    function initUI() {
        if (document.getElementById('ai-overlay-v14')) {
            overlay.style.display = 'flex';
            return;
        }
        overlay = document.createElement('div');
        overlay.id = 'ai-overlay-v14';
        overlay.innerHTML = `
            <div id="ai-box">
                <div class="ai-title">${t('title_ready')}</div>
                <div class="ai-banner">${t('banner_top')}</div>
                <div class="ai-status">${t('status_init')}</div>
                <div class="ai-count">0</div>
                <div class="ai-btn-container">
                    <button id="ai-save-btn" class="ai-btn">${t('btn_save')}</button>
                    <button id="ai-close-btn" class="ai-btn ai-btn-secondary">${t('btn_close')}</button>
                </div>
            </div>
        `;
        document.body.appendChild(overlay);

        titleEl = overlay.querySelector('.ai-title');
        statusEl = overlay.querySelector('.ai-status');
        countEl = overlay.querySelector('.ai-count');
        closeBtn = overlay.querySelector('#ai-close-btn');
        const saveBtn = overlay.querySelector('#ai-save-btn');

        closeBtn.onclick = () => { overlay.style.display = 'none'; };
        saveBtn.onclick = async () => {
            if (cachedExportBlob) {
                downloadBlob(cachedExportBlob, `Gemini_Chat_v14_${Date.now()}.${exportMode === 'full' ? 'zip' : 'md'}`);
                return;
            }
            try {
                const result = await downloadCollectedData();
                if (!result) {
                    updateUI('ERROR', t('err_no_data'));
                }
            } catch (err) {
                console.error("Failed to re-download file:", err);
                debugLog((t('err_runtime') + (err && err.message ? err.message : '')), 'error');
                updateUI('ERROR', t('err_runtime') + err.message);
            }
        };
    }

    function computeCounts(order, map, includeUser = false) {
        const turns = order.length;
        let paragraphs = 0;
        for (const id of order) {
            const item = map.get(id);
            if (!item) continue;
            if (item.role === ROLE_GEMINI && item.thoughts) paragraphs++;
            const textOut = (item.text || '').trim();
            if (textOut.length > 0) {
                if (includeUser) {
                    paragraphs++;
                } else if (item.role !== ROLE_USER) {
                    paragraphs++;
                }
            }
        }
        return { turns, paragraphs };
    }

    function getDualCounts() {
        return computeCounts(turnOrder, collectedData, false);
    }

    function resetExportState() {
        collectedData.clear();
        turnOrder = [];
        processedTurnIds.clear();
        scannedAttachmentTurns.clear();
        cachedExportBlob = null;
        cancelRequested = false;
        hasFinished = false;
    }

    // 更新遮罩界面状态(支持多种流程状态)
    // Update overlay UI state (supports multiple workflow states)
    function updateUI(state, msg = "") {
        initUI();
        const saveBtn = overlay.querySelector('#ai-save-btn');
        const btnContainer = overlay.querySelector('.ai-btn-container');
        btnContainer.style.display = 'none';
        // Hide any mode-selection buttons by default; only show them from showModeSelection()
        btnContainer.querySelectorAll('.ai-mode-btn').forEach(btn => btn.style.display = 'none');

        if (state === 'COUNTDOWN') {
            titleEl.innerText = t('title_countdown');
            statusEl.innerHTML = t('status_countdown', msg);
            countEl.style.display = 'none';
            countEl.innerText = '';
        } else if (state === 'SCROLLING') {
            titleEl.innerText = t('title_scrolling');
            statusEl.innerHTML = t('status_scrolling');
            countEl.style.display = 'block';
            const { turns, paragraphs } = getDualCounts();
            countEl.innerText = `${t('ui_turns')}: ${turns}\n${t('ui_paragraphs')}: ${paragraphs}`;
        } else if (state === 'PACKAGING') {
            titleEl.innerText = t('title_scrolling');
            statusEl.innerHTML = msg + '<br>' + t('status_esc_hint');
            countEl.style.display = 'none';
        } else if (state === 'FINISHED') {
            titleEl.innerText = t('title_finished');
            statusEl.innerHTML = t('status_finished');
            const { turns, paragraphs } = getDualCounts();
            countEl.innerText = `${t('ui_turns')}: ${turns}\n${t('ui_paragraphs')}: ${paragraphs}`;
            btnContainer.style.display = 'flex';
            saveBtn.style.display = 'inline-block';
            closeBtn.style.display = 'inline-block';
        } else if (state === 'ERROR') {
            titleEl.innerText = t('title_error');
            statusEl.innerHTML = `<span class="ai-red">${msg}</span>`;
            debugLog(msg, 'error');
            btnContainer.style.display = 'flex';
            closeBtn.style.display = 'inline-block';
        }
    }

    // 显示导出模式选择(附件/纯文本)
    // Show export mode selection (attachments/text-only)
    function showModeSelection() {
        return new Promise((resolve, reject) => {
            initUI();
            titleEl.innerText = t('title_mode_select');
            statusEl.innerHTML = t('status_mode_select');
            countEl.innerText = '';

            const btnContainer = overlay.querySelector('.ai-btn-container');
            // Hide the persistent save/close pair while in mode-selection UI
            const saveBtn = overlay.querySelector('#ai-save-btn');
            const closeBtnEl = overlay.querySelector('#ai-close-btn');
            if (saveBtn) saveBtn.style.display = 'none';
            if (closeBtnEl) closeBtnEl.style.display = 'none';

            btnContainer.style.display = 'flex';
            // Remove any previously created mode buttons but keep save/close
            btnContainer.querySelectorAll('.ai-mode-btn').forEach(btn => btn.remove());

            // Helper to create buttons
            const createModeButton = (id, text, isPrimary, onClick) => {
                const btn = document.createElement('button');
                btn.id = id;
                btn.className = (isPrimary ? 'ai-btn' : 'ai-btn ai-btn-secondary') + ' ai-mode-btn';
                btn.textContent = text;
                btn.onclick = onClick;
                btnContainer.appendChild(btn);
                return btn;
            };

            const fullBtn = createModeButton('ai-mode-full', t('btn_mode_full'), true, () => {
                exportMode = 'full';
                resolve('full');
            });
            fullBtn.disabled = true;
            const fullHint = document.createElement('span');
            fullHint.className = 'ai-hint';
            fullHint.textContent = '(已合并至纯文本)';
            btnContainer.appendChild(fullHint);

            createModeButton('ai-mode-text', t('btn_mode_text'), false, () => {
                exportMode = 'text';
                resolve('text');
            });

            createModeButton('ai-mode-close', t('btn_close'), false, () => {
                overlay.style.display = 'none';
                reject(new Error('Export cancelled by user.'));
            });
        });
    }

    function debugLog(message, level = 'info') {
        try {
            if (!overlay) initUI();
            if (!statusEl) return;
            const line = document.createElement('div');
            if (level === 'error') {
                line.className = 'ai-red';
            }
            line.textContent = message;
            statusEl.appendChild(line);
        } catch (_) {}
    }

    window.addEventListener('error', (e) => {
        const msg = e && e.message ? e.message : 'Script error';
        debugLog(msg, 'error');
    });
    window.addEventListener('unhandledrejection', (e) => {
        const reason = e && e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled rejection';
        debugLog(reason, 'error');
    });

    // 当 ZIP 库不可用时的回退提示(纯文本/重试/取消)
    // Fallback prompt when ZIP library is unavailable (text/retry/cancel)
    function showZipFallbackPrompt() {
        return new Promise((resolve) => {
            initUI();
            titleEl.innerText = t('title_zip_missing');
            statusEl.innerHTML = t('status_zip_missing');
            countEl.innerText = '';
            const btnContainer = overlay.querySelector('.ai-btn-container');
            const saveBtn = overlay.querySelector('#ai-save-btn');
            const closeBtnEl = overlay.querySelector('#ai-close-btn');
            if (saveBtn) saveBtn.style.display = 'none';
            if (closeBtnEl) closeBtnEl.style.display = 'none';
            btnContainer.style.display = 'flex';
            btnContainer.querySelectorAll('.ai-mode-btn').forEach(btn => btn.remove());

            const createModeButton = (id, text, isPrimary, onClick) => {
                const btn = document.createElement('button');
                btn.id = id;
                btn.className = (isPrimary ? 'ai-btn' : 'ai-btn ai-btn-secondary') + ' ai-mode-btn';
                btn.textContent = text;
                btn.onclick = onClick;
                btnContainer.appendChild(btn);
            };

            createModeButton('ai-fallback-text', t('btn_mode_text'), true, () => {
                exportMode = 'text';
                resolve('text');
            });

            createModeButton('ai-retry-zip', t('btn_retry'), false, () => {
                resolve('retry');
            });

            createModeButton('ai-cancel', t('btn_cancel'), false, () => {
                overlay.style.display = 'none';
                resolve('cancel');
            });
        });
    }

    // 用户按下 ESC 的取消提示(选择继续打包或改为纯文本)
    // Cancel prompt when user presses ESC (continue attachments or text-only)
    function showCancelPrompt() {
        return new Promise((resolve) => {
            initUI();
            titleEl.innerText = t('title_cancel');
            statusEl.innerHTML = t('status_cancel');
            countEl.innerText = '';
            const btnContainer = overlay.querySelector('.ai-btn-container');
            const saveBtn = overlay.querySelector('#ai-save-btn');
            const closeBtnEl = overlay.querySelector('#ai-close-btn');
            if (saveBtn) saveBtn.style.display = 'none';
            if (closeBtnEl) closeBtnEl.style.display = 'none';
            btnContainer.style.display = 'flex';
            btnContainer.querySelectorAll('.ai-mode-btn').forEach(btn => btn.remove());

            const createModeButton = (id, text, isPrimary, onClick) => {
                const btn = document.createElement('button');
                btn.id = id;
                btn.className = (isPrimary ? 'ai-btn' : 'ai-btn ai-btn-secondary') + ' ai-mode-btn';
                btn.textContent = text;
                btn.onclick = onClick;
                btnContainer.appendChild(btn);
            };

            createModeButton('ai-cancel-text', t('btn_mode_text'), true, () => resolve('text'));
            createModeButton('ai-cancel-retry', t('btn_retry'), false, () => resolve('retry'));
            createModeButton('ai-cancel-close', t('btn_cancel'), false, () => resolve('cancel'));
        });
    }

    // ==========================================
    // 4. 核心流程
    // ==========================================
    // 导出主流程:模式选择 → 倒计时 → 采集 → 导出
    // Main export flow: mode select → countdown → capture → export
    async function startProcess() {
        if (isRunning) return;
        resetExportState();

        autoFixFormFieldAttributes();

        // 显示模式选择
        try {
            await showModeSelection();
        } catch (e) {
            dlog('Export cancelled.');
            // isRunning is still false here, so no cleanup needed
            return;
        }

        isRunning = true; // Enable global ESC handler only after mode is selected

        for (let i = 3; i > 0; i--) {
            updateUI('COUNTDOWN', i);
            await sleep(1000);
        }

        let scroller = findRealScroller();

        // 移动端增强激活逻辑
        if (!scroller || scroller.scrollHeight <= scroller.clientHeight) {
            dlog("尝试主动激活滚动容器...");
            // 先尝试滚动 window
            window.scrollBy(0, 1);
            await sleep(100);
            scroller = findRealScroller();
        }

        // 如果还是找不到,尝试触摸激活
        if (!scroller || scroller.scrollHeight <= scroller.clientHeight) {
            dlog("尝试触摸激活...");
            const bubble = document.querySelector('ms-chat-turn');
            if (bubble) {
                bubble.scrollIntoView({ behavior: 'instant' });
                await sleep(200);
                scroller = findRealScroller();
            }
        }

        if (!scroller) {
            endProcess("ERROR", t('err_no_scroller'));
            return;
        }

        updateUI('SCROLLING', 0);

        // ========================================
        // 智能跳转:使用滚动条按钮直接跳到第一个对话
        // ========================================
        dlog("尝试使用滚动条按钮跳转到第一个对话...");

        // 查找所有对话轮次按钮
        const scrollbarButtons = document.querySelectorAll('button[id^="scrollbar-item-"]');
        dlog(`找到 ${scrollbarButtons.length} 个对话轮次按钮`);

        if (scrollbarButtons.length > 0) {
            // 点击第一个按钮(最早的对话)
            const firstButton = scrollbarButtons[0];
            dlog("点击第一个对话按钮:", firstButton.getAttribute('name') || firstButton.id);
            firstButton.click();

            // 等待跳转和渲染
            await sleep(1500);
            dlog("跳转后 scrollTop:", scroller.scrollTop);
        } else {
            dlog("未找到滚动条按钮,使用备用方案...");
        }

        // 备用方案:如果按钮不存在或跳转失败,逐步向上滚动
        const initialScrollTop = scroller.scrollTop;
        if (initialScrollTop > 500) {
            dlog("执行备用滚动方案,当前 scrollTop:", initialScrollTop);
            let currentPos = initialScrollTop;
            let upwardAttempts = 0;
            const maxUpwardAttempts = 15; // 减少尝试次数

            while (currentPos > 100 && upwardAttempts < maxUpwardAttempts) {
                upwardAttempts++;

                // 每次向上滚动一个视口高度
                const scrollAmount = Math.min(window.innerHeight, currentPos);
                scroller.scrollBy({ top: -scrollAmount, behavior: 'smooth' });

                await sleep(500);

                const newPos = scroller.scrollTop;
                dlog(`向上滚动 ${upwardAttempts}/${maxUpwardAttempts}: ${currentPos} → ${newPos}`);

                // 如果卡住了,尝试直接设置
                if (Math.abs(newPos - currentPos) < 10) {
                    dlog("检测到卡住,尝试直接设置...");
                    scroller.scrollTop = Math.max(0, currentPos - scrollAmount);
                    await sleep(300);
                }

                currentPos = scroller.scrollTop;

                // 如果已经到顶部附近,退出
                if (currentPos < 100) {
                    break;
                }
            }
        }

        // 最终确保到达顶部
        dlog("执行最终回到顶部,当前 scrollTop:", scroller.scrollTop);
        scroller.scrollTop = 0;
        await sleep(500);

        // 再次确认
        if (scroller.scrollTop > 10) {
            scroller.scrollTo({ top: 0, behavior: 'instant' });
            await sleep(500);
        }

        dlog("✓ 回到顶部完成,最终 scrollTop:", scroller.scrollTop);

        // 等待 DOM 稳定
        await sleep(800);





        let lastScrollTop = -9999;
        let stuckCount = 0;

        try {
            while (isRunning) {
                await captureData(scroller);
                updateUI('SCROLLING', collectedData.size);

                scroller.scrollBy({ top: window.innerHeight * 0.7, behavior: 'smooth' });

                await sleep(900);

                const currentScroll = scroller.scrollTop;

                if (Math.abs(currentScroll - lastScrollTop) <= 2) {
                    stuckCount++;
                    if (stuckCount >= 3) {
                        dlog("判定到底", currentScroll);
                        break;
                    }
                } else {
                    stuckCount = 0;
                }
                lastScrollTop = currentScroll;
            }
        } catch (e) {
            console.error(e);
            endProcess("ERROR", t('err_runtime') + e.message);
            return;
        }

        endProcess("FINISHED");
    }

    function autoFixFormFieldAttributes() {
        try {
            const fields = document.querySelectorAll(
                'input[autocomplete]:not([name]), textarea[autocomplete]:not([name]), select[autocomplete]:not([name])'
            );
            let i = 0;
            fields.forEach(el => {
                const nm = 'ai_exporter_field_' + (i++);
                el.setAttribute('name', nm);
            });
            if (fields.length > 0) debugLog('Auto-assigned name for ' + fields.length + ' form fields');
        } catch (_) {}
    }

    // ==========================================
    // 5. 辅助功能
    // ==========================================

    // Shared Regex Constants
    // Capture: 1=Alt/Text, 2=URL, 3=Optional title (supports ')' in URL and single/double-quoted titles)
    const IMG_REGEX = /!\[([^\]]*)\]\((.+?)(\s+["'][^"']*["'])?\)/g;
    const LINK_REGEX = /\[([^\]]*)\]\((.+?)(\s+["'][^"']*["'])?\)/g;
    const ROLE_USER = 'User';
    const ROLE_GEMINI = 'Gemini';
    const ROLE_GEMINI_THOUGHTS = 'Gemini-Thoughts';

    function findRealScroller() {
        // Prioritize finding chat turns within the main content area to avoid sidebars
        const bubble = document.querySelector('main ms-chat-turn') || document.querySelector('ms-chat-turn');
        if (!bubble) {
            return document.querySelector('div[class*="scroll"]') || document.body;
        }

        let el = bubble.parentElement;
        while (el && el !== document.body) {
            const style = window.getComputedStyle(el);
            if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && el.scrollHeight >= el.clientHeight) {
                return el;
            }
            el = el.parentElement;
    }
    return document.documentElement;
}

function normalizeHref(href) {
    try {
        const raw = String(href || '').trim();
        if (!raw || raw === '#') return '';
        const u = new URL(raw, window.location.href);
        return u.href;
    } catch (_) {
        return '';
    }
}

function filterHref(href) {
        if (!href) return false;
        const lower = href.toLowerCase();
        if (lower.startsWith('http:') || lower.startsWith('https:')) return true;
        if (ATTACHMENT_COMBINED_FALLBACK && lower.startsWith('blob:')) return true;
        return false;
}

function extractDownloadLinksFromTurn(el) {
    const links = [];
    const isDownloadish = (href, a) => {
        if (!href) return false;
        const h = href.toLowerCase();
        const hasDownloadAttr = !!(a && a.getAttribute('download'));
        const tokenMatch = h.includes('/download') || h.includes('download=true') || h.includes('/dl/');
        const extMatch = /(\.zip|\.pdf|\.png|\.jpe?g|\.gif|\.webp|\.mp4|\.mov|\.tgz|\.tar\.gz|\.exe|\.rar|\.7z|\.csv|\.txt|\.json|\.md|\.xlsx|\.docx)(?:$|[?#])/i.test(h);
        let hostMatch = false;
        try {
            const u = new URL(href, window.location.href);
            const host = u.hostname.toLowerCase();
            hostMatch = [
                's3.amazonaws.com',
                'googleapis.com',
                'storage.googleapis.com',
                'drive.google.com',
                'blob.core.windows.net',
                'googleusercontent.com'
            ].some(domain => host === domain || host.endsWith('.' + domain));
        } catch (_) {}
        const schemeMatch = h.startsWith('blob:') || h.startsWith('data:');
        return hasDownloadAttr || tokenMatch || extMatch || hostMatch || schemeMatch;
    };
    const icons = el.querySelectorAll('span.material-symbols-outlined, span.ms-button-icon-symbol');
    icons.forEach(sp => {
        const txt = (sp.textContent || '').trim().toLowerCase();
        if (txt === 'download' || txt === '下载') {
            const a = sp.closest('a') || sp.parentElement?.querySelector('a[href]');
            const href = normalizeHref(a?.getAttribute('href') || '');
            if (filterHref(href)) links.push(href);
        }
    });
    const anchors = el.querySelectorAll('a[href]');
    anchors.forEach(a => {
        const href = normalizeHref(a.getAttribute('href') || '');
        if (isDownloadish(href, a) && filterHref(href)) links.push(href);
    });
    return Array.from(new Set(links));
}

    async function captureData(scroller = document) {
        // Scope the query to the scroller container to avoid capturing elements from other parts of the page
        const turns = scroller.querySelectorAll('ms-chat-turn');

        // Helper to derive a stable turn id from container or inner chunks
        const getTurnId = (el) => {
            if (el.id) return el.id;
            const chunk = el.querySelector('ms-prompt-chunk[id], ms-response-chunk[id], ms-thought-chunk[id]');
            return chunk ? chunk.id : null;
        };

        // Update turn order based on visible turns
        const visibleTurnIds = Array.from(new Set(Array.from(turns)
            .filter(t => t.offsetParent !== null && window.getComputedStyle(t).visibility !== 'hidden')
            .map(t => getTurnId(t))
            .filter(id => !!id)));
        updateTurnOrder(visibleTurnIds);

        for (const turn of turns) {
            // Check if the element is visible (offsetParent is null for hidden elements)
            if (turn.offsetParent === null || window.getComputedStyle(turn).visibility === 'hidden') continue;

            const turnId = getTurnId(turn);
            if (!turnId) continue;

            const role = (turn.querySelector('[data-turn-role="Model"]') || turn.querySelector('[class*="model-prompt-container"]')) ? ROLE_GEMINI : ROLE_USER;
            const existing = collectedData.get(turnId) || { role };
            const hasThoughtChunkNow = role === ROLE_GEMINI && !!turn.querySelector('ms-thought-chunk');

            if (processedTurnIds.has(turnId) && !(role === ROLE_GEMINI && !existing.thoughts && hasThoughtChunkNow)) continue;

            // Extract download links from the original turn before stripping UI-only elements
            let dlLinks = extractDownloadLinksFromTurn(turn);
            if (dlLinks.length > 0) {
                const prev = existing.attachments || [];
                existing.attachments = Array.from(new Set([...prev, ...dlLinks]));
            }

            if ((!existing.attachments || existing.attachments.length === 0) && !scannedAttachmentTurns.has(turnId)) {
                const imgs = Array.from(turn.querySelectorAll('img'));
                const found = [];
                existing.attachmentScanAttempted = true;
                const scanImg = async (img) => {
                    const r1 = img.getBoundingClientRect();
                    img.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
                    img.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
                    await sleep(80);
                    const spans = turn.querySelectorAll('span.material-symbols-outlined, span.ms-button-icon-symbol');
                    spans.forEach(sp => {
                        const txt = (sp.textContent || '').trim().toLowerCase();
                        if (txt !== 'download' && txt !== '下载') return;
                        const a = sp.closest('a') || sp.parentElement?.querySelector('a[href]');
                        if (a) {
                            const r2 = a.getBoundingClientRect();
                            const cx1 = (r1.left + r1.right) / 2, cy1 = (r1.top + r1.bottom) / 2;
                            const cx2 = (r2.left + r2.right) / 2, cy2 = (r2.top + r2.bottom) / 2;
                            const dist = Math.hypot(cx1 - cx2, cy1 - cy2);
                            if (dist < ATTACHMENT_MAX_DIST) {
                                const href = a?.getAttribute('href') || '';
                                if (filterHref(href)) found.push(href);
                            }
                        }
                    });
                    img.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
                };
                await Promise.all(imgs.map(img => scanImg(img)));
                if (found.length > 0) {
                    const prev = existing.attachments || [];
                    existing.attachments = Array.from(new Set([...prev, ...found]));
                }
                scannedAttachmentTurns.add(turnId);
            }

            const clone = turn.cloneNode(true);
            const trash = ['.actions-container', '.turn-footer', 'button', 'mat-icon', 'ms-grounding-sources', 'ms-search-entry-point', '.role-label', '.ms-role-tag', 'svg', '.author-label'];
            trash.forEach(s => clone.querySelectorAll(s).forEach(e => e.remove()));

            if (role === ROLE_GEMINI) {
                const thoughtChunk = clone.querySelector('ms-thought-chunk');
                if (thoughtChunk) {
                    const thoughtsText = cleanMarkdown(htmlToMarkdown(thoughtChunk));
                    thoughtChunk.remove();
                    if (thoughtsText.length > 0 && !existing.thoughts) {
                        existing.thoughts = thoughtsText;
                    }
                }
            }

            const text = cleanMarkdown(htmlToMarkdown(clone));
            if (text.length > 0 && !existing.text) {
                existing.text = text;
            }

            if (existing.text || existing.thoughts || (Array.isArray(existing.attachments) && existing.attachments.length > 0)) {
                collectedData.set(turnId, existing);
                if (role === ROLE_USER || (role === ROLE_GEMINI && !!existing.text)) {
                    processedTurnIds.add(turnId);
                }
            }
        }
    }

    function findLastCommonIdx(newIds, oldOrder) {
        for (let i = newIds.length - 1; i >= 0; i--) {
            if (oldOrder.includes(newIds[i])) return i;
        }
        return -1;
    }

    function mergeWithOverlap(oldOrder, newIds) {
        const oldIdSet = new Set(oldOrder);
        const result = [...oldOrder];
        newIds.forEach((newId, index) => {
            if (!oldIdSet.has(newId)) {
                let prevInOldIdx = -1;
                for (let i = index - 1; i >= 0; i--) {
                    const neighborId = newIds[i];
                    const pos = result.indexOf(neighborId);
                    if (pos !== -1) { prevInOldIdx = pos; break; }
                }
                result.splice(prevInOldIdx + 1, 0, newId);
            }
        });
        return result;
    }

    function appendDisjointIds(oldOrder, newIds) {
        return [...oldOrder, ...newIds];
    }

    function updateTurnOrder(newIds) {
        if (!newIds || newIds.length === 0) return;
        if (turnOrder.length === 0) {
            turnOrder = [...newIds];
            return;
        }
        const firstCommonIdx = newIds.findIndex(id => turnOrder.includes(id));
        if (firstCommonIdx !== -1) {
            turnOrder = mergeWithOverlap(turnOrder, newIds);
        } else {
            turnOrder = appendDisjointIds(turnOrder, newIds);
        }
        turnOrder = [...new Set(turnOrder)];
    }

    function htmlToMarkdown(node, listContext = null, indent = 0) {
        if (node.nodeType === Node.TEXT_NODE) {
            return node.textContent;
        }

        if (node.nodeType !== Node.ELEMENT_NODE) return '';

        const tag = node.tagName.toLowerCase();

        // Images
        if (tag === 'img') {
            const alt = node.getAttribute('alt') || '';
            const src = node.getAttribute('src') || '';
            return `![${alt}](${src})`;
        }

        // Code blocks
        if (tag === 'pre') {
            const codeEl = node.querySelector('code');
            if (codeEl) {
                const language = Array.from(codeEl.classList).find(c => c.startsWith('language-'))?.replace('language-', '') || '';
                const code = codeEl.textContent;
                return `\n\`\`\`${language}\n${code}\n\`\`\`\n`;
            }
        }

        // Inline code
        if (tag === 'code') {
            const text = node.textContent;
            // Handle backticks inside inline code for correct Markdown rendering.
            if (text.includes('`')) {
                return `\`\` ${text} \`\``;
            }
            return `\`${text}\``;
        }

        // Headings
        if (/^h[1-6]$/.test(tag)) {
            const level = parseInt(tag[1]);
            return '\n' + '#'.repeat(level) + ' ' + getChildrenText(node, listContext, indent) + '\n';
        }

        // Bold
        if (tag === 'strong' || tag === 'b') {
            return `**${getChildrenText(node, listContext, indent)}**`;
        }

        // Italic
        if (tag === 'em' || tag === 'i') {
            return `*${getChildrenText(node, listContext, indent)}*`;
        }

        // Links
        if (tag === 'a') {
            const href = node.getAttribute('href') || '';
            const text = getChildrenText(node, listContext, indent);
            return `[${text}](${href})`;
        }

        // Lists - pass context to children
        if (tag === 'ul' || tag === 'ol') {
            const listType = tag; // 'ul' or 'ol'
            let index = 0;
            let result = '\n';

            for (const child of node.childNodes) {
                if (child.nodeType === Node.ELEMENT_NODE && child.tagName.toLowerCase() === 'li') {
                    index++;
                    // Pass indent + 1 to children
                    result += htmlToMarkdown(child, { type: listType, index: index }, indent + 1);
                } else {
                    // Pass indent + 1 to children even if not li (e.g. nested ul)
                    result += htmlToMarkdown(child, listContext, indent + 1);
                }
            }

            return result + '\n';
        }

        // List items - use context to determine format
        if (tag === 'li') {
            // Children of li are at the same indent level as the li itself (which is already indented by parent)
            const content = getChildrenText(node, listContext, indent);
            // Render bullet at indent - 1
            const indentStr = '  '.repeat(Math.max(0, indent - 1));
            if (listContext && listContext.type === 'ol') {
                return `${indentStr}${listContext.index}. ${content}\n`;
            } else {
                return `${indentStr}- ${content}\n`;
            }
        }

        // Line breaks
        if (tag === 'br') {
            return '  \n';
        }

        // Blockquotes - prefix each line with >
        if (tag === 'blockquote') {
            const content = getChildrenText(node, listContext, indent);
            // Split by lines and prefix each with "> "
            return '\n' + content.split('\n')
                .map(line => `> ${line}`)
                .join('\n') + '\n';
        }

        // Block elements
        if (['div', 'p'].includes(tag)) {
            return '\n' + getChildrenText(node, listContext, indent) + '\n';
        }

        return getChildrenText(node, listContext, indent);
    }

    function getChildrenText(node, listContext = null, indent = 0) {
        return Array.from(node.childNodes).map(child => htmlToMarkdown(child, listContext, indent)).join('');
    }

    function cleanMarkdown(str) {
        return str.trim().replace(/\n{3,}/g, '\n\n');
    }

    // Helper: Get role name for display
    function getRoleName(role) {
        switch (role) {
            case ROLE_GEMINI_THOUGHTS:
                return t('role_thoughts');
            case ROLE_GEMINI:
                return t('role_gemini');
            case ROLE_USER:
                return t('role_user');
            default:
                return role; // 为未知的角色类型提供回退
        }
    }

    // Normalize: merge consecutive Gemini-thoughts-only into next Gemini text within the same segment
    function normalizeConversation() {
        if (turnOrder.length === 0 || collectedData.size === 0) return;
        const newOrder = [];
        const newMap = new Map();

        for (let i = 0; i < turnOrder.length; i++) {
            const id = turnOrder[i];
            const item = collectedData.get(id);
            if (!item) continue;

            if (item.role === ROLE_GEMINI && item.thoughts && !item.text) {
                let merged = false;
                for (let j = i + 1; j < turnOrder.length; j++) {
                    const nextId = turnOrder[j];
                    const nextItem = collectedData.get(nextId);
                    if (!nextItem) continue;
                    if (nextItem.role === ROLE_USER) break;
                    if (nextItem.role === ROLE_GEMINI && nextItem.text) {
                        nextItem.thoughts = nextItem.thoughts
                            ? (item.thoughts + '\n\n' + nextItem.thoughts)
                            : item.thoughts;
                        collectedData.set(nextId, nextItem);
                        merged = true;
                        break;
                    }
                }
                if (merged) {
                    continue; // skip adding this thoughts-only entry
                }
            }

            newOrder.push(id);
            newMap.set(id, item);
        }

        turnOrder = newOrder;
        collectedData = newMap;
    }

    // 统计导出内容的段落数(不含 User 段落)
    // Count exported paragraphs (excluding User paragraphs)
    function countParagraphs() {
        return computeCounts(turnOrder, collectedData, false).paragraphs;
    }

    // Helper: Download text-only mode
    // 仅文本导出:生成 Markdown 并下载
    // Text-only export: generate Markdown and download
    async function downloadTextOnly() {
        let content = `# ${t('file_header')}` + "\n\n";
        content += `**${t('file_time')}:** ${new Date().toLocaleString()}` + "\n\n";
        content += `**${t('file_turns')}:** ${turnOrder.length}` + "\n\n";
        content += `**${t('file_paragraphs')}:** ${countParagraphs()}` + "\n\n";
        content += "---\n\n";

        for (const id of turnOrder) {
            const item = collectedData.get(id);
            if (!item) continue;
            if (item.role === ROLE_GEMINI && item.thoughts) {
                const processedThoughts = convertResourcesToLinks(item.thoughts || '');
                content += `## ${t('role_thoughts')}\n\n${processedThoughts}\n\n`;
                content += `---\n\n`;
            }
            const roleName = getRoleName(item.role);
            const textOut = (item.text || '').trim();
            const attachmentsMd = generateAttachmentsMarkdown(item);
            if (textOut.length > 0) {
                const processedText = convertResourcesToLinks(textOut);
                content += `## ${roleName}\n\n${processedText}\n\n`;
                if (attachmentsMd) content += attachmentsMd;
                content += `---\n\n`;
            } else if (attachmentsMd) {
                content += attachmentsMd + `---\n\n`;
            }
        }

        const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
        cachedExportBlob = blob;
        downloadBlob(blob, `Gemini_Chat_v14_${Date.now()}.md`);
        return;
    }

    // Generic Helper: Process resources (images or files)
    // 通用打包助手:并发下载资源、支持进度与取消
    // Generic packaging helper: concurrent downloads with progress and cancel support
    async function processResources(uniqueUrls, zipFolder, config) {
        const resourceMap = new Map();

        if (uniqueUrls.size > 0) {
            updateUI('PACKAGING', t(config.statusStart, { n: uniqueUrls.size }));
            let completedCount = 0;

            const promises = Array.from(uniqueUrls).map(async (url, index) => {
                if (cancelRequested) return;
                try {
                    const blob = await fetchResource(url);
                    if (blob) {
                        const filename = config.filenameGenerator(url, index, blob);
                        zipFolder.file(filename, blob);
                        resourceMap.set(url, `${config.subDir}/${filename}`);
                    }
                } catch (e) {
                    console.error(`${config.subDir} download failed:`, url, e);
                    debugLog(`${config.subDir} download failed: ${url} (${e && e.message ? e.message : 'error'})`, 'error');
                }
                completedCount++;
                if (completedCount % 5 === 0 || completedCount === uniqueUrls.size) {
                    updateUI('PACKAGING', t(config.statusProgress, { c: completedCount, t: uniqueUrls.size }));
                }
            });

            let cancelIntervalId = null;
            const cancelWatcher = new Promise(resolve => {
                cancelIntervalId = setInterval(() => {
                    if (cancelRequested) { clearInterval(cancelIntervalId); resolve(); }
                }, 200);
            });
            try {
                await Promise.race([Promise.all(promises), cancelWatcher]);
            } finally {
                if (cancelIntervalId) clearInterval(cancelIntervalId);
            }
        }
        return resourceMap;
    }

    // Helper: Collect unique image URLs from all messages
    function collectImageUrls() {
        const uniqueUrls = new Set();
        for (const item of collectedData.values()) {
            const text = item.text || '';
            const thoughts = item.thoughts || '';

            for (const match of text.matchAll(IMG_REGEX)) {
                uniqueUrls.add(match[2]);
            }
            for (const match of thoughts.matchAll(IMG_REGEX)) {
                uniqueUrls.add(match[2]);
            }
        }
        return uniqueUrls;
    }

    // Helper: Process and download images
    async function processImages(imgFolder) {
        const uniqueUrls = collectImageUrls();
        return processResources(uniqueUrls, imgFolder, {
            subDir: 'images',
            statusStart: 'status_packaging_images',
            statusProgress: 'status_packaging_images_progress',
            filenameGenerator: (url, index, blob) => {
                const extension = (blob.type.split('/')[1] || 'png').split('+')[0];
                return `image_${index}.${extension}`;
            }
        });
    }

    // Helper: Collect unique file URLs from all messages
    function collectFileUrls() {
        const downloadableExtensions = ['.pdf', '.csv', '.txt', '.json', '.py', '.js', '.html', '.css', '.md', '.zip', '.tar', '.gz'];
        const uniqueUrls = new Set();

        const fileFilter = (match) => {
            // match[0].startsWith('!') check removed as it's ineffective for LINK_REGEX matches
            const url = match[2];
            const lowerUrl = url.toLowerCase();
            const isBlob = lowerUrl.startsWith('blob:');
            const isGoogleStorage = lowerUrl.includes('googlestorage') || lowerUrl.includes('googleusercontent');
            const hasExt = downloadableExtensions.some(ext => lowerUrl.split('?')[0].endsWith(ext));
            return isBlob || isGoogleStorage || hasExt;
        };

        for (const item of collectedData.values()) {
            const text = item.text || '';
            const thoughts = item.thoughts || '';

            for (const match of text.matchAll(LINK_REGEX)) {
                // Skip image-style markdown links: `![alt](url)`
                if (match.index > 0 && text[match.index - 1] === '!') continue;

                if (fileFilter(match)) {
                    uniqueUrls.add(match[2]);
                }
            }
            for (const match of thoughts.matchAll(LINK_REGEX)) {
                if (match.index > 0 && thoughts[match.index - 1] === '!') continue;
                if (fileFilter(match)) {
                    uniqueUrls.add(match[2]);
                }
            }
        }
        return uniqueUrls;
    }

    // Helper: Process and download files
    async function processFiles(fileFolder) {
        const uniqueUrls = collectFileUrls();
        return processResources(uniqueUrls, fileFolder, {
            subDir: 'files',
            statusStart: 'status_packaging_files',
            statusProgress: 'status_packaging_files_progress',
            filenameGenerator: (url, index, blob) => {
                let filename = "file";
                try {
                    const urlObj = new URL(url);
                    filename = urlObj.pathname.substring(urlObj.pathname.lastIndexOf('/') + 1);
                } catch (e) {
                    filename = url.split('/').pop().split('?')[0];
                }

                let decodedFilename = filename;
                try {
                    decodedFilename = decodeURIComponent(filename);
                } catch (e) {
                    console.warn(`Could not decode filename: ${filename}`, e);
                }
                // Increased limit from 50 to 100 as per PR review
                if (!decodedFilename || decodedFilename.length > 100) {
                    const extMatch = filename.match(/\.[^./?]+$/);
                    const ext = extMatch ? extMatch[0] : '';
                    decodedFilename = `file_${index}${ext}`;
                }
                return `${index}_${decodedFilename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
            }
        });
    }

    // Helper: Generate Markdown content with URL replacements
    function generateMarkdownContent(imgMap, fileMap) {
        let content = `# ${t('file_header')}` + "\n\n";
        content += `**${t('file_time')}:** ${new Date().toLocaleString()}` + "\n\n";
        content += `**${t('file_turns')}:** ${turnOrder.length}` + "\n\n";
        content += `**${t('file_paragraphs')}:** ${countParagraphs()}` + "\n\n";
        content += "---\n\n";

        for (const id of turnOrder) {
            const item = collectedData.get(id);
            if (!item) continue;
            if (item.role === ROLE_GEMINI && item.thoughts) {
                let processedThoughts = item.thoughts;
                processedThoughts = processedThoughts.replace(IMG_REGEX, (match, alt, url, title) => {
                    if (imgMap.has(url)) {
                        const titleStr = title || '';
                        return `![${alt}](${imgMap.get(url)}${titleStr})`;
                    }
                    return match;
                });
                processedThoughts = processedThoughts.replace(LINK_REGEX, (match, text, url, title) => {
                    if (fileMap.has(url)) {
                        const titleStr = title || '';
                        return `[${text}](${fileMap.get(url)}${titleStr})`;
                    }
                    return match;
                });
                content += `## ${t('role_thoughts')}\n\n${processedThoughts}\n\n`;
                content += `---\n\n`;
            }

            const roleName = getRoleName(item.role);
            let processedText = (item.text || '').trim();
            const attachmentsMd = generateAttachmentsMarkdown(item);

            processedText = processedText.replace(IMG_REGEX, (match, alt, url, title) => {
                if (imgMap.has(url)) {
                    const titleStr = title || '';
                    return `![${alt}](${imgMap.get(url)}${titleStr})`;
                }
                return match;
            });
            processedText = processedText.replace(LINK_REGEX, (match, text, url, title) => {
                if (fileMap.has(url)) {
                    const titleStr = title || '';
                    return `[${text}](${fileMap.get(url)}${titleStr})`;
                }
                return match;
            });

            if (processedText.length > 0) {
                content += `## ${roleName}\n\n${processedText}\n\n`;
                if (attachmentsMd) content += attachmentsMd;
                content += `---\n\n`;
            } else if (attachmentsMd) {
                content += attachmentsMd + `---\n\n`;
            }
        }

        return content;
    }

    function toFileName(url) {
        let base = 'file';
        try {
            const u = new URL(url);
            base = u.pathname.substring(u.pathname.lastIndexOf('/') + 1) || 'file';
            if (!base || base === 'file') {
                const qp = new URLSearchParams(u.search);
                const cand = qp.get('filename') || qp.get('file') || qp.get('name');
                if (cand) base = cand;
            }
        } catch (_) {
            base = url.split('/').pop().split('?')[0] || 'file';
            if (!base || base === 'file') {
                const m = String(url).match(/[?&](?:filename|file|name)=([^&]+)/i);
                if (m) base = m[1];
            }
        }
        base = String(base).replace(/^['"]+|['"]+$/g, '');
        try {
            return decodeURIComponent(base);
        } catch (_) {
            return base;
        }
    }

    function escapeMdLabel(s) {
        return String(s || '').replace(/]/g, '\\]').replace(/\n/g, ' ');
    }

    function generateAttachmentsMarkdown(item) {
        const links = Array.isArray(item.attachments) ? item.attachments : [];
        if (links.length === 0 && !(ATTACHMENT_COMBINED_FALLBACK && item.attachmentScanAttempted)) {
            return '';
        }
        let listContent;
        if (links.length > 0) {
            listContent = links.map(u => {
                const label = escapeMdLabel(toFileName(u));
                return `- [${label}](<${u}>)`;
            }).join('\n');
        } else {
            listContent = `- ${t('attachments_link_unavailable')}`;
        }
        return `### ${t('attachments_section')}\n\n${listContent}\n\n`;
    }

    function convertResourcesToLinks(text) {
        const replacedImages = text.replace(IMG_REGEX, (match, alt, url) => {
            const name = (alt && alt.trim().length > 0) ? alt.trim() : toFileName(url);
            return `[${name}](${url})`;
        });
        const replacedLinks = replacedImages.replace(LINK_REGEX, (match, textLabel, url) => {
            const name = (textLabel && textLabel.trim().length > 0) ? textLabel.trim() : toFileName(url);
            return `[${name}](${url})`;
        });
        return replacedLinks;
    }

    // 获取 JSZip:优先使用 IIFE 外部捕获的引用
    // Get JSZip: prefer the reference captured outside IIFE
    function getJSZip() {
        // 1. 使用 IIFE 外部捕获的引用(@require 加载的)
        if (_JSZipRef) {
            return _JSZipRef;
        }
        // 2. 检查当前作用域中的 JSZip
        if (typeof JSZip !== 'undefined') {
            return JSZip;
        }
        // 3. 检查页面上下文(通过 script 标签注入的)
        if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.JSZip !== 'undefined') {
            return unsafeWindow.JSZip;
        }
        // 4. 检查 window 对象
        if (typeof window !== 'undefined' && typeof window.JSZip !== 'undefined') {
            return window.JSZip;
        }
        return null;
    }

    // 加载 JSZip 的备用方案(通过 blob URL 注入脚本绕过 CSP)
    // Fallback loader for JSZip (inject script via blob URL to bypass CSP)
    async function ensureJSZip() {
        const existing = getJSZip();
        if (existing) return existing;

        if (DISABLE_SCRIPT_INJECTION) {
            debugLog('Script injection disabled due to CSP. Use @require or choose text-only.', 'error');
            return null;
        }

        // GM 注入:依次尝试多 CDN
        if (typeof GM_xmlhttpRequest !== 'undefined') {
            for (const url of JSZIP_URLS) {
                try {
                    /* eslint-disable no-await-in-loop */
                    const lib = await new Promise((resolve, reject) => {
                        GM_xmlhttpRequest({
                            method: 'GET',
                            url,
                            responseType: 'blob',
                            onload: (response) => {
                                try {
                                    const blobUrl = URL.createObjectURL(response.response);
                                    const script = document.createElement('script');
                                    script.src = blobUrl;
                                    script.onload = () => {
                                        URL.revokeObjectURL(blobUrl);
                                        const loaded = getJSZip();
                                        loaded ? resolve(loaded) : reject(new Error('JSZip not defined after load'));
                                    };
                                    script.onerror = () => { URL.revokeObjectURL(blobUrl); reject(new Error('JSZip script load failed')); };
                                    document.head.appendChild(script);
                                } catch (e) { reject(e); }
                            },
                            onerror: () => reject(new Error('JSZip download failed'))
                        });
                    });
                    if (lib) return lib;
                } catch (e) { debugLog('JSZip load failed: ' + url + ' (' + (e && e.message ? e.message : 'error') + ')', 'error'); }
            }
        }

        // script 注入:依次尝试多 CDN
        for (const url of JSZIP_URLS) {
            try {
                /* eslint-disable no-await-in-loop */
                const lib = await new Promise((resolve, reject) => {
                    const script = document.createElement('script');
                    script.src = url;
                    script.onload = () => {
                        const loaded = getJSZip();
                        loaded ? resolve(loaded) : reject(new Error('JSZip not defined after load'));
                    };
                    script.onerror = () => reject(new Error('JSZip load failed'));
                    document.head.appendChild(script);
                });
                if (lib) return lib;
            } catch (e) { debugLog('JSZip script injection failed: ' + url + ' (' + (e && e.message ? e.message : 'error') + ')', 'error'); }
        }
        debugLog('All JSZip CDN attempts failed', 'error');
        throw new Error('All JSZip CDN attempts failed');
    }

    // Main function: orchestrate the download process
    // 导出调度:纯文本/附件模式、ZIP 生成与回退
    // Export orchestrator: text/attachments modes, ZIP generation & fallback
    async function downloadCollectedData() {
        if (collectedData.size === 0) return false;
        // Normalize conversation before exporting (affects both modes)
        normalizeConversation();

        // Text-only mode
        if (exportMode === 'text') {
            downloadTextOnly();
            return true;
        }

        // Full mode with attachments
        let JSZipLib = getJSZip();
        if (!JSZipLib) {
            try { JSZipLib = await ensureJSZip(); } catch (e) { console.error('ensureJSZip failed:', e); debugLog('ensureJSZip failed: ' + (e && e.message ? e.message : 'error'), 'error'); }
        }
        while (!JSZipLib) {
            const action = await showZipFallbackPrompt();
            if (action === 'text') {
                downloadTextOnly();
                return true;
            }
            if (action === 'retry') {
                try { JSZipLib = await ensureJSZip(); } catch (e) { console.error('ensureJSZip retry failed:', e); }
                continue;
            }
            return false;
        }
        const zip = new JSZipLib();
        const imgFolder = zip.folder("images");
        const fileFolder = zip.folder("files");

        // Process images and files in parallel (memory-efficient approach)
        const [imgMap, fileMap] = await Promise.all([
            processImages(imgFolder),
            processFiles(fileFolder)
        ]);

        // Generate final Markdown content
        const content = generateMarkdownContent(imgMap, fileMap);

        zip.file("chat_history.md", content);
        let zipBlob;
        try {
            zipBlob = await Promise.race([
                zip.generateAsync({ type: "blob" }),
                new Promise((_, reject) => setTimeout(() => reject(new Error('ZIP timeout')), 15000))
            ]);
        } catch (e) {
            const action = await showZipFallbackPrompt();
            if (action === 'text') {
                downloadTextOnly();
                return true;
            }
            if (action === 'retry') {
                try {
                    zipBlob = await zip.generateAsync({ type: "blob" });
                } catch (_) {
                    downloadTextOnly();
                    return true;
                }
            } else {
                return false;
            }
        }
        cachedExportBlob = zipBlob;
        downloadBlob(zipBlob, `Gemini_Chat_v14_${Date.now()}.zip`);

        return true;
    }

    // 资源下载:支持 GM_xmlhttpRequest 与 fetch,并内置超时
    // Resource fetcher: supports GM_xmlhttpRequest and fetch, with timeout
    function fetchResource(url) {
        const timeoutMs = 10000;
        return new Promise((resolve) => {
            let settled = false;
            const timeout = setTimeout(() => { if (!settled) { settled = true; debugLog(`Resource fetch timed out: ${url}`, 'error'); resolve(null); } }, timeoutMs);
            const finish = (val) => { if (!settled) { settled = true; clearTimeout(timeout); resolve(val); } };

            if (typeof GM_xmlhttpRequest !== 'undefined') {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    responseType: "blob",
                    onload: (response) => {
                        if (response.status >= 200 && response.status < 300) {
                            finish(response.response);
                        } else {
                            console.warn(`Resource fetch failed with status ${response.status}:`, url);
                            debugLog(`Resource fetch failed (${response.status}): ${url}`, 'error');
                            finish(null);
                        }
                    },
                    onerror: () => { debugLog(`Resource fetch network error: ${url}`, 'error'); finish(null); }
                });
            } else {
                fetch(url, { credentials: 'include' })
                    .then(r => {
                        if (r.ok) return r.blob();
                        debugLog(`Fetch failed (${r.status}): ${url}`, 'error');
                        return null;
                    })
                    .then(finish)
                    .catch(() => { debugLog(`Fetch error: ${url}`, 'error'); finish(null); });
            }
        });
    }

    function downloadBlob(blob, name) {
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = name;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    function endProcess(status, msg) {
        if (hasFinished) return;
        hasFinished = true;
        isRunning = false;

        if (status === "FINISHED") {
            if (collectedData.size > 0) {
                downloadCollectedData().then(() => {
                    updateUI('FINISHED', collectedData.size);
                }).catch(err => {
                    console.error("Failed to generate and download file:", err);
                    updateUI('ERROR', t('err_runtime') + err.message);
                });
            } else {
                updateUI('ERROR', t('err_no_data'));
            }
        } else {
            updateUI('ERROR', msg);
        }
    }

    function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

    // 全局 ESC 处理:弹出取消提示并根据选择继续或回退
    // Global ESC handler: show cancel prompt and proceed based on choice
    document.addEventListener('keydown', async e => {
        if (e.key !== 'Escape') return;
        if (!isRunning || isHandlingEscape) return;
        isHandlingEscape = true;
        try {
            cancelRequested = true;
            const choice = await showCancelPrompt();
            if (choice === 'text') {
                normalizeConversation();
                exportMode = 'text';
                try { await downloadTextOnly(); } catch (err) { debugLog('Text export failed: ' + (err && err.message ? err.message : 'error'), 'error'); }
                updateUI('FINISHED', collectedData.size);
                isRunning = false;
            } else if (choice === 'retry') {
                cancelRequested = false;
                exportMode = 'full';
                isRunning = true;
                try { await downloadCollectedData(); } catch (err) { debugLog('Retry export failed: ' + (err && err.message ? err.message : 'error'), 'error'); }
            } else {
                isRunning = false;
                overlay.style.display = 'none';
            }
        } finally {
            isHandlingEscape = false;
        }
    });

    setInterval(createEntryButton, 2000);
})();