Thriller

M

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Thriller
// @namespace    http://tampermonkey.net/merged_tools
// @version      1.0.1
// @description  M
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @match        https://notebooklm.google.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @connect      notebooklm.google.com
// @connect      accounts.google.com
// @license PolyForm-Noncommercial-1.0.0  https://polyformproject.org/licenses/noncommercial/1.0.0/
// ==/UserScript==


(function () {
    'use strict';

    const SINGLETON_KEY = '__CHATGPT_EXPORTER_MARKDOWN_SINGLETON__';
    if (window[SINGLETON_KEY]) {
        console.warn('[ChatGPT Exporter] 检测到重复实例,当前实例已跳过启动。');
        return;
    }
    window[SINGLETON_KEY] = true;

    // --- 配置与全局变量 ---
    const BASE_DELAY = 600;
    const JITTER = 400;
    const PAGE_LIMIT = 100;
    const PROJECT_SIDEBAR_PREVIEW = 5;
    const AUTO_EXPORT_STORAGE_KEY = 'chatgpt_exporter_auto_export_schedule_v1';
    const EXPORT_FILENAME_SETTINGS_STORAGE_KEY = 'chatgpt_exporter_filename_settings_v1';
    const EXPORT_DOWNLOAD_SETTINGS_STORAGE_KEY = 'chatgpt_exporter_download_settings_v1';
    const FILE_HANDLE_DB_NAME = 'chatgpt_exporter_file_handles_v1';
    const FILE_HANDLE_STORE_NAME = 'handles';
    const FILE_HANDLE_KEY_ZIP = 'zip_export_target';
    const AUTO_EXPORT_MIN_MINUTES = 1;
    const DEFAULT_NOTIFY_MODE = 'toast'; // 可改为 'silent' 以完全静默
    const SCHEDULE_NOTIFY_MODE = 'silent';
    const TEAM_ONLY_MODE = true; // 仅保留团队空间导出
    let accessToken = null;
    let capturedWorkspaceIds = new Set(); // 使用Set存储网络拦截到的ID,确保唯一性
    let exportInProgress = false;
    let autoExportTimer = null;
    let autoExportTaskRunning = false;
    let autoExportState = loadAutoExportState();
    let filenameSettings = loadFilenameSettings();
    let downloadSettings = loadDownloadSettings();
    let cachedZipFileHandle = null;
    let fileHandleDbPromise = null;
    // --- 核心:网络拦截与信息捕获 ---
    (function interceptNetwork() {
        const rawFetch = window.fetch;
        window.fetch = async function (resource, options) {
            tryCaptureToken(options?.headers);
            if (options?.headers?.['ChatGPT-Account-Id']) {
                const id = options.headers['ChatGPT-Account-Id'];
                if (id && !capturedWorkspaceIds.has(id)) {
                    console.log('🎯 [Fetch] 捕获到 Workspace ID:', id);
                    capturedWorkspaceIds.add(id);
                }
            }
            return rawFetch.apply(this, arguments);
        };

        const rawOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function () {
            this.addEventListener('readystatechange', () => {
                if (this.readyState === 4) {
                    try {
                        tryCaptureToken(this.getRequestHeader('Authorization'));
                        const id = this.getRequestHeader('ChatGPT-Account-Id');
                        if (id && !capturedWorkspaceIds.has(id)) {
                            console.log('🎯 [XHR] 捕获到 Workspace ID:', id);
                            capturedWorkspaceIds.add(id);
                        }
                    } catch (_) {}
                }
            });
            return rawOpen.apply(this, arguments);
        };
    })();

    function tryCaptureToken(header) {
        if (!header) return;
        const h = typeof header === 'string' ? header : header instanceof Headers ? header.get('Authorization') : header.Authorization || header.authorization;
        if (h?.startsWith('Bearer ')) {
        const token = h.slice(7);
        // [v8.2.0 修复] 在捕获源头增加验证,拒绝已知的无效占位符Token
        if (token && token.toLowerCase() !== 'dummy') {
            accessToken = token;
        }
        }
    }

    async function ensureAccessToken(options = {}) {
        const { notifyMode = DEFAULT_NOTIFY_MODE } = options;
        if (accessToken) return accessToken;
        try {
            const session = await (await fetch('/api/auth/session?unstable_client=true')).json();
            if (session.accessToken) {
                accessToken = session.accessToken;
                return accessToken;
            }
        } catch (_) {}
        notifyUser('无法获取 Access Token。请刷新页面或打开任意一个对话后再试。', {
            mode: notifyMode,
            level: 'error',
            duration: 3200
        });
        return null;
    }

    // --- 辅助函数 ---
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const jitter = () => BASE_DELAY + Math.random() * JITTER;
    const sanitizeFilename = (name) => name.replace(/[\/\\?%*:|"<>]/g, '-').trim();
    const normalizeEpochSeconds = (value) => {
        if (!value) return 0;
        if (typeof value === 'number' && Number.isFinite(value)) {
            return value > 1e12 ? Math.floor(value / 1000) : value;
        }
        if (typeof value === 'string') {
            const parsed = Date.parse(value);
            if (!Number.isNaN(parsed)) {
                return Math.floor(parsed / 1000);
            }
        }
        return 0;
    };
    const formatTimestamp = (value) => {
        const seconds = normalizeEpochSeconds(value);
        if (!seconds) return '';
        const date = new Date(seconds * 1000);
        return Number.isNaN(date.getTime()) ? '' : date.toLocaleString();
    };
    const parseDateInputToEpoch = (value, isEnd = false) => {
        if (!value) return null;
        const parts = value.split('-').map(Number);
        if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
        const [year, month, day] = parts;
        const date = isEnd
            ? new Date(year, month - 1, day, 23, 59, 59, 999)
            : new Date(year, month - 1, day, 0, 0, 0, 0);
        const epochMs = date.getTime();
        return Number.isNaN(epochMs) ? null : Math.floor(epochMs / 1000);
    };

    function ensureToastStyles() {
        if (document.getElementById('chatgpt-exporter-toast-style')) return;
        const style = document.createElement('style');
        style.id = 'chatgpt-exporter-toast-style';
        style.textContent = `
            #cge-toast-container {
                position: fixed;
                right: 16px;
                bottom: 16px;
                z-index: 100001;
                display: flex;
                flex-direction: column;
                gap: 8px;
                pointer-events: none;
                max-width: min(78vw, 360px);
            }
            .cge-toast {
                padding: 10px 12px;
                border-radius: 10px;
                color: #fff;
                font-size: 13px;
                line-height: 1.35;
                box-shadow: 0 6px 20px rgba(0, 0, 0, 0.26);
                opacity: 0;
                transform: translateY(6px);
                transition: opacity 0.2s ease, transform 0.2s ease;
                pointer-events: none;
                word-break: break-word;
            }
            .cge-toast[data-level="info"] { background: rgba(15, 66, 116, 0.95); }
            .cge-toast[data-level="success"] { background: rgba(24, 122, 84, 0.95); }
            .cge-toast[data-level="error"] { background: rgba(164, 52, 66, 0.96); }
            .cge-toast.cge-toast-show {
                opacity: 1;
                transform: translateY(0);
            }
        `;
        document.head.appendChild(style);
    }

    function showToast(message, level = 'info', duration = 2600) {
        if (!message) return;
        ensureToastStyles();
        let container = document.getElementById('cge-toast-container');
        if (!container) {
            container = document.createElement('div');
            container.id = 'cge-toast-container';
            document.body.appendChild(container);
        }
        const toast = document.createElement('div');
        toast.className = 'cge-toast';
        toast.dataset.level = level;
        toast.textContent = message;
        container.appendChild(toast);
        requestAnimationFrame(() => toast.classList.add('cge-toast-show'));
        setTimeout(() => {
            toast.classList.remove('cge-toast-show');
            setTimeout(() => {
                if (toast.parentNode) toast.parentNode.removeChild(toast);
                if (container && container.childElementCount === 0 && container.parentNode) {
                    container.parentNode.removeChild(container);
                }
            }, 220);
        }, Math.max(1200, duration));
    }

    function notifyUser(message, options = {}) {
        const { mode = DEFAULT_NOTIFY_MODE, level = 'info', duration = 2600 } = options;
        if (!message || mode === 'silent') return;
        if (mode === 'modal') {
            alert(message);
            return;
        }
        showToast(message, level, duration);
    }

    function escapeHtml(value) {
        return String(value ?? '')
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }

    function normalizeFilenameSettings(input = {}) {
        const template = typeof input.template === 'string' ? input.template.trim() : '';
        const appendDate = input.appendDate === undefined ? true : Boolean(input.appendDate);
        return { template, appendDate };
    }

    function loadFilenameSettings() {
        try {
            const raw = localStorage.getItem(EXPORT_FILENAME_SETTINGS_STORAGE_KEY);
            if (!raw) return normalizeFilenameSettings();
            const parsed = JSON.parse(raw);
            return normalizeFilenameSettings(parsed);
        } catch (_) {
            return normalizeFilenameSettings();
        }
    }

    function saveFilenameSettings() {
        try {
            localStorage.setItem(EXPORT_FILENAME_SETTINGS_STORAGE_KEY, JSON.stringify(filenameSettings));
        } catch (_) {}
    }

    function normalizeDownloadSettings(input = {}) {
        const mode = input.mode === 'file-handle' ? 'file-handle' : 'browser';
        const handleName = typeof input.handleName === 'string' ? input.handleName.trim() : '';
        return { mode, handleName };
    }

    function loadDownloadSettings() {
        try {
            const raw = localStorage.getItem(EXPORT_DOWNLOAD_SETTINGS_STORAGE_KEY);
            if (!raw) return normalizeDownloadSettings();
            const parsed = JSON.parse(raw);
            return normalizeDownloadSettings(parsed);
        } catch (_) {
            return normalizeDownloadSettings();
        }
    }

    function saveDownloadSettings() {
        try {
            localStorage.setItem(EXPORT_DOWNLOAD_SETTINGS_STORAGE_KEY, JSON.stringify(downloadSettings));
        } catch (_) {}
    }

    function supportsFixedFileOverwrite() {
        return typeof window.showSaveFilePicker === 'function' && typeof indexedDB !== 'undefined';
    }

    function idbRequestToPromise(request) {
        return new Promise((resolve, reject) => {
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error || new Error('IndexedDB 请求失败'));
        });
    }

    function idbTransactionToPromise(tx) {
        return new Promise((resolve, reject) => {
            tx.oncomplete = () => resolve();
            tx.onerror = () => reject(tx.error || new Error('IndexedDB 事务失败'));
            tx.onabort = () => reject(tx.error || new Error('IndexedDB 事务中止'));
        });
    }

    function getFileHandleDb() {
        if (!fileHandleDbPromise) {
            fileHandleDbPromise = new Promise((resolve, reject) => {
                const request = indexedDB.open(FILE_HANDLE_DB_NAME, 1);
                request.onupgradeneeded = () => {
                    const db = request.result;
                    if (!db.objectStoreNames.contains(FILE_HANDLE_STORE_NAME)) {
                        db.createObjectStore(FILE_HANDLE_STORE_NAME);
                    }
                };
                request.onsuccess = () => resolve(request.result);
                request.onerror = () => reject(request.error || new Error('打开 IndexedDB 失败'));
            });
        }
        return fileHandleDbPromise;
    }

    async function getStoredZipFileHandle() {
        if (cachedZipFileHandle) return cachedZipFileHandle;
        if (!supportsFixedFileOverwrite()) return null;
        try {
            const db = await getFileHandleDb();
            const tx = db.transaction(FILE_HANDLE_STORE_NAME, 'readonly');
            const store = tx.objectStore(FILE_HANDLE_STORE_NAME);
            const handle = await idbRequestToPromise(store.get(FILE_HANDLE_KEY_ZIP));
            await idbTransactionToPromise(tx);
            cachedZipFileHandle = handle || null;
            return cachedZipFileHandle;
        } catch (_) {
            return null;
        }
    }

    async function setStoredZipFileHandle(handle) {
        if (!supportsFixedFileOverwrite() || !handle) return;
        const db = await getFileHandleDb();
        const tx = db.transaction(FILE_HANDLE_STORE_NAME, 'readwrite');
        tx.objectStore(FILE_HANDLE_STORE_NAME).put(handle, FILE_HANDLE_KEY_ZIP);
        await idbTransactionToPromise(tx);
        cachedZipFileHandle = handle;
    }

    async function clearStoredZipFileHandle() {
        cachedZipFileHandle = null;
        if (!supportsFixedFileOverwrite()) return;
        try {
            const db = await getFileHandleDb();
            const tx = db.transaction(FILE_HANDLE_STORE_NAME, 'readwrite');
            tx.objectStore(FILE_HANDLE_STORE_NAME).delete(FILE_HANDLE_KEY_ZIP);
            await idbTransactionToPromise(tx);
        } catch (_) {}
    }

    async function ensureFileHandlePermission(handle, requestWrite = false) {
        if (!handle) return false;
        const options = { mode: 'readwrite' };
        try {
            let state = await handle.queryPermission(options);
            if (state === 'granted') return true;
            if (!requestWrite) return false;
            state = await handle.requestPermission(options);
            return state === 'granted';
        } catch (_) {
            return false;
        }
    }

    async function pickAndRememberZipFileHandle(suggestedName = 'chatgpt_backup.zip') {
        if (!supportsFixedFileOverwrite()) {
            throw new Error('当前浏览器不支持固定文件覆盖模式。');
        }

        const handle = await window.showSaveFilePicker({
            suggestedName,
            types: [{
                description: 'ZIP 文件',
                accept: { 'application/zip': ['.zip'] }
            }]
        });
        const granted = await ensureFileHandlePermission(handle, true);
        if (!granted) {
            throw new Error('未授予固定文件写入权限。');
        }

        await setStoredZipFileHandle(handle);
        downloadSettings.handleName = handle.name || '';
        saveDownloadSettings();
        return handle;
    }

    function buildDefaultExportZipFilename(mode, selectionType, workspaceId, date, appendDate = true) {
        if (selectionType === 'selected') {
            return mode === 'team'
                ? `chatgpt_team_selected_${workspaceId}${appendDate ? `_${date}` : ''}.zip`
                : mode === 'project'
                    ? `chatgpt_project_selected${appendDate ? `_${date}` : ''}.zip`
                    : `chatgpt_personal_selected${appendDate ? `_${date}` : ''}.zip`;
        }
        return mode === 'team'
            ? `chatgpt_team_backup_${workspaceId}${appendDate ? `_${date}` : ''}.zip`
            : mode === 'project'
                ? `chatgpt_project_backup${appendDate ? `_${date}` : ''}.zip`
                : `chatgpt_personal_backup${appendDate ? `_${date}` : ''}.zip`;
    }

    function buildExportZipFilename(mode, selectionType, workspaceId, overrideSettings = null) {
        const normalizedMode = normalizeExportMode(mode);
        const normalizedSelectionType = selectionType === 'selected' ? 'selected' : 'full';
        const date = new Date().toISOString().slice(0, 10);
        const settings = overrideSettings ? normalizeFilenameSettings(overrideSettings) : filenameSettings;

        if (!settings.template) {
            return buildDefaultExportZipFilename(
                normalizedMode,
                normalizedSelectionType,
                workspaceId,
                date,
                settings.appendDate
            );
        }

        const typeValue = normalizedSelectionType === 'selected' ? 'selected' : 'backup';
        const workspaceValue = typeof workspaceId === 'string' ? workspaceId.trim() : '';
        const hasDatePlaceholder = /\{date\}/i.test(settings.template);
        let baseName = settings.template
            .replace(/\{mode\}/gi, normalizedMode)
            .replace(/\{type\}/gi, typeValue)
            .replace(/\{workspace\}/gi, workspaceValue)
            .replace(/\{date\}/gi, date)
            .trim();

        if (settings.appendDate && !hasDatePlaceholder) {
            baseName = `${baseName}_${date}`;
        }

        baseName = sanitizeFilename(baseName)
            .replace(/\.zip$/i, '')
            .replace(/_+/g, '_')
            .replace(/^[-_. ]+|[-_. ]+$/g, '');

        if (!baseName) {
            baseName = `chatgpt_export_${date}`;
        }

        return `${baseName}.zip`;
    }

    function normalizeScheduleEntries(entries) {
        if (!Array.isArray(entries)) return [];
        const map = new Map();
        entries.forEach(item => {
            const id = typeof item?.id === 'string' ? item.id.trim() : '';
            if (!id || map.has(id)) return;
            map.set(id, {
                id,
                title: typeof item?.title === 'string' && item.title.trim() ? item.title.trim() : 'Untitled Conversation',
                projectId: typeof item?.projectId === 'string' && item.projectId.trim() ? item.projectId.trim() : null,
                projectTitle: typeof item?.projectTitle === 'string' && item.projectTitle.trim() ? item.projectTitle.trim() : null,
                is_archived: Boolean(item?.is_archived),
                create_time: normalizeEpochSeconds(item?.create_time || 0),
                update_time: normalizeEpochSeconds(item?.update_time || item?.create_time || 0)
            });
        });
        return Array.from(map.values());
    }

    function normalizeExportMode(mode) {
        if (TEAM_ONLY_MODE) return 'team';
        return ['personal', 'project', 'team'].includes(mode) ? mode : 'personal';
    }

    function getModeLabel(mode) {
        return mode === 'team' ? '团队空间' : mode === 'project' ? '项目空间' : '个人空间';
    }

    function normalizeAutoExportState(input = {}) {
        const mode = normalizeExportMode(input.mode);
        const intervalRaw = Number(input.intervalMinutes);
        const intervalMinutes = Number.isFinite(intervalRaw)
            ? Math.max(AUTO_EXPORT_MIN_MINUTES, Math.floor(intervalRaw))
            : 60;
        const workspaceId = typeof input.workspaceId === 'string' ? input.workspaceId.trim() : '';
        const nextRunAt = Number(input.nextRunAt);
        const lastRunAt = Number(input.lastRunAt);
        const lastError = typeof input.lastError === 'string' ? input.lastError.trim() : '';
        const selectionType = input.selectionType === 'selected' ? 'selected' : 'all';
        const selectedModeRaw = ['personal', 'project', 'team'].includes(input.selectedMode) ? input.selectedMode : '';
        let selectedEntries = normalizeScheduleEntries(input.selectedEntries);
        const selectedMode = TEAM_ONLY_MODE ? 'team' : selectedModeRaw;
        const selectedWorkspaceId = typeof input.selectedWorkspaceId === 'string' ? input.selectedWorkspaceId.trim() : '';
        if (TEAM_ONLY_MODE && selectedModeRaw && selectedModeRaw !== 'team') {
            selectedEntries = [];
        }
        return {
            enabled: Boolean(input.enabled),
            mode,
            intervalMinutes,
            workspaceId,
            nextRunAt: Number.isFinite(nextRunAt) && nextRunAt > 0 ? nextRunAt : null,
            lastRunAt: Number.isFinite(lastRunAt) && lastRunAt > 0 ? lastRunAt : null,
            lastError,
            selectionType,
            selectedEntries,
            selectedMode,
            selectedWorkspaceId
        };
    }

    function loadAutoExportState() {
        try {
            const raw = localStorage.getItem(AUTO_EXPORT_STORAGE_KEY);
            if (!raw) {
                return normalizeAutoExportState();
            }
            const parsed = JSON.parse(raw);
            return normalizeAutoExportState(parsed);
        } catch (_) {
            return normalizeAutoExportState();
        }
    }

    function saveAutoExportState() {
        try {
            localStorage.setItem(AUTO_EXPORT_STORAGE_KEY, JSON.stringify(autoExportState));
        } catch (_) {}
    }

    function getIntervalExportState() {
        return {
            ...autoExportState,
            timerArmed: Boolean(autoExportTimer)
        };
    }

    function getAutoExportStatusText() {
        const errorHint = autoExportState.lastError
            ? ` 最近错误:${autoExportState.lastError}`
            : '';
        if (!autoExportState.enabled) {
            return `未启动定时导出。${errorHint}`;
        }
        const modeLabel = getModeLabel(autoExportState.mode);
        const selectionHint = autoExportState.selectionType === 'selected'
            ? `仅已选 ${autoExportState.selectedEntries.length} 条`
            : '全部对话';
        const workspaceHint = autoExportState.mode === 'team'
            ? `,Workspace: ${autoExportState.workspaceId || '未配置'}`
            : '';
        const nextRun = autoExportState.nextRunAt
            ? new Date(autoExportState.nextRunAt).toLocaleString()
            : '未知';
        const lastRun = autoExportState.lastRunAt
            ? new Date(autoExportState.lastRunAt).toLocaleString()
            : '暂无';
        return `运行中:每 ${autoExportState.intervalMinutes} 分钟导出一次(${modeLabel}${workspaceHint},${selectionHint})。下次:${nextRun},上次:${lastRun}。${errorHint}`;
    }

    function clearAutoExportTimer() {
        if (autoExportTimer) {
            clearTimeout(autoExportTimer);
            autoExportTimer = null;
        }
    }

    function scheduleNextAutoExport(delayMs = null) {
        clearAutoExportTimer();
        if (!autoExportState.enabled) return;
        const intervalMs = autoExportState.intervalMinutes * 60 * 1000;
        const waitMs = delayMs == null ? intervalMs : Math.max(1000, Math.floor(delayMs));
        autoExportState.nextRunAt = Date.now() + waitMs;
        saveAutoExportState();
        autoExportTimer = setTimeout(runAutoExportTick, waitMs);
    }

    async function runAutoExportTick(source = 'interval') {
        if (!autoExportState.enabled) return;

        if (autoExportTaskRunning || exportInProgress) {
            scheduleNextAutoExport(autoExportState.intervalMinutes * 60 * 1000);
            return;
        }

        autoExportTaskRunning = true;
        try {
            let workspaceId = null;
            if (autoExportState.mode === 'team') {
                workspaceId = autoExportState.workspaceId || resolveWorkspaceId(null) || '';
                if (!workspaceId) {
                    throw new Error('团队空间定时导出失败:缺少 Workspace ID。');
                }
                if (!autoExportState.workspaceId) {
                    autoExportState.workspaceId = workspaceId;
                }
            }

            let exported = false;
            if (autoExportState.selectionType === 'selected') {
                const selectedEntries = normalizeScheduleEntries(autoExportState.selectedEntries);
                if (selectedEntries.length === 0) {
                    throw new Error('定时导出已设置为“仅已选对话”,但未配置对话列表。');
                }
                exported = await exportConversations({
                    mode: autoExportState.mode,
                    workspaceId,
                    conversationEntries: selectedEntries,
                    exportType: 'selected',
                    notifyMode: SCHEDULE_NOTIFY_MODE
                });
            } else {
                exported = await startScheduledExport({
                    mode: autoExportState.mode,
                    workspaceId,
                    autoConfirm: true,
                    source,
                    notifyMode: SCHEDULE_NOTIFY_MODE
                });
            }
            if (exported) {
                autoExportState.lastRunAt = Date.now();
                autoExportState.lastError = '';
            } else {
                autoExportState.lastError = '任务未执行(可能被取消或并发导出跳过)。';
            }
        } catch (err) {
            autoExportState.lastError = err?.message || String(err);
            console.error('[ChatGPT Exporter] 定时导出执行失败:', err);
        } finally {
            autoExportTaskRunning = false;
            if (autoExportState.enabled) {
                scheduleNextAutoExport(autoExportState.intervalMinutes * 60 * 1000);
            }
            saveAutoExportState();
        }
    }

    function startIntervalExport(options = {}) {
        const runNow = Boolean(options.runNow);
        const merged = normalizeAutoExportState({
            ...autoExportState,
            ...options,
            enabled: true
        });

        if (merged.mode === 'team' && !merged.workspaceId) {
            const detectedIds = detectAllWorkspaceIds();
            if (detectedIds.length === 1) {
                merged.workspaceId = detectedIds[0];
            }
        }

        if (merged.mode === 'team' && !merged.workspaceId) {
            throw new Error('团队空间定时导出需要 Team Workspace ID。');
        }

        if (merged.selectionType === 'selected') {
            if (!Array.isArray(merged.selectedEntries) || merged.selectedEntries.length === 0) {
                throw new Error('定时导出选择了“仅已选对话”,但尚未选择任何对话。');
            }
            const expectedWorkspaceId = merged.mode === 'team' ? merged.workspaceId : '';
            const selectedWorkspaceId = merged.mode === 'team' ? (merged.selectedWorkspaceId || '') : '';
            if (merged.selectedMode !== merged.mode || selectedWorkspaceId !== expectedWorkspaceId) {
                throw new Error('已选定时对话与当前空间设置不匹配,请重新选择定时对话。');
            }
        }

        autoExportState = merged;
        autoExportState.lastError = '';
        scheduleNextAutoExport();
        if (runNow) {
            setTimeout(() => {
                runAutoExportTick('interval-start');
            }, 50);
        }
        console.log('[ChatGPT Exporter] 已启动定时导出:', getIntervalExportState());
        return getIntervalExportState();
    }

    function stopIntervalExport() {
        clearAutoExportTimer();
        autoExportState.enabled = false;
        autoExportState.nextRunAt = null;
        saveAutoExportState();
        console.log('[ChatGPT Exporter] 已停止定时导出。');
        return getIntervalExportState();
    }

    function resumeIntervalExportIfNeeded() {
        if (!autoExportState.enabled) return;
        const intervalMs = autoExportState.intervalMinutes * 60 * 1000;
        const remaining = autoExportState.nextRunAt
            ? autoExportState.nextRunAt - Date.now()
            : intervalMs;
        scheduleNextAutoExport(Math.max(1000, remaining));
    }

    function ensureGreatBallThemeStyles() {
        if (document.getElementById('chatgpt-exporter-greatball-theme')) return;
        const style = document.createElement('style');
        style.id = 'chatgpt-exporter-greatball-theme';
        style.textContent = `
            #export-dialog-overlay.cge-theme-greatball-overlay {
                backdrop-filter: blur(3px);
                background:
                    radial-gradient(circle at 12% 12%, rgba(112, 173, 255, 0.18), transparent 44%),
                    radial-gradient(circle at 88% 88%, rgba(105, 163, 255, 0.12), transparent 42%),
                    linear-gradient(180deg, rgba(5, 12, 24, 0.86), rgba(5, 12, 24, 0.74)) !important;
            }

            #export-dialog.cge-theme-greatball-dialog {
                position: relative;
                overflow: hidden;
                isolation: isolate;
                border: 4px solid #214b7e !important;
                border-radius: 16px !important;
                background: linear-gradient(180deg, #3f6ba3 0%, #325b90 48%, #2a4f80 100%) !important;
                box-shadow:
                    0 20px 42px rgba(0, 0, 0, 0.45),
                    inset 0 0 0 2px rgba(160, 204, 255, 0.72),
                    inset 0 1px 0 rgba(230, 243, 255, 0.2) !important;
                color: #edf6ff !important;
                font-family: "Trebuchet MS", "Verdana", "Noto Sans SC", sans-serif !important;
            }

            #export-dialog.cge-theme-greatball-dialog::before {
                content: "";
                position: absolute;
                inset: 10px;
                border: 1px solid rgba(169, 210, 255, 0.3);
                border-radius: 12px;
                pointer-events: none;
                z-index: 0;
            }

            #export-dialog.cge-theme-greatball-dialog > * {
                position: relative;
                z-index: 1;
            }

            #export-dialog.cge-theme-greatball-dialog h2 {
                margin: 0 0 16px 0 !important;
                padding: 10px 14px !important;
                border: 2px solid #7eb1ea !important;
                border-radius: 11px !important;
                background: linear-gradient(180deg, #496fa3 0%, #3a6093 100%) !important;
                color: #f7fbff !important;
                font-size: 18px !important;
                font-weight: 900 !important;
                text-shadow: 0 1px 0 rgba(10, 24, 42, 0.42);
                box-shadow: inset 0 1px 0 rgba(215, 235, 255, 0.28);
            }

            #export-dialog.cge-theme-greatball-dialog strong,
            #export-dialog.cge-theme-greatball-dialog code {
                color: #f5fbff !important;
            }

            #export-dialog.cge-theme-greatball-dialog [style*="color: #666"] {
                color: #cfe3ff !important;
            }

            #export-dialog.cge-theme-greatball-dialog code {
                background: rgba(17, 37, 64, 0.58) !important;
                border: 1px solid rgba(141, 181, 228, 0.42);
                border-radius: 6px;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-space-grid {
                display: grid;
                gap: 16px;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-space-card {
                border: 2px solid #5486be !important;
                border-radius: 14px !important;
                background: linear-gradient(180deg, rgba(21, 46, 80, 0.92), rgba(18, 40, 69, 0.96)) !important;
                box-shadow:
                    inset 0 0 0 2px rgba(12, 31, 54, 0.86),
                    inset 0 1px 0 rgba(179, 211, 250, 0.16) !important;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-space-card p {
                color: #d2e5ff !important;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-space-actions {
                display: flex;
                flex-wrap: wrap;
                gap: 8px;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-team-footer {
                display: flex;
                justify-content: space-between;
                align-items: center;
                gap: 10px;
                flex-wrap: wrap;
            }

            #export-dialog.cge-theme-greatball-dialog button {
                border: 2px solid #5180b3 !important;
                border-radius: 11px !important;
                background: linear-gradient(180deg, #f4fbff 0%, #d9e9fb 100%) !important;
                color: #16385d !important;
                font-weight: 900 !important;
                box-shadow:
                    0 2px 0 #2f517a,
                    inset 0 1px 0 rgba(255, 255, 255, 0.75) !important;
                transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
            }

            #export-dialog.cge-theme-greatball-dialog button:hover:not(:disabled) {
                transform: translateY(-1px);
                filter: saturate(1.06);
            }

            #export-dialog.cge-theme-greatball-dialog button:active:not(:disabled) {
                transform: translateY(1px);
            }

            #export-dialog.cge-theme-greatball-dialog button:disabled {
                opacity: 0.62;
                cursor: not-allowed;
            }

            #export-dialog.cge-theme-greatball-dialog #select-team-btn,
            #export-dialog.cge-theme-greatball-dialog #start-team-export-btn,
            #export-dialog.cge-theme-greatball-dialog #schedule-start-btn,
            #export-dialog.cge-theme-greatball-dialog #schedule-pick-btn,
            #export-dialog.cge-theme-greatball-dialog #schedule-run-now-btn,
            #export-dialog.cge-theme-greatball-dialog #export-selected-btn {
                border-color: #1a4f80 !important;
                background: linear-gradient(180deg, #6cdaff 0%, #308fd5 100%) !important;
                color: #f7fcff !important;
            }

            #export-dialog.cge-theme-greatball-dialog input,
            #export-dialog.cge-theme-greatball-dialog select {
                border: 2px solid #6e9acd !important;
                border-radius: 10px !important;
                background: linear-gradient(180deg, #f7fcff, #e2efff) !important;
                color: #123252 !important;
                font-weight: 700;
            }

            #export-dialog.cge-theme-greatball-dialog input:focus,
            #export-dialog.cge-theme-greatball-dialog select:focus {
                outline: none;
                border-color: #8db8eb !important;
                box-shadow: 0 0 0 3px rgba(120, 170, 230, 0.25) !important;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-picker-row {
                display: flex;
                gap: 8px;
                margin-bottom: 8px;
                align-items: center;
                flex-wrap: wrap;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-picker-row-search input {
                flex: 1 1 260px;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-picker-row-search select {
                flex: 0 1 auto;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-picker-row-filter select,
            #export-dialog.cge-theme-greatball-dialog .cge-picker-row-filter input,
            #export-dialog.cge-theme-greatball-dialog .cge-picker-row-filter button {
                flex: 1 1 140px;
                min-width: 120px;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-picker-footer {
                display: flex;
                justify-content: space-between;
                align-items: center;
                gap: 10px;
                flex-wrap: wrap;
                margin-top: 16px;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-picker-footer-left,
            #export-dialog.cge-theme-greatball-dialog .cge-picker-footer-right {
                display: flex;
                flex-wrap: wrap;
                gap: 8px;
            }

            #export-dialog.cge-theme-greatball-dialog #conv-list {
                border: 2px solid #5d8abd !important;
                border-radius: 10px !important;
                background: linear-gradient(180deg, rgba(225, 238, 255, 0.95), rgba(204, 224, 249, 0.92)) !important;
            }

            #export-dialog.cge-theme-greatball-dialog #conv-list label {
                border: 1px solid rgba(56, 98, 146, 0.38) !important;
                border-radius: 10px !important;
                background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(231, 242, 255, 0.92)) !important;
            }

            #export-dialog.cge-theme-greatball-dialog .cge-tag-project {
                background: #d8ecff !important;
                color: #0f4c84 !important;
                border: 1px solid rgba(15, 76, 132, 0.3);
            }

            #export-dialog.cge-theme-greatball-dialog .cge-tag-archived {
                background: #ffe6cf !important;
                color: #8f520f !important;
                border: 1px solid rgba(143, 82, 15, 0.3);
            }

            #export-dialog.cge-theme-greatball-dialog[data-cge-dialog="picker"] #conv-list {
                max-height: min(48vh, 420px) !important;
            }

            .cge-ball-btn {
                position: relative !important;
                border: none !important;
                border-radius: 50% !important;
                overflow: hidden !important;
                cursor: pointer !important;
                user-select: none !important;
                transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
            }

            .cge-ball-btn::before {
                content: "";
                position: absolute;
                left: 0;
                right: 0;
                top: 50%;
                height: 4px;
                margin-top: -2px;
                background: #0f1f35;
                z-index: 2;
            }

            .cge-ball-btn::after {
                content: "";
                position: absolute;
                width: 16px;
                height: 16px;
                left: 50%;
                top: 50%;
                margin: -8px 0 0 -8px;
                border-radius: 50%;
                background: radial-gradient(circle at 32% 28%, #fff, #d8e1ee 62%, #9aa8bb 100%);
                border: 2px solid #1b2940;
                z-index: 3;
            }

            .cge-ball-btn .cge-ball-label {
                position: absolute;
                left: 50%;
                top: 73%;
                transform: translate(-50%, -50%);
                z-index: 4;
                color: #0d2a46;
                font-weight: 900;
                font-size: 13px;
                line-height: 1;
                text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
                pointer-events: none;
                white-space: nowrap;
            }

            #gpt-rescue-btn.cge-theme-greatball-fab,
            #gpt-rescue-btn.cge-ball-great {
                position: fixed !important;
                right: 18px !important;
                bottom: 122px !important;
                width: 56px !important;
                height: 56px !important;
                padding: 0 !important;
                border: 2px solid #11406e !important;
                background:
                    radial-gradient(circle at 24% 24%, #ff6a70 0 8px, transparent 9px),
                    radial-gradient(circle at 76% 24%, #ff6a70 0 8px, transparent 9px),
                    radial-gradient(circle at 30% 14%, rgba(255, 255, 255, 0.34) 0 10px, transparent 11px),
                    linear-gradient(180deg, #4cb9ff 0 50%, #f6faff 50% 100%) !important;
                box-shadow:
                    0 8px 16px rgba(0, 0, 0, 0.32),
                    inset 0 1px 0 rgba(255, 255, 255, 0.7) !important;
                z-index: 99997 !important;
            }

            #gpt-rescue-btn.cge-theme-greatball-fab[data-export-state="busy"] {
                filter: saturate(1.12) brightness(1.02);
            }

            #gpt-rescue-btn.cge-theme-greatball-fab[data-export-state="done"] {
                background: linear-gradient(180deg, #5dd9ad, #2f9d76) !important;
            }

            #gpt-rescue-btn.cge-theme-greatball-fab[data-export-state="error"] {
                background: linear-gradient(180deg, #ff8f98, #dc4a57) !important;
            }

            @media (max-width: 900px) {
                #export-dialog.cge-theme-greatball-dialog {
                    padding: 16px !important;
                }

                #gpt-rescue-btn.cge-theme-greatball-fab,
                #gpt-rescue-btn.cge-ball-great {
                    right: 10px !important;
                    bottom: 108px !important;
                    width: 52px !important;
                    height: 52px !important;
                }

                .cge-ball-btn .cge-ball-label {
                    font-size: 12px;
                }
            }
        `;
        document.head.appendChild(style);
    }

    /**
     * [新增] 从Cookie中获取 oai-device-id
     * @returns {string|null} - 返回设备ID或null
     */
    function getOaiDeviceId() {
        const cookieString = document.cookie;
        const match = cookieString.match(/oai-did=([^;]+)/);
        return match ? match[1] : null;
    }

    function generateUniqueFilename(convData) {
        const convId = convData.conversation_id || '';
        const shortId = convId.includes('-') ? convId.split('-').pop() : (convId || Date.now().toString(36));
        let baseName = convData.title;
        if (!baseName || baseName.trim().toLowerCase() === 'new chat') {
            baseName = 'Untitled Conversation';
        }
        return `${sanitizeFilename(baseName)}_${shortId}.json`;
    }

    function generateMarkdownFilename(convData) {
        const jsonName = generateUniqueFilename(convData);
        return jsonName.endsWith('.json')
            ? `${jsonName.slice(0, -5)}.md`
            : `${jsonName}.md`;
    }

    function cleanMessageContent(text) {
        if (!text) return '';
        return text
            .replace(/\uE200cite(?:\uE202turn\d+(?:search|view)\d+)+\uE201/gi, '')
            .replace(/cite(?:turn\d+(?:search|view)\d+)+/gi, '')
            .trim();
    }

    function processContentReferences(text, contentReferences) {
        if (!text || !Array.isArray(contentReferences) || contentReferences.length === 0) {
            return { text, footnotes: [] };
        }

        const references = contentReferences.filter(ref => ref && typeof ref.matched_text === 'string' && ref.matched_text.length > 0);
        if (references.length === 0) {
            return { text, footnotes: [] };
        }

        const getReferenceInfo = (ref) => {
            const item = Array.isArray(ref.items) ? ref.items[0] : null;
            const url = item?.url || (Array.isArray(ref.safe_urls) ? ref.safe_urls[0] : '') || '';
            const title = item?.title || '';
            let label = item?.attribution || '';
            if (!label && typeof ref.alt === 'string') {
                const match = ref.alt.match(/\[([^\]]+)\]\([^)]+\)/);
                if (match) label = match[1];
            }
            if (!label) label = title || url;
            return { url, title, label };
        };

        const footnotes = [];
        const footnoteIndexByKey = new Map();
        const citationRefs = references
            .filter(ref => ref.type === 'grouped_webpages')
            .sort((a, b) => {
                const aIdx = Number.isFinite(a.start_idx) ? a.start_idx : Number.MAX_SAFE_INTEGER;
                const bIdx = Number.isFinite(b.start_idx) ? b.start_idx : Number.MAX_SAFE_INTEGER;
                return aIdx - bIdx;
            });

        citationRefs.forEach(ref => {
            const info = getReferenceInfo(ref);
            if (!info.url) return;
            const key = `${info.url}|${info.title}`;
            if (footnoteIndexByKey.has(key)) return;
            const index = footnotes.length + 1;
            footnoteIndexByKey.set(key, index);
            footnotes.push({ index, url: info.url, title: info.title, label: info.label });
        });

        const sortedByReplacement = references
            .slice()
            .sort((a, b) => {
                const aIdx = Number.isFinite(a.start_idx) ? a.start_idx : -1;
                const bIdx = Number.isFinite(b.start_idx) ? b.start_idx : -1;
                if (aIdx !== -1 || bIdx !== -1) {
                    return bIdx - aIdx;
                }
                return (b.matched_text?.length || 0) - (a.matched_text?.length || 0);
            });

        let output = text;
        sortedByReplacement.forEach(ref => {
            if (!ref?.matched_text || ref.type === 'sources_footnote') return;
            let replacement = '';
            if (ref.type === 'grouped_webpages') {
                const info = getReferenceInfo(ref);
                if (info.url) {
                    const key = `${info.url}|${info.title}`;
                    const index = footnoteIndexByKey.get(key);
                    replacement = index ? `([${info.label}][${index}])` : (ref.alt || '');
                } else {
                    replacement = ref.alt || '';
                }
            } else {
                replacement = ref.alt || '';
            }

            if (Number.isFinite(ref.start_idx) && Number.isFinite(ref.end_idx)) {
                if (output.slice(ref.start_idx, ref.end_idx) === ref.matched_text) {
                    output = output.slice(0, ref.start_idx) + replacement + output.slice(ref.end_idx);
                    return;
                }
            }
            output = output.split(ref.matched_text).join(replacement);
        });

        return { text: output, footnotes };
    }

    function extractConversationMessages(convData) {
        const mapping = convData?.mapping;
        if (!mapping) return [];

        const messages = [];
        const mappingKeys = Object.keys(mapping);
        const rootId = mapping['client-created-root']
            ? 'client-created-root'
            : mappingKeys.find(id => !mapping[id]?.parent) || mappingKeys[0];
        const visited = new Set();

        const traverse = (nodeId) => {
            if (!nodeId || visited.has(nodeId)) return;
            visited.add(nodeId);
            const node = mapping[nodeId];
            if (!node) return;

            const msg = node.message;
            if (msg) {
                const author = msg.author?.role;
                const isHidden = msg.metadata?.is_visually_hidden_from_conversation ||
                    msg.metadata?.is_contextual_answers_system_message;
                if (author && author !== 'system' && !isHidden) {
                    const content = msg.content;
                    if (content?.content_type === 'text' && Array.isArray(content.parts)) {
                        const rawText = content.parts
                            .map(part => typeof part === 'string' ? part : (part?.text ?? ''))
                            .filter(Boolean)
                            .join('\n');
                        const contentReferences = msg.metadata?.content_references || [];
                        let processedText = rawText;
                        let footnotes = [];
                        if (Array.isArray(contentReferences) && contentReferences.length > 0) {
                            const processed = processContentReferences(rawText, contentReferences);
                            processedText = processed.text;
                            footnotes = processed.footnotes;
                        }
                        const cleaned = cleanMessageContent(processedText);
                        if (cleaned) {
                            messages.push({
                                role: author,
                                content: cleaned,
                                create_time: msg.create_time || null,
                                footnotes
                            });
                        }
                    }
                }
            }

            if (Array.isArray(node.children)) {
                node.children.forEach(childId => traverse(childId));
            }
        };

        if (rootId) {
            traverse(rootId);
        } else {
            mappingKeys.forEach(traverse);
        }

        return messages;
    }

    function convertConversationToMarkdown(convData) {
        const messages = extractConversationMessages(convData);
        if (messages.length === 0) {
            return '# Conversation\nNo visible user or assistant messages were exported.\n';
        }

        const mdLines = [];
        messages.forEach(msg => {
            const roleLabel = msg.role === 'user' ? '# User' : '# Assistant';
            mdLines.push(roleLabel);
            mdLines.push(msg.content);
            if (Array.isArray(msg.footnotes) && msg.footnotes.length > 0) {
                mdLines.push('');
                msg.footnotes
                    .slice()
                    .sort((a, b) => a.index - b.index)
                    .forEach(note => {
                        if (!note.url) return;
                        const title = note.title ? ` "${note.title}"` : '';
                        mdLines.push(`[${note.index}]: ${note.url}${title}`);
                    });
            }
            mdLines.push('');
        });

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

    function downloadFile(blob, filename) {
        const a = document.createElement('a');
        const blobUrl = URL.createObjectURL(blob);
        a.href = blobUrl;
        a.download = filename;
        a.rel = 'noopener';
        a.style.display = 'none';
        document.body.appendChild(a);
        a.click();
        setTimeout(() => {
            if (a.parentNode) {
                a.parentNode.removeChild(a);
            }
            URL.revokeObjectURL(blobUrl);
        }, 60000);
    }

    async function saveExportArchive(blob, filename, options = {}) {
        const { notifyMode = DEFAULT_NOTIFY_MODE } = options;
        const fallbackToBrowser = (message, switchMode = false) => {
            if (switchMode) {
                downloadSettings.mode = 'browser';
                saveDownloadSettings();
            }
            if (message) {
                notifyUser(message, { mode: notifyMode, level: 'info', duration: 3200 });
            }
            downloadFile(blob, filename);
        };

        if (downloadSettings.mode !== 'file-handle') {
            downloadFile(blob, filename);
            return;
        }

        if (!supportsFixedFileOverwrite()) {
            fallbackToBrowser('固定覆盖模式不可用,已回退为普通下载。', true);
            return;
        }

        const handle = await getStoredZipFileHandle();
        if (!handle) {
            fallbackToBrowser('未找到固定保存文件,已回退为普通下载。请在“文件命名”里重新选择。', true);
            return;
        }

        const granted = await ensureFileHandlePermission(handle, true);
        if (!granted) {
            fallbackToBrowser('固定文件写入权限不可用,本次已回退为普通下载。');
            return;
        }

        try {
            const writable = await handle.createWritable();
            await writable.write(blob);
            await writable.close();
            if (handle.name && downloadSettings.handleName !== handle.name) {
                downloadSettings.handleName = handle.name;
                saveDownloadSettings();
            }
        } catch (err) {
            const errName = err?.name || '';
            if (errName === 'NotFoundError' || errName === 'InvalidStateError') {
                await clearStoredZipFileHandle();
                downloadSettings.handleName = '';
                saveDownloadSettings();
                fallbackToBrowser('固定保存文件已失效,已回退为普通下载。请重新选择固定文件。', true);
                return;
            }
            fallbackToBrowser(
                `固定覆盖写入失败(${err?.message || errName || '未知错误'}),已回退为普通下载。`,
                false
            );
            return;
        }
    }

    // --- 导出流程核心逻辑 ---
    function getExportButton() {
        let btn = document.getElementById('gpt-rescue-btn');
        if (!btn) {
            addBtn();
            btn = document.getElementById('gpt-rescue-btn');
        }
        return btn;
    }

    function setExportButtonState(btn, options = {}) {
        if (!btn) return;
        const {
            label = '导出',
            title = '打开导出面板',
            state = 'idle'
        } = options;
        const labelEl = btn.querySelector('.cge-ball-label');
        if (labelEl) {
            labelEl.textContent = label;
        } else {
            btn.textContent = label;
        }
        btn.title = title;
        btn.setAttribute('aria-label', title);
        btn.dataset.exportState = state;
    }

    async function exportConversations(options = {}) {
        const {
            mode = 'personal',
            workspaceId = null,
            conversationEntries = null,
            exportType = null,
            notifyMode = DEFAULT_NOTIFY_MODE
        } = options;
        if (exportInProgress) {
            console.warn('[ChatGPT Exporter] 已有导出任务进行中,本次请求已跳过。');
            notifyUser('已有导出任务进行中,本次请求已跳过。', { mode: notifyMode, level: 'info' });
            return false;
        }
        exportInProgress = true;
        let exportSucceeded = false;

        const btn = getExportButton();
        btn.disabled = true;
        setExportButtonState(btn, { label: '准备', title: '正在准备导出...', state: 'busy' });

        try {
            if (!await ensureAccessToken({ notifyMode })) {
                btn.disabled = false;
                setExportButtonState(btn, { label: '导出', title: '打开导出面板', state: 'idle' });
                return false;
            }

            const zip = new JSZip();
            if (Array.isArray(conversationEntries) && conversationEntries.length > 0) {
                for (let i = 0; i < conversationEntries.length; i++) {
                    const entry = conversationEntries[i];
                    const label = entry?.title ? entry.title.slice(0, 12) : '对话';
                    setExportButtonState(btn, {
                        label: '抓取',
                        title: `正在导出: ${label} (${i + 1}/${conversationEntries.length})`,
                        state: 'busy'
                    });
                    const convData = await getConversation(entry.id, workspaceId);
                    const target = entry?.projectTitle
                        ? zip.folder(sanitizeFilename(entry.projectTitle))
                        : zip;
                    target.file(generateUniqueFilename(convData), JSON.stringify(convData, null, 2));
                    target.file(generateMarkdownFilename(convData), convertConversationToMarkdown(convData));
                    await sleep(jitter());
                }
            } else {
                setExportButtonState(btn, {
                    label: '扫描',
                    title: '正在获取项目外对话...',
                    state: 'busy'
                });
                const orphanIds = await collectIds(btn, workspaceId, null);
                for (let i = 0; i < orphanIds.length; i++) {
                    setExportButtonState(btn, {
                        label: '抓取',
                        title: `根目录对话 (${i + 1}/${orphanIds.length})`,
                        state: 'busy'
                    });
                    const convData = await getConversation(orphanIds[i], workspaceId);
                    zip.file(generateUniqueFilename(convData), JSON.stringify(convData, null, 2));
                    zip.file(generateMarkdownFilename(convData), convertConversationToMarkdown(convData));
                    await sleep(jitter());
                }

                setExportButtonState(btn, {
                    label: '项目',
                    title: '正在获取项目列表...',
                    state: 'busy'
                });
                const projects = await getProjects(workspaceId);
                for (const project of projects) {
                    const projectFolder = zip.folder(sanitizeFilename(project.title));
                    setExportButtonState(btn, {
                        label: '项目',
                        title: `项目: ${project.title}`,
                        state: 'busy'
                    });
                    const projectConvIds = await collectIds(btn, workspaceId, project.id);
                    if (projectConvIds.length === 0) continue;

                    for (let i = 0; i < projectConvIds.length; i++) {
                        setExportButtonState(btn, {
                            label: '抓取',
                            title: `${project.title.substring(0, 12)} (${i + 1}/${projectConvIds.length})`,
                            state: 'busy'
                        });
                        const convData = await getConversation(projectConvIds[i], workspaceId);
                        projectFolder.file(generateUniqueFilename(convData), JSON.stringify(convData, null, 2));
                        projectFolder.file(generateMarkdownFilename(convData), convertConversationToMarkdown(convData));
                        await sleep(jitter());
                    }
                }
            }

            setExportButtonState(btn, {
                label: '打包',
                title: '正在生成 ZIP 文件...',
                state: 'busy'
            });
            const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
            const selectionType = exportType || ((Array.isArray(conversationEntries) && conversationEntries.length > 0) ? 'selected' : 'full');
            const filename = buildExportZipFilename(mode, selectionType, workspaceId);
            await saveExportArchive(blob, filename, { notifyMode });
            notifyUser('导出完成。', { mode: notifyMode, level: 'success' });
            setExportButtonState(btn, { label: '完成', title: '导出完成', state: 'done' });
            exportSucceeded = true;

        } catch (e) {
            console.error('导出过程中发生严重错误:', e);
            notifyUser(`导出失败: ${e.message}。详情请查看控制台(F12 -> Console)。`, {
                mode: notifyMode,
                level: 'error',
                duration: 3800
            });
            setExportButtonState(btn, { label: '错误', title: `导出失败: ${e.message}`, state: 'error' });
        } finally {
            exportInProgress = false;
            setTimeout(() => {
                btn.disabled = false;
                setExportButtonState(btn, { label: '导出', title: '打开导出面板', state: 'idle' });
            }, 2500);
        }
        return exportSucceeded;
    }

    async function startExportProcess(mode, workspaceId, options = {}) {
        const normalizedMode = normalizeExportMode(mode);
        const resolvedWorkspaceId = normalizedMode === 'team'
            ? (workspaceId || resolveWorkspaceId(null) || '')
            : null;
        if (normalizedMode === 'team' && !resolvedWorkspaceId) {
            throw new Error('团队空间导出需要 Team Workspace ID。');
        }
        return await exportConversations({
            mode: normalizedMode,
            workspaceId: resolvedWorkspaceId,
            ...options
        });
    }

    async function startProjectSpaceExportProcess(workspaceId = null, options = {}) {
        const { notifyMode = DEFAULT_NOTIFY_MODE } = options;
        try {
            const projectEntries = await listProjectSpaceConversations(workspaceId);
            if (projectEntries.length === 0) {
                notifyUser('未找到项目空间对话。', { mode: notifyMode, level: 'info' });
                return false;
            }
            return await exportConversations({
                mode: 'project',
                workspaceId,
                conversationEntries: projectEntries,
                exportType: 'full',
                notifyMode
            });
        } catch (err) {
            console.error('导出项目空间失败:', err);
            notifyUser(`导出项目空间失败: ${err.message}`, {
                mode: notifyMode,
                level: 'error',
                duration: 3800
            });
            return false;
        }
    }

    async function startSelectiveExportProcess(mode, workspaceId, conversationEntries, options = {}) {
        return await exportConversations({ mode, workspaceId, conversationEntries, ...options });
    }

    function startScheduledExport(options = {}) {
        const {
            mode = 'personal',
            workspaceId = null,
            autoConfirm = false,
            source = 'schedule',
            notifyMode = autoConfirm ? SCHEDULE_NOTIFY_MODE : DEFAULT_NOTIFY_MODE
        } = options;
        const normalizedMode = normalizeExportMode(mode);
        const proceed = async () => {
            if (normalizedMode === 'project') {
                return await startProjectSpaceExportProcess(workspaceId, { notifyMode });
            }
            return await startExportProcess(normalizedMode, workspaceId, { notifyMode });
        };

        if (autoConfirm) {
            return proceed();
        }

        const modeLabel = normalizedMode === 'team' ? '团队空间' : normalizedMode === 'project' ? '项目空间' : '个人空间';
        if (confirm(`Chrome 扩展请求导出 ${modeLabel} 对话(来源: ${source})。是否开始?`)) {
            return proceed();
        }
        return Promise.resolve(false);
    }

    // --- API 调用函数 ---
    async function getProjects(workspaceId) {
        if (!workspaceId) return [];
        const deviceId = getOaiDeviceId();
        if (!deviceId) {
            throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。');
        }
        const headers = {
            'Authorization': `Bearer ${accessToken}`,
            'ChatGPT-Account-Id': workspaceId,
            'oai-device-id': deviceId
        };
        const r = await fetch(`/backend-api/gizmos/snorlax/sidebar`, { headers });
        if (!r.ok) {
            console.warn(`获取项目(Gizmo)列表失败 (${r.status})`);
            return [];
        }
        const data = await r.json();
        const projects = [];
        data.items?.forEach(item => {
            if (item?.gizmo?.id && item?.gizmo?.display?.name) {
                projects.push({ id: item.gizmo.id, title: item.gizmo.display.name });
            }
        });
        return projects;
    }

    function resolveWorkspaceId(workspaceId) {
        if (workspaceId) return workspaceId;
        const match = document.cookie.match(/(?:^|; )_account=([^;]+)/);
        if (match?.[1]) return match[1];
        const detectedIds = detectAllWorkspaceIds();
        return detectedIds.length > 0 ? detectedIds[0] : null;
    }

    async function getProjectSpaces(workspaceId, options = {}) {
        const deviceId = getOaiDeviceId();
        if (!deviceId) {
            throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。');
        }
        const headers = {
            'Authorization': `Bearer ${accessToken}`,
            'oai-device-id': deviceId
        };
        const resolvedWorkspaceId = resolveWorkspaceId(workspaceId);
        if (resolvedWorkspaceId) { headers['ChatGPT-Account-Id'] = resolvedWorkspaceId; }

        const query = new URLSearchParams();
        if (options.conversationsPerGizmo !== undefined) {
            query.set('conversations_per_gizmo', String(options.conversationsPerGizmo));
        }
        if (options.ownedOnly !== undefined) {
            query.set('owned_only', options.ownedOnly ? 'true' : 'false');
        }
        const url = query.toString()
            ? `/backend-api/gizmos/snorlax/sidebar?${query.toString()}`
            : '/backend-api/gizmos/snorlax/sidebar';

        const r = await fetch(url, { headers });
        if (!r.ok) {
            throw new Error(`获取项目空间列表失败 (${r.status})`);
        }
        const data = await r.json();
        const projects = [];
        data.items?.forEach(item => {
            const rawGizmo = item?.gizmo?.gizmo || item?.gizmo || item;
            const display = rawGizmo?.display || item?.gizmo?.display || item?.display;
            const id = rawGizmo?.id || item?.gizmo?.id || item?.id;
            const title = display?.name || rawGizmo?.name || 'Untitled Project';
            if (!id) return;
            projects.push({
                id,
                title,
                conversations: item?.conversations?.items || []
            });
        });
        return projects;
    }

    async function collectIds(btn, workspaceId, gizmoId) {
        const all = new Set();
        const deviceId = getOaiDeviceId();
        if (!deviceId) {
            throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。');
        }
        const headers = {
            'Authorization': `Bearer ${accessToken}`,
            'oai-device-id': deviceId
        };
        if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; }

        if (gizmoId) {
            let cursor = '0';
            do {
                const r = await fetch(`/backend-api/gizmos/${gizmoId}/conversations?cursor=${cursor}`, { headers });
                if (!r.ok) throw new Error(`列举项目对话列表失败 (${r.status})`);
                const j = await r.json();
                j.items?.forEach(it => all.add(it.id));
                cursor = j.cursor;
                await sleep(jitter());
            } while (cursor);
        } else {
            for (const is_archived of [false, true]) {
                let offset = 0, has_more = true, page = 0;
                do {
                    setExportButtonState(btn, {
                        label: '扫描',
                        title: `项目外对话 (${is_archived ? 'Archived' : 'Active'} p${++page})`,
                        state: 'busy'
                    });
                    const r = await fetch(`/backend-api/conversations?offset=${offset}&limit=${PAGE_LIMIT}&order=updated${is_archived ? '&is_archived=true' : ''}`, { headers });
                    if (!r.ok) throw new Error(`列举项目外对话列表失败 (${r.status})`);
                    const j = await r.json();
                    if (j.items && j.items.length > 0) {
                        j.items.forEach(it => all.add(it.id));
                        has_more = j.items.length === PAGE_LIMIT;
                        offset += j.items.length;
                    } else {
                        has_more = false;
                    }
                    await sleep(jitter());
                } while (has_more);
            }
        }
        return Array.from(all);
    }

    function upsertConversationEntry(map, item, extra = {}) {
        if (!item?.id) return;
        const create_time = normalizeEpochSeconds(item.create_time || 0);
        const update_time = normalizeEpochSeconds(item.update_time || item.create_time || 0);
        const entry = {
            id: item.id,
            title: item.title || 'Untitled Conversation',
            create_time,
            update_time,
            is_archived: item.is_archived ?? extra.is_archived ?? false,
            projectId: extra.projectId || null,
            projectTitle: extra.projectTitle || null
        };
        const existing = map.get(entry.id);
        if (!existing) {
            map.set(entry.id, entry);
            return;
        }
        if (!existing.projectTitle && entry.projectTitle) {
            existing.projectTitle = entry.projectTitle;
            existing.projectId = entry.projectId;
        }
        if (!existing.create_time && entry.create_time) {
            existing.create_time = entry.create_time;
        }
        existing.is_archived = existing.is_archived || entry.is_archived;
        if ((entry.update_time || 0) > (existing.update_time || 0)) {
            existing.update_time = entry.update_time;
        }
        if (existing.title === 'Untitled Conversation' && entry.title) {
            existing.title = entry.title;
        }
    }

    async function listConversations(workspaceId) {
        if (!await ensureAccessToken()) {
            throw new Error('无法获取 Access Token,请刷新页面或打开任意一个对话后再试。');
        }

        const deviceId = getOaiDeviceId();
        if (!deviceId) {
            throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。');
        }

        const headers = {
            'Authorization': `Bearer ${accessToken}`,
            'oai-device-id': deviceId
        };
        if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; }

        const map = new Map();
        const addEntry = (item, extra = {}) => upsertConversationEntry(map, item, extra);

        for (const is_archived of [false, true]) {
            let offset = 0;
            let has_more = true;
            do {
                const r = await fetch(`/backend-api/conversations?offset=${offset}&limit=${PAGE_LIMIT}&order=updated${is_archived ? '&is_archived=true' : ''}`, { headers });
                if (!r.ok) throw new Error(`列举对话列表失败 (${r.status})`);
                const j = await r.json();
                if (j.items && j.items.length > 0) {
                    j.items.forEach(it => addEntry(it, { is_archived }));
                    has_more = j.items.length === PAGE_LIMIT;
                    offset += j.items.length;
                } else {
                    has_more = false;
                }
                await sleep(jitter());
            } while (has_more);
        }

        if (workspaceId) {
            const projects = await getProjects(workspaceId);
            for (const project of projects) {
                let cursor = '0';
                do {
                    const r = await fetch(`/backend-api/gizmos/${project.id}/conversations?cursor=${cursor}`, { headers });
                    if (!r.ok) throw new Error(`列举项目对话列表失败 (${r.status})`);
                    const j = await r.json();
                    j.items?.forEach(it => addEntry(it, { projectId: project.id, projectTitle: project.title }));
                    cursor = j.cursor;
                    await sleep(jitter());
                } while (cursor);
            }
        }

        return Array.from(map.values())
            .sort((a, b) => (b.update_time || 0) - (a.update_time || 0));
    }

    async function listProjectSpaceConversations(workspaceId) {
        if (!await ensureAccessToken()) {
            throw new Error('无法获取 Access Token,请刷新页面或打开任意一个对话后再试。');
        }

        const deviceId = getOaiDeviceId();
        if (!deviceId) {
            throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。');
        }

        const headers = {
            'Authorization': `Bearer ${accessToken}`,
            'oai-device-id': deviceId
        };
        const resolvedWorkspaceId = resolveWorkspaceId(workspaceId);
        if (resolvedWorkspaceId) { headers['ChatGPT-Account-Id'] = resolvedWorkspaceId; }

        const map = new Map();
        const projects = await getProjectSpaces(resolvedWorkspaceId, { conversationsPerGizmo: PROJECT_SIDEBAR_PREVIEW, ownedOnly: true });

        for (const project of projects) {
            let cursor = '0';
            let fetched = false;
            do {
                const r = await fetch(`/backend-api/gizmos/${project.id}/conversations?cursor=${cursor}`, { headers });
                if (!r.ok) {
                    if (!fetched && Array.isArray(project.conversations) && project.conversations.length > 0) {
                        console.warn(`项目空间对话列表请求失败 (${r.status}),使用侧边栏返回的预览对话。`);
                        project.conversations.forEach(item => upsertConversationEntry(map, item, {
                            projectId: project.id,
                            projectTitle: project.title
                        }));
                        cursor = null;
                        break;
                    }
                    throw new Error(`列举项目空间对话列表失败 (${r.status})`);
                }
                const j = await r.json();
                j.items?.forEach(item => upsertConversationEntry(map, item, {
                    projectId: project.id,
                    projectTitle: project.title
                }));
                cursor = j.cursor;
                fetched = true;
                await sleep(jitter());
            } while (cursor);
        }

        return Array.from(map.values())
            .sort((a, b) => (b.update_time || 0) - (a.update_time || 0));
    }

    async function getConversation(id, workspaceId) {
        const deviceId = getOaiDeviceId();
        if (!deviceId) {
            throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。');
        }
        const headers = {
            'Authorization': `Bearer ${accessToken}`,
            'oai-device-id': deviceId
        };
        if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; }
        const r = await fetch(`/backend-api/conversation/${id}`, { headers });
        if (!r.ok) throw new Error(`获取对话详情失败 conv ${id} (${r.status})`);
        const j = await r.json();
        j.__fetched_at = new Date().toISOString();
        return j;
    }

    // --- UI 相关函数 ---
    // (UI部分无变动,此处省略以保持简洁)
    /**
     * [新增] 全面检测函数,返回所有找到的ID
     * @returns {string[]} - 返回包含所有唯一Workspace ID的数组
     */
    function detectAllWorkspaceIds() {
        const foundIds = new Set(capturedWorkspaceIds); // 从网络拦截的结果开始
        // 扫描 __NEXT_DATA__
        try {
            const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent);
            // 遍历所有账户信息
            const accounts = data?.props?.pageProps?.user?.accounts;
            if (accounts) {
                Object.values(accounts).forEach(acc => {
                    if (acc?.account?.id) {
                        foundIds.add(acc.account.id);
                    }
                });
            }
        } catch (e) {}

        // 扫描 localStorage
        try {
            for (let i = 0; i < localStorage.length; i++) {
                const key = localStorage.key(i);
                if (key && (key.includes('account') || key.includes('workspace'))) {
                    const value = localStorage.getItem(key);
                    if (value && /^[a-z0-9]{2,}-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value.replace(/"/g, ''))) {
                         const extractedId = value.match(/ws-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i);
                         if(extractedId) foundIds.add(extractedId[0]);
                    } else if (value && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value.replace(/"/g, ''))) {
                         foundIds.add(value.replace(/"/g, ''));
                    }
                }
            }
        } catch(e) {}

        console.log('🔍 检测到以下 Workspace IDs:', Array.from(foundIds));
        return Array.from(foundIds);
    }

    function showConversationPicker(options = {}) {
        const {
            mode: requestedMode = 'personal',
            workspaceId = null,
            action = 'export',
            initialSelectedIds = [],
            onConfirm = null,
            onBack = null
        } = options;
        const mode = normalizeExportMode(requestedMode);
        const isScheduleAction = action === 'schedule';
        const selectedActionLabel = isScheduleAction ? '保存定时选择' : '导出选中';
        ensureGreatBallThemeStyles();
        const existing = document.getElementById('export-dialog-overlay');
        if (existing) existing.remove();

        const overlay = document.createElement('div');
        overlay.id = 'export-dialog-overlay';
        overlay.classList.add('cge-theme-greatball-overlay');
        Object.assign(overlay.style, {
            position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
            backgroundColor: 'rgba(0, 0, 0, 0.5)', zIndex: '99998',
            display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
            padding: '5vh 16px 3vh', boxSizing: 'border-box', overflowY: 'auto'
        });

        const dialog = document.createElement('div');
        dialog.id = 'export-dialog';
        dialog.classList.add('cge-theme-greatball-dialog');
        dialog.setAttribute('data-cge-dialog', 'picker');
        Object.assign(dialog.style, {
            background: '#fff', padding: '24px', borderRadius: '12px',
            boxShadow: '0 5px 15px rgba(0,0,0,.3)', width: 'min(92vw, 880px)',
            fontFamily: 'sans-serif', color: '#333', boxSizing: 'border-box'
        });

        const closeDialog = () => {
            if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
        };
        const state = {
            list: [],
            filtered: [],
            selected: new Set(Array.isArray(initialSelectedIds) ? initialSelectedIds : []),
            query: '',
            scope: mode === 'project' ? 'project' : 'all',
            scopeLocked: mode === 'project',
            archived: 'all',
            timeField: 'update',
            loading: true,
            pageSize: 100,
            visibleCount: 100,
            startDate: '',
            endDate: ''
        };

        const renderBase = () => {
            const modeLabel = mode === 'team' ? '团队空间' : mode === 'project' ? '项目空间' : '个人空间';
            const workspaceLabel = workspaceId ? `(${workspaceId})` : '';
            dialog.innerHTML = `
                <h2 style="margin-top:0; margin-bottom: 12px; font-size: 18px;">${isScheduleAction ? '选择定时导出对话' : '选择要导出的对话'}</h2>
                <div style="margin-bottom: 12px; color: #666; font-size: 12px;">空间:${modeLabel}${workspaceLabel}</div>
                <div class="cge-picker-row cge-picker-row-search" style="display: flex; gap: 8px; margin-bottom: 8px;">
                    <input id="conv-search" type="text" placeholder="搜索标题/项目名/ID"
                        style="flex: 1; padding: 8px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box;">
                    <select id="filter-scope" style="padding: 8px 28px 8px 8px; border-radius: 6px; border: 1px solid #ccc;">
                        <option value="all">全部范围</option>
                        <option value="project">仅项目</option>
                        <option value="root">仅项目外</option>
                    </select>
                    <select id="filter-archived" style="padding: 8px 28px 8px 8px; border-radius: 6px; border: 1px solid #ccc;">
                        <option value="all">全部状态</option>
                        <option value="active">仅未归档</option>
                        <option value="archived">仅已归档</option>
                    </select>
                </div>
                <div class="cge-picker-row cge-picker-row-filter" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center;">
                    <select id="filter-time-field" style="padding: 8px 28px 8px 8px; border-radius: 6px; border: 1px solid #ccc;">
                        <option value="update">按更新时间</option>
                        <option value="create">按创建时间</option>
                    </select>
                    <input id="filter-start-date" type="date" style="padding: 8px; border-radius: 6px; border: 1px solid #ccc;">
                    <span style="color: #666; font-size: 12px;">至</span>
                    <input id="filter-end-date" type="date" style="padding: 8px; border-radius: 6px; border: 1px solid #ccc;">
                    <button id="clear-date-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">清空日期</button>
                </div>
                <div id="conv-status" style="margin-bottom: 8px; font-size: 12px; color: #666;">正在加载列表...</div>
                <div id="conv-list" style="max-height: 360px; overflow: auto; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; background: #fff;"></div>
                <div class="cge-picker-footer" style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px;">
                    <div class="cge-picker-footer-left" style="display: flex; gap: 8px;">
                        <button id="select-all-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">全选</button>
                        <button id="clear-all-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">清空</button>
                    </div>
                    <div class="cge-picker-footer-right" style="display: flex; gap: 8px;">
                        <button id="back-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">返回</button>
                        <button id="export-selected-btn" style="padding: 8px 12px; border: none; border-radius: 6px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;" disabled>${selectedActionLabel} (0)</button>
                    </div>
                </div>
            `;

            const searchInput = dialog.querySelector('#conv-search');
            const scopeSelect = dialog.querySelector('#filter-scope');
            const archivedSelect = dialog.querySelector('#filter-archived');
            const timeFieldSelect = dialog.querySelector('#filter-time-field');
            const startDateInput = dialog.querySelector('#filter-start-date');
            const endDateInput = dialog.querySelector('#filter-end-date');
            const clearDateBtn = dialog.querySelector('#clear-date-btn');
            const selectAllBtn = dialog.querySelector('#select-all-btn');
            const clearAllBtn = dialog.querySelector('#clear-all-btn');
            const backBtn = dialog.querySelector('#back-btn');
            const exportBtn = dialog.querySelector('#export-selected-btn');

            if (state.scopeLocked && scopeSelect) {
                scopeSelect.value = 'project';
                scopeSelect.disabled = true;
                scopeSelect.style.opacity = '0.7';
                scopeSelect.style.cursor = 'not-allowed';
                scopeSelect.title = '项目空间仅包含项目对话';
            }

            searchInput.oninput = (e) => {
                state.query = e.target.value || '';
                applyFilters();
                renderList();
            };
            scopeSelect.onchange = (e) => {
                state.scope = e.target.value;
                applyFilters();
                renderList();
            };
            archivedSelect.onchange = (e) => {
                state.archived = e.target.value;
                applyFilters();
                renderList();
            };
            timeFieldSelect.onchange = (e) => {
                state.timeField = e.target.value;
                applyFilters();
                renderList();
            };
            startDateInput.onchange = (e) => {
                state.startDate = e.target.value || '';
                applyFilters();
                renderList();
            };
            endDateInput.onchange = (e) => {
                state.endDate = e.target.value || '';
                applyFilters();
                renderList();
            };
            clearDateBtn.onclick = () => {
                state.startDate = '';
                state.endDate = '';
                startDateInput.value = '';
                endDateInput.value = '';
                applyFilters();
                renderList();
            };
            selectAllBtn.onclick = () => {
                state.filtered.forEach(item => state.selected.add(item.id));
                renderList();
            };
            clearAllBtn.onclick = () => {
                state.selected.clear();
                renderList();
            };
            backBtn.onclick = () => {
                closeDialog();
                if (isScheduleAction) {
                    if (typeof onBack === 'function') {
                        onBack();
                    } else {
                        showExportDialog();
                    }
                    return;
                }
                showExportDialog();
            };
            exportBtn.onclick = async () => {
                if (state.selected.size === 0) return;
                const selectedList = state.list.filter(item => state.selected.has(item.id));
                closeDialog();
                if (isScheduleAction) {
                    if (typeof onConfirm === 'function') {
                        await onConfirm(selectedList);
                    } else {
                        showExportDialog();
                    }
                    return;
                }
                await startSelectiveExportProcess(mode, workspaceId, selectedList);
            };
        };

        const applyFilters = () => {
            const query = state.query.trim().toLowerCase();
            const startBound = parseDateInputToEpoch(state.startDate, false);
            const endBound = parseDateInputToEpoch(state.endDate, true);
            state.filtered = state.list.filter(item => {
                const text = `${item.title || ''} ${item.projectTitle || ''} ${item.id || ''}`.toLowerCase();
                if (query && !text.includes(query)) return false;
                if (state.scope === 'project' && !item.projectTitle) return false;
                if (state.scope === 'root' && item.projectTitle) return false;
                if (state.archived === 'active' && item.is_archived) return false;
                if (state.archived === 'archived' && !item.is_archived) return false;
                if (startBound || endBound) {
                    const sourceTime = state.timeField === 'create'
                        ? item.create_time
                        : item.update_time;
                    const ts = normalizeEpochSeconds(sourceTime || 0);
                    if (!ts) return false;
                    if (startBound && ts < startBound) return false;
                    if (endBound && ts > endBound) return false;
                }
                return true;
            });
            state.visibleCount = state.pageSize;
        };

        const renderList = () => {
            const statusEl = dialog.querySelector('#conv-status');
            const listEl = dialog.querySelector('#conv-list');
            const exportBtn = dialog.querySelector('#export-selected-btn');
            const selectAllBtn = dialog.querySelector('#select-all-btn');
            const clearAllBtn = dialog.querySelector('#clear-all-btn');
            const controlsDisabled = state.loading;

            if (selectAllBtn) selectAllBtn.disabled = controlsDisabled;
            if (clearAllBtn) clearAllBtn.disabled = controlsDisabled;
            if (exportBtn) exportBtn.disabled = controlsDisabled || state.selected.size === 0;

            listEl.innerHTML = '';
            if (state.loading) {
                statusEl.textContent = '正在加载列表...';
                return;
            }

            const visibleCount = Math.min(state.visibleCount, state.filtered.length);
            statusEl.textContent = `共 ${state.list.length} 条,当前筛选 ${state.filtered.length} 条,显示 ${visibleCount} 条,已选 ${state.selected.size} 条`;
            exportBtn.textContent = `${selectedActionLabel} (${state.selected.size})`;

            if (state.filtered.length === 0) {
                const empty = document.createElement('div');
                empty.textContent = '没有匹配的对话。';
                empty.style.color = '#999';
                empty.style.padding = '8px 4px';
                listEl.appendChild(empty);
                return;
            }

            const visibleItems = state.filtered.slice(0, state.visibleCount);
            visibleItems.forEach(item => {
                const label = document.createElement('label');
                Object.assign(label.style, {
                    display: 'flex', gap: '8px', padding: '8px',
                    border: '1px solid #e5e7eb', borderRadius: '6px',
                    marginBottom: '8px', cursor: 'pointer', alignItems: 'flex-start'
                });

                const checkbox = document.createElement('input');
                checkbox.type = 'checkbox';
                checkbox.checked = state.selected.has(item.id);
                checkbox.onchange = (e) => {
                    if (e.target.checked) {
                        state.selected.add(item.id);
                    } else {
                        state.selected.delete(item.id);
                    }
                    renderList();
                };

                const content = document.createElement('div');
                content.style.flex = '1';

                const title = document.createElement('div');
                title.textContent = item.title || 'Untitled Conversation';
                title.style.fontWeight = 'bold';
                title.style.fontSize = '14px';

                const meta = document.createElement('div');
                meta.style.fontSize = '12px';
                meta.style.color = '#666';
                const timeLabelPrefix = state.timeField === 'create' ? '创建' : '更新';
                const timeValue = state.timeField === 'create' ? item.create_time : item.update_time;
                const timeLabel = formatTimestamp(timeValue) || '未知';
                meta.textContent = `${timeLabelPrefix}: ${timeLabel}`;

                const tags = document.createElement('div');
                tags.style.marginTop = '6px';
                tags.style.display = 'flex';
                tags.style.gap = '6px';
                tags.style.flexWrap = 'wrap';

                if (item.projectTitle) {
                    const projectTag = document.createElement('span');
                    projectTag.className = 'cge-tag-project';
                    projectTag.textContent = `项目: ${item.projectTitle}`;
                    Object.assign(projectTag.style, {
                        background: '#eef2ff', color: '#4338ca',
                        padding: '2px 6px', borderRadius: '999px', fontSize: '11px'
                    });
                    tags.appendChild(projectTag);
                }

                if (item.is_archived) {
                    const archivedTag = document.createElement('span');
                    archivedTag.className = 'cge-tag-archived';
                    archivedTag.textContent = '已归档';
                    Object.assign(archivedTag.style, {
                        background: '#fef3c7', color: '#92400e',
                        padding: '2px 6px', borderRadius: '999px', fontSize: '11px'
                    });
                    tags.appendChild(archivedTag);
                }

                content.appendChild(title);
                content.appendChild(meta);
                if (tags.childNodes.length > 0) content.appendChild(tags);

                label.appendChild(checkbox);
                label.appendChild(content);
                listEl.appendChild(label);
            });

            if (state.filtered.length > state.visibleCount) {
                const loadMore = document.createElement('button');
                loadMore.textContent = `加载更多(剩余 ${state.filtered.length - state.visibleCount} 条)`;
                Object.assign(loadMore.style, {
                    width: '100%', padding: '8px 12px', border: '1px solid #ccc',
                    borderRadius: '6px', background: '#fff', cursor: 'pointer'
                });
                loadMore.onclick = () => {
                    state.visibleCount = Math.min(state.visibleCount + state.pageSize, state.filtered.length);
                    renderList();
                };
                listEl.appendChild(loadMore);
            }
        };

        renderBase();
        overlay.appendChild(dialog);
        document.body.appendChild(overlay);
        overlay.onclick = (e) => {
            if (e.target !== overlay) return;
            closeDialog();
            if (isScheduleAction) {
                if (typeof onBack === 'function') {
                    onBack();
                } else {
                    showExportDialog();
                }
            }
        };

        const listPromise = mode === 'project'
            ? listProjectSpaceConversations(workspaceId)
            : listConversations(workspaceId);
        listPromise
            .then(list => {
                state.list = list;
                const listIds = new Set(list.map(item => item.id));
                state.selected = new Set(Array.from(state.selected).filter(id => listIds.has(id)));
                state.loading = false;
                applyFilters();
                renderList();
            })
            .catch(err => {
                const statusEl = dialog.querySelector('#conv-status');
                state.loading = false;
                state.list = [];
                state.filtered = [];
                statusEl.textContent = `加载失败: ${err.message}`;
                renderList();
            });
    }

    /**
     * [重构] 多步骤、用户主导的导出对话框
     */
    function showExportDialog() {
        ensureGreatBallThemeStyles();
        if (document.getElementById('export-dialog-overlay')) return;

        const overlay = document.createElement('div');
        overlay.id = 'export-dialog-overlay';
        overlay.classList.add('cge-theme-greatball-overlay');
        Object.assign(overlay.style, {
            position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
            backgroundColor: 'rgba(0, 0, 0, 0.5)', zIndex: '99998',
            display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
            padding: '5vh 16px 3vh', boxSizing: 'border-box', overflowY: 'auto'
        });

        const dialog = document.createElement('div');
        dialog.id = 'export-dialog';
        dialog.classList.add('cge-theme-greatball-dialog');
        dialog.setAttribute('data-cge-dialog', 'main');
        Object.assign(dialog.style, {
            background: '#fff', padding: '24px', borderRadius: '12px',
            boxShadow: '0 5px 15px rgba(0,0,0,.3)', width: 'min(92vw, 560px)',
            fontFamily: 'sans-serif', color: '#333', boxSizing: 'border-box'
        });

        const closeDialog = () => document.body.removeChild(overlay);

        let pendingTeamAction = null;
        const renderStep = (step, action = null) => {
            pendingTeamAction = action;
            let html = '';
            switch (step) {
                case 'team': {
                    const detectedIds = detectAllWorkspaceIds();
                    html = `<h2 style="margin-top:0; margin-bottom: 20px; font-size: 18px;">导出团队空间</h2>`;

                    if (detectedIds.length > 1) {
                        html += `<div style="background: #eef2ff; border: 1px solid #818cf8; border-radius: 8px; padding: 12px; margin-bottom: 20px;">
                                     <p style="margin: 0 0 12px 0; font-weight: bold; color: #4338ca;">🔎 检测到多个 Workspace,请选择一个:</p>
                                     <div id="workspace-id-list">`;
                        detectedIds.forEach((id, index) => {
                            html += `<label style="display: block; margin-bottom: 8px; padding: 8px; border-radius: 6px; cursor: pointer; border: 1px solid #ddd; background: #fff;">
                                         <input type="radio" name="workspace_id" value="${id}" ${index === 0 ? 'checked' : ''}>
                                         <code style="margin-left: 8px; font-family: monospace; color: #555;">${id}</code>
                                      </label>`;
                        });
                        html += `</div></div>`;
                    } else if (detectedIds.length === 1) {
                        html += `<div style="background: #f0fdf4; border: 1px solid #4ade80; border-radius: 8px; padding: 12px; margin-bottom: 20px;">
                                     <p style="margin: 0 0 8px 0; font-weight: bold; color: #166534;">✅ 已自动检测到 Workspace ID:</p>
                                     <code id="workspace-id-code" style="background: #e0e7ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; color: #4338ca; word-break: break-all;">${detectedIds[0]}</code>
                                   </div>`;
                    } else {
                        html += `<div style="background: #fffbeb; border: 1px solid #facc15; border-radius: 8px; padding: 12px; margin-bottom: 20px;">
                                     <p style="margin: 0; color: #92400e;">⚠️ 未能自动检测到 Workspace ID。</p>
                                     <p style="margin: 8px 0 0 0; font-size: 12px; color: #92400e;">请尝试刷新页面或打开一个团队对话,或在下方手动输入。</p>
                                   </div>
                                   <label for="team-id-input" style="display: block; margin-bottom: 8px; font-weight: bold;">手动输入 Team Workspace ID:</label>
                                   <input type="text" id="team-id-input" placeholder="粘贴您的 Workspace ID (ws-...)" style="width: 100%; padding: 8px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box;">`;
                    }

                    let actionButtons = '';
                    if (pendingTeamAction === 'all') {
                        actionButtons = `<button id="start-team-export-btn" style="padding: 10px 16px; border: none; border-radius: 8px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">导出全部 (ZIP)</button>`;
                    } else if (pendingTeamAction === 'select') {
                        actionButtons = `<button id="start-team-picker-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">选择对话导出</button>`;
                    } else {
                        actionButtons = `<button id="start-team-export-btn" style="padding: 10px 16px; border: none; border-radius: 8px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">导出全部 (ZIP)</button>
                                     <button id="start-team-picker-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">选择对话导出</button>`;
                    }

                    html += `<div class="cge-team-footer" style="display: flex; justify-content: space-between; align-items: center; margin-top: 24px;">
                                 <button id="back-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">返回</button>
                                 <div class="cge-space-actions" style="display: flex; gap: 8px;">
                                     ${actionButtons}
                                 </div>
                               </div>`;
                    break;
                }

                case 'initial':
                default:
                    const scheduleWorkspaceValue = (autoExportState.workspaceId || '').replace(/"/g, '&quot;');
                    const scheduleWorkspaceDisabled = '';
                    const scheduleSelectionType = autoExportState.selectionType === 'selected' ? 'selected' : 'all';
                    const filenameTemplateValue = escapeHtml(filenameSettings.template || '');
                    const filenameAppendDateChecked = filenameSettings.appendDate ? 'checked' : '';
                    const fixedOverwriteSupported = supportsFixedFileOverwrite();
                    const fixedOverwriteChecked = downloadSettings.mode === 'file-handle' ? 'checked' : '';
                    const fixedOverwriteDisabled = fixedOverwriteSupported ? '' : 'disabled';
                    const fixedFileName = escapeHtml(downloadSettings.handleName || '未设置');
                    const fixedOverwriteHint = fixedOverwriteSupported
                        ? '开启后将直接写入同一个 ZIP 文件,避免浏览器自动改名为 (1)。'
                        : '当前浏览器不支持固定文件覆盖模式,将使用普通下载。';
                    const filenamePreviewValue = escapeHtml(buildExportZipFilename(
                        normalizeExportMode(autoExportState.mode),
                        autoExportState.selectionType === 'selected' ? 'selected' : 'full',
                        autoExportState.workspaceId || ''
                    ));
                    html = `<h2 style="margin-top:0; margin-bottom: 20px; font-size: 18px;">团队空间导出</h2>
                                <div class="cge-space-grid" style="display: flex; flex-direction: column; gap: 16px;">
                                    <div class="cge-space-card" style="padding: 16px; border: 1px solid #ccc; border-radius: 8px; background: #f9fafb;">
                                        <strong style="font-size: 16px;">团队空间</strong>
                                        <p style="margin: 4px 0 12px 0; color: #666;">导出团队空间下的对话,将自动检测ID。</p>
                                        <div class="cge-space-actions" style="display: flex; gap: 8px;">
                                            <button id="select-team-btn" style="padding: 8px 12px; border: none; border-radius: 6px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">导出全部</button>
                                            <button id="select-team-picker-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">选择对话导出</button>
                                        </div>
                                    </div>
                                    <div class="cge-space-card" style="padding: 16px; border: 1px solid #ccc; border-radius: 8px; background: #f9fafb;">
                                        <strong style="font-size: 16px;">文件命名</strong>
                                        <p style="margin: 4px 0 12px 0; color: #666;">自定义 ZIP 文件名。若要尽量保持同名文件,请关闭“自动追加日期”(部分浏览器仍可能自动改名为 (1))。</p>
                                        <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;">
                                            <input id="filename-template-input" type="text" value="${filenameTemplateValue}" placeholder="例如 latest_chat_backup 或 backup_{mode}_{type}" style="flex: 1 1 320px; min-width: 220px; padding: 8px; border-radius: 6px; border: 1px solid #ccc;" />
                                        </div>
                                        <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;">
                                            <label style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #666;">
                                                <input id="filename-append-date-checkbox" type="checkbox" ${filenameAppendDateChecked} />
                                                自动追加日期
                                            </label>
                                            <button id="filename-save-btn" style="padding: 8px 12px; border: none; border-radius: 6px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">保存命名设置</button>
                                            <button id="filename-reset-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">恢复默认</button>
                                        </div>
                                        <div style="font-size: 12px; color: #666; margin-bottom: 6px;">支持变量:<code>{mode}</code> <code>{type}</code> <code>{workspace}</code> <code>{date}</code></div>
                                        <div style="font-size: 12px; color: #666; margin-bottom: 8px;">预览:<code id="filename-preview">${filenamePreviewValue}</code></div>
                                        <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;">
                                            <label style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #666;">
                                                <input id="download-overwrite-checkbox" type="checkbox" ${fixedOverwriteChecked} ${fixedOverwriteDisabled} />
                                                固定文件覆盖模式(避免 (1))
                                            </label>
                                            <button id="download-pick-file-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;" ${fixedOverwriteDisabled}>选择固定保存文件</button>
                                            <button id="download-clear-file-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;" ${fixedOverwriteDisabled}>清除固定文件</button>
                                        </div>
                                        <div id="download-fixed-file-status" style="font-size: 12px; color: #666; margin-bottom: 4px;">当前固定文件:<code>${fixedFileName}</code></div>
                                        <div style="font-size: 12px; color: #666;">${fixedOverwriteHint}</div>
                                    </div>
                                    <div class="cge-space-card" style="padding: 16px; border: 1px solid #ccc; border-radius: 8px; background: #f9fafb;">
                                        <strong style="font-size: 16px;">定时导出</strong>
                                        <p style="margin: 4px 0 12px 0; color: #666;">每隔固定时间自动导出,启动后会立即执行一次,刷新页面后会自动恢复。</p>
                                        <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;">
                                            <input id="schedule-interval-input" type="number" min="${AUTO_EXPORT_MIN_MINUTES}" value="${autoExportState.intervalMinutes}" style="flex: 1 1 120px; min-width: 120px; padding: 8px; border-radius: 6px; border: 1px solid #ccc;" />
                                            <select id="schedule-mode-select" disabled style="flex: 1 1 140px; min-width: 140px; padding: 8px; border-radius: 6px; border: 1px solid #ccc; opacity: 0.75; cursor: not-allowed;">
                                                <option value="team" selected>团队空间</option>
                                            </select>
                                            <input id="schedule-workspace-input" type="text" placeholder="团队 Workspace ID (ws-...)" value="${scheduleWorkspaceValue}" ${scheduleWorkspaceDisabled} style="flex: 2 1 200px; min-width: 180px; padding: 8px; border-radius: 6px; border: 1px solid #ccc;" />
                                        </div>
                                        <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;">
                                            <select id="schedule-selection-type" style="flex: 1 1 180px; min-width: 180px; padding: 8px; border-radius: 6px; border: 1px solid #ccc;">
                                                <option value="all" ${scheduleSelectionType === 'all' ? 'selected' : ''}>导出全部对话</option>
                                                <option value="selected" ${scheduleSelectionType === 'selected' ? 'selected' : ''}>仅导出已选对话</option>
                                            </select>
                                            <button id="schedule-pick-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">选择定时对话</button>
                                        </div>
                                        <div id="schedule-selection-summary" style="font-size: 12px; color: #666; margin-bottom: 8px;"></div>
                                        <div class="cge-space-actions" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px;">
                                            <button id="schedule-start-btn" style="padding: 8px 12px; border: none; border-radius: 6px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">启动定时</button>
                                            <button id="schedule-stop-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">停止定时</button>
                                            <button id="schedule-run-now-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">立即执行一次</button>
                                        </div>
                                        <div id="schedule-status" style="font-size: 12px; color: #666;">${getAutoExportStatusText()}</div>
                                    </div>
                                </div>
                                <div class="cge-team-footer" style="display: flex; justify-content: flex-end; margin-top: 24px;">
                                    <button id="cancel-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">取消</button>
                                </div>`;
                    break;
            }
            dialog.innerHTML = html;
            attachListeners(step);
        };

        const attachListeners = (step) => {
            if (step === 'initial') {
                const startTeamFlow = (action) => {
                    const detectedIds = detectAllWorkspaceIds();
                    if (detectedIds.length === 1) {
                        const workspaceId = detectedIds[0];
                        closeDialog();
                        if (action === 'all') {
                            startExportProcess('team', workspaceId);
                        } else {
                            showConversationPicker({ mode: 'team', workspaceId });
                        }
                        return;
                    }
                    renderStep('team', action);
                };
                document.getElementById('select-team-btn').onclick = () => startTeamFlow('all');
                document.getElementById('select-team-picker-btn').onclick = () => startTeamFlow('select');
                document.getElementById('cancel-btn').onclick = closeDialog;

                const scheduleModeSelect = document.getElementById('schedule-mode-select');
                const scheduleIntervalInput = document.getElementById('schedule-interval-input');
                const scheduleWorkspaceInput = document.getElementById('schedule-workspace-input');
                const scheduleSelectionTypeSelect = document.getElementById('schedule-selection-type');
                const schedulePickBtn = document.getElementById('schedule-pick-btn');
                const scheduleSelectionSummaryEl = document.getElementById('schedule-selection-summary');
                const scheduleStatusEl = document.getElementById('schedule-status');
                const filenameTemplateInput = document.getElementById('filename-template-input');
                const filenameAppendDateCheckbox = document.getElementById('filename-append-date-checkbox');
                const filenamePreviewEl = document.getElementById('filename-preview');
                const filenameSaveBtn = document.getElementById('filename-save-btn');
                const filenameResetBtn = document.getElementById('filename-reset-btn');
                const downloadOverwriteCheckbox = document.getElementById('download-overwrite-checkbox');
                const downloadPickFileBtn = document.getElementById('download-pick-file-btn');
                const downloadClearFileBtn = document.getElementById('download-clear-file-btn');
                const downloadFixedFileStatusEl = document.getElementById('download-fixed-file-status');
                if (autoExportState.mode !== 'team') {
                    autoExportState.mode = 'team';
                    autoExportState.workspaceId = (autoExportState.workspaceId || '').trim();
                    saveAutoExportState();
                }
                const resolveTeamWorkspaceIdFromInput = (rawWorkspaceId, showAlert = false) => {
                    const directWorkspaceId = (rawWorkspaceId || '').trim();
                    if (directWorkspaceId) return directWorkspaceId;
                    const detectedIds = detectAllWorkspaceIds();
                    if (detectedIds.length === 1) return detectedIds[0];
                    if (showAlert) {
                        alert('团队空间需要 Team Workspace ID(可手动填写,或先打开一个团队对话后再试)。');
                    }
                    return '';
                };
                const isSelectionContextMatched = (mode, workspaceId) => {
                    const normalizedWorkspaceId = mode === 'team' ? (workspaceId || '') : '';
                    return autoExportState.selectedMode === mode &&
                        (autoExportState.selectedWorkspaceId || '') === normalizedWorkspaceId;
                };
                const updateScheduleWorkspaceState = () => {
                    const teamMode = scheduleModeSelect.value === 'team';
                    scheduleWorkspaceInput.disabled = !teamMode;
                    scheduleWorkspaceInput.style.opacity = teamMode ? '1' : '0.7';
                    if (!teamMode) return;
                    if ((scheduleWorkspaceInput.value || '').trim()) return;
                    const detectedIds = detectAllWorkspaceIds();
                    if (detectedIds.length === 1) {
                        scheduleWorkspaceInput.value = detectedIds[0];
                    }
                };
                const getDraftFilenameSettings = () => normalizeFilenameSettings({
                    template: filenameTemplateInput?.value || '',
                    appendDate: Boolean(filenameAppendDateCheckbox?.checked)
                });
                const updateFilenamePreview = () => {
                    if (!filenamePreviewEl) return;
                    const mode = scheduleModeSelect?.value || normalizeExportMode(autoExportState.mode);
                    const selectionType = scheduleSelectionTypeSelect?.value === 'selected' ? 'selected' : 'full';
                    const workspaceId = mode === 'team'
                        ? (scheduleWorkspaceInput?.value || '').trim()
                        : '';
                    filenamePreviewEl.textContent = buildExportZipFilename(
                        mode,
                        selectionType,
                        workspaceId,
                        getDraftFilenameSettings()
                    );
                };
                const updateFixedFileStatus = () => {
                    if (downloadFixedFileStatusEl) {
                        const name = downloadSettings.handleName || '未设置';
                        downloadFixedFileStatusEl.innerHTML = `当前固定文件:<code>${escapeHtml(name)}</code>`;
                    }
                };
                const updateScheduleSelectionSummary = () => {
                    const mode = scheduleModeSelect.value;
                    const workspaceId = mode === 'team' ? scheduleWorkspaceInput.value.trim() : '';
                    const selectionType = scheduleSelectionTypeSelect.value === 'selected' ? 'selected' : 'all';
                    schedulePickBtn.disabled = false;
                    schedulePickBtn.style.opacity = '1';
                    schedulePickBtn.style.cursor = 'pointer';
                    schedulePickBtn.textContent = selectionType === 'selected'
                        ? '选择定时对话'
                        : '切换到已选并选择';

                    if (selectionType === 'all') {
                        scheduleSelectionSummaryEl.textContent = '当前策略:导出全部对话。';
                        return;
                    }

                    const selectedCount = autoExportState.selectedEntries.length;
                    if (selectedCount === 0) {
                        scheduleSelectionSummaryEl.textContent = '当前策略:仅导出已选对话(未选择)。';
                        return;
                    }

                    if (!isSelectionContextMatched(mode, workspaceId)) {
                        scheduleSelectionSummaryEl.textContent = `已保存 ${selectedCount} 条定时对话,但与当前空间设置不匹配,请重新选择。`;
                        return;
                    }
                    scheduleSelectionSummaryEl.textContent = `已保存 ${selectedCount} 条定时对话。`;
                };
                const updateScheduleStatus = () => {
                    scheduleStatusEl.textContent = getAutoExportStatusText();
                };
                scheduleModeSelect.onchange = () => {
                    updateScheduleWorkspaceState();
                    updateScheduleSelectionSummary();
                    updateFilenamePreview();
                };
                scheduleWorkspaceInput.oninput = () => {
                    updateScheduleSelectionSummary();
                    updateFilenamePreview();
                };
                scheduleSelectionTypeSelect.onchange = () => {
                    autoExportState.selectionType = scheduleSelectionTypeSelect.value === 'selected' ? 'selected' : 'all';
                    saveAutoExportState();
                    updateScheduleSelectionSummary();
                    updateFilenamePreview();
                };
                if (filenameTemplateInput) {
                    filenameTemplateInput.oninput = () => updateFilenamePreview();
                }
                if (filenameAppendDateCheckbox) {
                    filenameAppendDateCheckbox.onchange = () => updateFilenamePreview();
                }
                if (filenameSaveBtn) {
                    filenameSaveBtn.onclick = () => {
                        filenameSettings = getDraftFilenameSettings();
                        saveFilenameSettings();
                        if (downloadOverwriteCheckbox) {
                            downloadSettings.mode = downloadOverwriteCheckbox.checked ? 'file-handle' : 'browser';
                            saveDownloadSettings();
                        }
                        updateFilenamePreview();
                        updateFixedFileStatus();
                        notifyUser('命名设置已保存。', { mode: DEFAULT_NOTIFY_MODE, level: 'success' });
                    };
                }
                if (filenameResetBtn) {
                    filenameResetBtn.onclick = () => {
                        filenameSettings = normalizeFilenameSettings();
                        saveFilenameSettings();
                        if (filenameTemplateInput) filenameTemplateInput.value = filenameSettings.template;
                        if (filenameAppendDateCheckbox) filenameAppendDateCheckbox.checked = filenameSettings.appendDate;
                        updateFilenamePreview();
                        notifyUser('已恢复默认命名规则。', { mode: DEFAULT_NOTIFY_MODE, level: 'info' });
                    };
                }
                if (downloadOverwriteCheckbox) {
                    downloadOverwriteCheckbox.onchange = () => {
                        if (downloadOverwriteCheckbox.checked && !supportsFixedFileOverwrite()) {
                            downloadOverwriteCheckbox.checked = false;
                            notifyUser('当前浏览器不支持固定文件覆盖模式。', {
                                mode: DEFAULT_NOTIFY_MODE,
                                level: 'error'
                            });
                            return;
                        }
                        downloadSettings.mode = downloadOverwriteCheckbox.checked ? 'file-handle' : 'browser';
                        saveDownloadSettings();
                        updateFixedFileStatus();
                    };
                }
                if (downloadPickFileBtn) {
                    downloadPickFileBtn.onclick = async () => {
                        try {
                            const mode = scheduleModeSelect?.value || normalizeExportMode(autoExportState.mode);
                            const selectionType = scheduleSelectionTypeSelect?.value === 'selected' ? 'selected' : 'full';
                            const workspaceId = mode === 'team' ? (scheduleWorkspaceInput?.value || '').trim() : '';
                            const suggestedName = buildExportZipFilename(
                                mode,
                                selectionType,
                                workspaceId,
                                getDraftFilenameSettings()
                            );
                            await pickAndRememberZipFileHandle(suggestedName);
                            downloadSettings.mode = 'file-handle';
                            saveDownloadSettings();
                            if (downloadOverwriteCheckbox) downloadOverwriteCheckbox.checked = true;
                            updateFixedFileStatus();
                            notifyUser('固定保存文件已设置,后续将直接覆盖该文件。', {
                                mode: DEFAULT_NOTIFY_MODE,
                                level: 'success'
                            });
                        } catch (err) {
                            if (err?.name === 'AbortError') return;
                            notifyUser(`选择固定文件失败: ${err?.message || err}`, {
                                mode: DEFAULT_NOTIFY_MODE,
                                level: 'error',
                                duration: 3800
                            });
                        }
                    };
                }
                if (downloadClearFileBtn) {
                    downloadClearFileBtn.onclick = async () => {
                        await clearStoredZipFileHandle();
                        downloadSettings.handleName = '';
                        if (downloadSettings.mode === 'file-handle') {
                            downloadSettings.mode = 'browser';
                            if (downloadOverwriteCheckbox) downloadOverwriteCheckbox.checked = false;
                        }
                        saveDownloadSettings();
                        updateFixedFileStatus();
                        notifyUser('已清除固定保存文件。', {
                            mode: DEFAULT_NOTIFY_MODE,
                            level: 'info'
                        });
                    };
                }
                updateScheduleWorkspaceState();
                updateScheduleSelectionSummary();
                updateScheduleStatus();
                updateFilenamePreview();
                updateFixedFileStatus();

                schedulePickBtn.onclick = () => {
                    if (scheduleSelectionTypeSelect.value !== 'selected') {
                        scheduleSelectionTypeSelect.value = 'selected';
                        autoExportState.selectionType = 'selected';
                        saveAutoExportState();
                    }
                    const mode = scheduleModeSelect.value;
                    let workspaceId = mode === 'team' ? resolveTeamWorkspaceIdFromInput(scheduleWorkspaceInput.value, true) : '';
                    if (mode === 'team' && !workspaceId) return;
                    if (mode === 'team') {
                        scheduleWorkspaceInput.value = workspaceId;
                    }
                    const initialSelectedIds = isSelectionContextMatched(mode, workspaceId)
                        ? autoExportState.selectedEntries.map(item => item.id)
                        : [];
                    closeDialog();
                    showConversationPicker({
                        mode,
                        workspaceId: mode === 'team' ? workspaceId : null,
                        action: 'schedule',
                        initialSelectedIds,
                        onConfirm: async (selectedList) => {
                            autoExportState.selectionType = 'selected';
                            autoExportState.selectedEntries = normalizeScheduleEntries(selectedList);
                            autoExportState.selectedMode = mode;
                            autoExportState.selectedWorkspaceId = mode === 'team' ? workspaceId : '';
                            autoExportState.lastError = '';
                            saveAutoExportState();
                            showExportDialog();
                        },
                        onBack: () => showExportDialog()
                    });
                };

                document.getElementById('schedule-start-btn').onclick = () => {
                    const intervalMinutes = Math.max(
                        AUTO_EXPORT_MIN_MINUTES,
                        Math.floor(Number(scheduleIntervalInput.value || 0))
                    );
                    if (!Number.isFinite(intervalMinutes) || intervalMinutes < AUTO_EXPORT_MIN_MINUTES) {
                        alert(`请输入有效的间隔分钟数(>= ${AUTO_EXPORT_MIN_MINUTES})。`);
                        return;
                    }

                    const mode = scheduleModeSelect.value;
                    const selectionType = scheduleSelectionTypeSelect.value === 'selected' ? 'selected' : 'all';
                    const workspaceId = mode === 'team'
                        ? resolveTeamWorkspaceIdFromInput(scheduleWorkspaceInput.value, true)
                        : '';
                    if (mode === 'team' && !workspaceId) return;
                    if (mode === 'team') {
                        scheduleWorkspaceInput.value = workspaceId;
                    }

                    const contextMatched = isSelectionContextMatched(mode, workspaceId);
                    const selectedEntries = contextMatched
                        ? normalizeScheduleEntries(autoExportState.selectedEntries)
                        : [];
                    if (selectionType === 'selected' && selectedEntries.length === 0) {
                        alert('当前“仅导出已选对话”没有有效选择,请先点击“选择定时对话”。');
                        updateScheduleSelectionSummary();
                        return;
                    }

                    try {
                        const state = startIntervalExport({
                            mode,
                            workspaceId,
                            intervalMinutes,
                            runNow: true,
                            selectionType,
                            selectedEntries: selectionType === 'selected' ? selectedEntries : autoExportState.selectedEntries,
                            selectedMode: selectionType === 'selected' ? mode : autoExportState.selectedMode,
                            selectedWorkspaceId: selectionType === 'selected'
                                ? (mode === 'team' ? workspaceId : '')
                                : autoExportState.selectedWorkspaceId
                        });
                        scheduleIntervalInput.value = String(state.intervalMinutes);
                        scheduleModeSelect.value = state.mode;
                        scheduleWorkspaceInput.value = state.workspaceId || '';
                        scheduleSelectionTypeSelect.value = state.selectionType || 'all';
                        updateScheduleWorkspaceState();
                        updateScheduleSelectionSummary();
                        updateScheduleStatus();
                    } catch (err) {
                        alert(err?.message || '启动定时导出失败。');
                    }
                };

                document.getElementById('schedule-stop-btn').onclick = () => {
                    stopIntervalExport();
                    updateScheduleSelectionSummary();
                    updateScheduleStatus();
                };

                document.getElementById('schedule-run-now-btn').onclick = async () => {
                    const mode = scheduleModeSelect.value;
                    const selectionType = scheduleSelectionTypeSelect.value === 'selected' ? 'selected' : 'all';
                    let workspaceId = mode === 'team'
                        ? resolveTeamWorkspaceIdFromInput(scheduleWorkspaceInput.value, true)
                        : null;
                    if (mode === 'team' && !workspaceId) return;
                    if (mode === 'team') {
                        scheduleWorkspaceInput.value = workspaceId;
                    }

                    if (selectionType === 'selected') {
                        const contextMatched = isSelectionContextMatched(mode, workspaceId || '');
                        const selectedEntries = contextMatched
                            ? normalizeScheduleEntries(autoExportState.selectedEntries)
                            : [];
                        if (selectedEntries.length === 0) {
                            alert('当前“仅导出已选对话”没有有效选择,请先点击“选择定时对话”。');
                            updateScheduleSelectionSummary();
                            return;
                        }
                        closeDialog();
                        const exported = await exportConversations({
                            mode,
                            workspaceId,
                            conversationEntries: selectedEntries,
                            exportType: 'selected',
                            notifyMode: SCHEDULE_NOTIFY_MODE
                        });
                        if (exported) {
                            autoExportState.lastRunAt = Date.now();
                            autoExportState.lastError = '';
                        } else {
                            autoExportState.lastError = '立即执行未完成导出。';
                        }
                        saveAutoExportState();
                        return;
                    }

                    closeDialog();
                    await startScheduledExport({
                        mode,
                        workspaceId,
                        autoConfirm: true,
                        source: 'manual-interval'
                    });
                };
            } else if (step === 'team') {
                document.getElementById('back-btn').onclick = () => renderStep('initial');
                const resolveWorkspaceId = () => {
                    let workspaceId = '';
                    const radioChecked = document.querySelector('input[name="workspace_id"]:checked');
                    const codeEl = document.getElementById('workspace-id-code');
                    const inputEl = document.getElementById('team-id-input');

                    if (radioChecked) {
                        workspaceId = radioChecked.value;
                    } else if (codeEl) {
                        workspaceId = codeEl.textContent;
                    } else if (inputEl) {
                        workspaceId = inputEl.value.trim();
                    }

                    if (!workspaceId) {
                        alert('请选择或输入一个有效的 Team Workspace ID!');
                        return;
                    }
                    return workspaceId;
                };
                const exportAllBtn = document.getElementById('start-team-export-btn');
                const pickerBtn = document.getElementById('start-team-picker-btn');
                if (exportAllBtn) exportAllBtn.onclick = () => {
                    const workspaceId = resolveWorkspaceId();
                    if (!workspaceId) return;
                    closeDialog();
                    startExportProcess('team', workspaceId);
                };
                if (pickerBtn) pickerBtn.onclick = () => {
                    const workspaceId = resolveWorkspaceId();
                    if (!workspaceId) return;
                    closeDialog();
                    showConversationPicker({ mode: 'team', workspaceId });
                };
            }
        };

        overlay.appendChild(dialog);
        document.body.appendChild(overlay);
        overlay.onclick = (e) => { if (e.target === overlay) closeDialog(); };
        renderStep('initial');
    }

    function addBtn() {
        ensureGreatBallThemeStyles();
        const oldRail = document.getElementById('cge-poke-rail');
        if (oldRail) oldRail.remove();
        const oldMasterBtn = document.getElementById('cge-master-btn');
        if (oldMasterBtn) oldMasterBtn.remove();
        const oldPokeBtn = document.getElementById('cge-poke-btn');
        if (oldPokeBtn) oldPokeBtn.remove();

        let b = document.getElementById('gpt-rescue-btn');
        if (!b) {
            b = document.createElement('button');
            b.id = 'gpt-rescue-btn';
            b.type = 'button';
            b.className = 'cge-ball-btn cge-ball-great cge-theme-greatball-fab';
            b.innerHTML = '<span class="cge-ball-label">导出</span>';
        } else {
            if (!b.querySelector('.cge-ball-label')) {
                b.innerHTML = '<span class="cge-ball-label">导出</span>';
            }
            b.classList.remove('cge-ball-master', 'cge-ball-ultra', 'cge-ball-poke');
            b.classList.add('cge-ball-btn', 'cge-ball-great', 'cge-theme-greatball-fab');
        }
        if (!b.parentElement || b.parentElement !== document.body) {
            document.body.appendChild(b);
        }

        b.style.display = '';
        b.disabled = false;
        setExportButtonState(b, { label: '导出', title: '打开导出面板', state: 'idle' });
        b.onclick = showExportDialog;
    }

    // --- 脚本启动 ---
    setTimeout(addBtn, 2000);
    resumeIntervalExportIfNeeded();

    window.ChatGPTExporter = window.ChatGPTExporter || {};
    Object.assign(window.ChatGPTExporter, {
        showDialog: showExportDialog,
        startManualExport: (mode = 'team', workspaceId = null) => {
            const normalizedMode = normalizeExportMode(mode);
            if (normalizedMode === 'project') {
                return startProjectSpaceExportProcess(workspaceId);
            }
            return startExportProcess(normalizedMode, workspaceId);
        },
        startScheduledExport,
        startIntervalExport,
        stopIntervalExport,
        getIntervalExportState
    });

    document.documentElement.setAttribute('data-chatgpt-exporter-ready', '1');
    window.dispatchEvent(new CustomEvent('CHATGPT_EXPORTER_READY'));

    window.addEventListener('message', (event) => {
        if (event.source !== window) return;
        const data = event.data || {};
        if (data?.type !== 'CHATGPT_EXPORTER_COMMAND') return;
        const api = window.ChatGPTExporter;
        if (!api) return;
        try {
            switch (data.action) {
                case 'START_SCHEDULED_EXPORT':
                    api.startScheduledExport(data.payload || {});
                    break;
                case 'START_INTERVAL_EXPORT':
                    api.startIntervalExport(data.payload || {});
                    break;
                case 'STOP_INTERVAL_EXPORT':
                    api.stopIntervalExport();
                    break;
                case 'OPEN_DIALOG':
                    api.showDialog();
                    break;
                case 'START_MANUAL_EXPORT':
                    api.startManualExport(data.payload?.mode, data.payload?.workspaceId);
                    break;
                default:
                    console.warn('[ChatGPT Exporter] 未知命令:', data.action);
            }
        } catch (err) {
            console.error('[ChatGPT Exporter] 处理命令失败:', err);
        }
    });

})();





// ===== END 1.js =====

// ===== BEGIN 2.js =====

// ==UserScript==
// @name         NotebookLM Chat
// @namespace    local.notebooklm
// @version      0.4.4
// @description  在 ChatGPT 页面调用 NotebookLM 聊天,支持背景框与发送时自动拼接
// @author       you
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @match        https://notebooklm.google.com/*
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @connect      notebooklm.google.com
// @connect      accounts.google.com
// ==/UserScript==

(function () {
  "use strict";

  const NOTEBOOKLM_HOME_URL = "https://notebooklm.google.com/";
  const BATCHEXECUTE_URL =
    "https://notebooklm.google.com/_/LabsTailwindUi/data/batchexecute";
  const QUERY_URL =
    "https://notebooklm.google.com/_/LabsTailwindUi/data/google.internal.labs.tailwind.orchestration.v1.LabsTailwindOrchestrationService/GenerateFreeFormStreamed";

  const RPC = {
    LIST_NOTEBOOKS: "wXbhsf",
    GET_NOTEBOOK: "rLM1Ne",
  };

  const DEFAULT_BL = "boq_labs-tailwind-frontend_20251221.14_p0";
  const STORAGE_KEY = "tm_notebooklm_chat_clipboard_state_v1";
  const TOKEN_CACHE_TTL_MS = 60 * 1000;
  const CONTEXT_BLOCK_TITLE = "【NotebookLM 背景信息】";
  const QUESTION_BLOCK_TITLE = "【用户问题】";
  const ASK_SEND_BUTTON_TITLE = "先查询 NotebookLM 背景,再自动拼接并发送";
  const DEFAULT_NOTEBOOK_SYSTEM_PROMPT = "";
  const DEFAULT_MERGE_INSTRUCTION_PROMPT = [
    "以下是 NotebookLM 返回的背景资料,请把它作为上下文参考:",
    "笔记本:{{notebook}}",
    "NotebookLM 提问:{{question}}",
    "NotebookLM 返回:",
    "{{answer}}",
    "",
    "请基于以上背景继续回答我的问题。",
  ].join("\n");

  const state = {
    panelOpen: false,
    reqid: 100000,
    notebooks: [],
    sources: [],
    selectedNotebookId: "",
    sourceSelectionByNotebook: {},
    conversationByNotebook: {},
    historyByConversation: {},
    lastResult: null,
    notebookSystemPrompt: DEFAULT_NOTEBOOK_SYSTEM_PROMPT,
    mergeInstructionPrompt: DEFAULT_MERGE_INSTRUCTION_PROMPT,
    chatContextText: "",
    chatContextLayout: null,
    chatContextMinimized: false,
    chatContextFabPos: null,
  };

  const tokenCache = {
    csrfToken: "",
    sessionId: "",
    bl: DEFAULT_BL,
    fetchedAt: 0,
  };

  const ui = {
    root: null,
    toggle: null,
    notebookSelect: null,
    reloadBtn: null,
    sourceList: null,
    sourceCount: null,
    selectAllBtn: null,
    clearAllBtn: null,
    notebookSystemPromptInput: null,
    mergeInstructionPromptInput: null,
    newConversationCheckbox: null,
    askBtn: null,
    chatContextWrap: null,
    chatContextFab: null,
    askSendFloatingBtn: null,
    chatContextTextarea: null,
    status: null,
  };

  let chatContextObserver = null;
  let chatContextResizeObserver = null;
  let chatContextResizeObservedEl = null;
  let chatContextResizePersistTimer = 0;
  let chatContextApplyingLayout = false;
  let chatContextWindowResizeBound = false;
  let chatContextFabJustDragged = false;
  let chatAutoAskInFlight = false;
  let askSendFloatingPositionBound = false;

  function loadPersistedState() {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return;
      const saved = JSON.parse(raw);
      if (saved && typeof saved === "object") {
        state.panelOpen = saved.panelOpen !== false;
        state.notebookSystemPrompt =
          typeof saved.notebookSystemPrompt === "string"
            ? saved.notebookSystemPrompt
            : DEFAULT_NOTEBOOK_SYSTEM_PROMPT;
        state.mergeInstructionPrompt = (() => {
          if (typeof saved.mergeInstructionPrompt !== "string") {
            return DEFAULT_MERGE_INSTRUCTION_PROMPT;
          }
          const legacyValue = "请结合以下背景信息回答用户问题。";
          if (saved.mergeInstructionPrompt.trim() === legacyValue) {
            return DEFAULT_MERGE_INSTRUCTION_PROMPT;
          }
          return saved.mergeInstructionPrompt;
        })();
        state.chatContextText = saved.chatContextText || "";
        state.chatContextLayout =
          saved.chatContextLayout && typeof saved.chatContextLayout === "object"
            ? saved.chatContextLayout
            : null;
        state.chatContextMinimized = saved.chatContextMinimized === true;
        state.chatContextFabPos =
          saved.chatContextFabPos && typeof saved.chatContextFabPos === "object"
            ? saved.chatContextFabPos
            : null;
        state.selectedNotebookId = saved.selectedNotebookId || "";
        state.sourceSelectionByNotebook = saved.sourceSelectionByNotebook || {};
        // On ChatGPT, keep panel collapsed after refresh. Open only when user clicks.
        if (isChatGPTPage()) {
          state.panelOpen = false;
        }
      }
    } catch (_) {
      // ignore storage errors
    }
  }

  function persistState() {
    try {
      localStorage.setItem(
        STORAGE_KEY,
        JSON.stringify({
          panelOpen: state.panelOpen,
          notebookSystemPrompt: state.notebookSystemPrompt,
          mergeInstructionPrompt: state.mergeInstructionPrompt,
          chatContextText: state.chatContextText,
          chatContextLayout: state.chatContextLayout,
          chatContextMinimized: state.chatContextMinimized,
          chatContextFabPos: state.chatContextFabPos,
          selectedNotebookId: state.selectedNotebookId,
          sourceSelectionByNotebook: state.sourceSelectionByNotebook,
        })
      );
    } catch (_) {
      // ignore storage errors
    }
  }

  function injectStyles() {
    const style = document.createElement("style");
    style.textContent = `
#tm-nblm-toggle{
  position:fixed; right:20px; bottom:20px; z-index:12030;
  width:60px; height:60px; border:3px solid #111827; border-radius:999px; padding:0;
  display:flex; align-items:center; justify-content:center;
  background:linear-gradient(180deg,#ef4444 0 47%, #111827 47% 53%, #f8fafc 53% 100%);
  color:transparent; font-size:0; cursor:pointer; user-select:none; overflow:hidden;
  box-shadow:0 12px 28px rgba(17,24,39,.35);
  transition:transform .2s ease, box-shadow .2s ease, filter .2s ease;
}
#tm-nblm-toggle::before{
  content:"";
  position:absolute;
  left:50%;
  top:50%;
  width:20px;
  height:20px;
  transform:translate(-50%,-50%);
  border-radius:999px;
  border:3px solid #111827;
  background:#ffffff;
}
#tm-nblm-toggle::after{
  content:"";
  position:absolute;
  left:50%;
  top:50%;
  width:8px;
  height:8px;
  transform:translate(-50%,-50%);
  border-radius:999px;
  background:#ef4444;
}
#tm-nblm-toggle:hover{
  transform:translateY(-1px);
  filter:saturate(108%);
  box-shadow:0 16px 32px rgba(17,24,39,.44);
}
#tm-nblm-toggle:active{
  transform:translateY(0) scale(.97);
}
#tm-nblm-root{
  position:fixed; right:20px; bottom:90px; z-index:12020;
  width:min(430px, calc(100vw - 24px)); max-height:min(78vh, 760px);
  display:flex; flex-direction:column; gap:10px; overflow:hidden;
  border:3px solid #111827; border-radius:20px;
  background:linear-gradient(165deg,#ef4444 0%,#dc2626 68%,#b91c1c 100%);
  box-shadow:0 20px 44px rgba(127,29,29,.42), inset 0 1px 0 rgba(255,255,255,.25);
  transform-origin:right bottom;
  transition:opacity .18s ease, transform .18s ease;
  font:13px/1.45 "Trebuchet MS","Noto Sans SC","Microsoft YaHei",sans-serif; color:#111827;
}
#tm-nblm-root.tm-hide{
  opacity:0;
  transform:translateY(10px) scale(.97);
  pointer-events:none;
}
#tm-nblm-root, #tm-nblm-root *{
  box-sizing:border-box;
}
#tm-nblm-root button,
#tm-nblm-root select,
#tm-nblm-root textarea,
#tm-nblm-root input{
  color:#101623;
}
#tm-nblm-head{
  display:flex; align-items:center; justify-content:space-between;
  padding:10px 12px;
  border-bottom:3px solid #111827;
  background:linear-gradient(180deg,#991b1b,#7f1d1d);
}
#tm-nblm-title{
  color:#fde047;
  font-weight:800;
  font-size:14px;
  letter-spacing:.4px;
  text-shadow:0 1px 0 rgba(0,0,0,.35);
}
#tm-nblm-close{
  border:2px solid #111827;
  border-radius:999px;
  background:#fef3c7;
  color:#7c2d12;
  padding:5px 11px;
  cursor:pointer;
  font-weight:700;
}
#tm-nblm-body{
  padding:10px 12px 12px;
  display:flex;
  flex-direction:column;
  gap:10px;
  overflow:auto;
  background-image:
    radial-gradient(rgba(15,23,42,.08) 1px, transparent 1px),
    linear-gradient(180deg,#fee2e2 0%,#fef3c7 52%,#dbeafe 100%);
  background-size:8px 8px, auto;
}
#tm-nblm-row{ display:flex; gap:8px; align-items:center; }
#tm-nblm-row select, #tm-nblm-row button, #tm-nblm-body textarea, #tm-nblm-body input[type="text"]{
  font:inherit;
}
#tm-nblm-notebook{
  flex:1; min-width:0;
  border:2px solid #111827;
  border-radius:10px;
  padding:8px;
  background:#ecfccb;
  color:#1f2937;
  appearance:auto;
}
#tm-nblm-reload{
  border:2px solid #111827;
  border-radius:10px;
  background:#e0f2fe;
  color:#0c4a6e;
  font-weight:700;
  padding:8px 10px;
  cursor:pointer;
}
#tm-nblm-source-wrap{
  border:2px solid #111827;
  border-radius:12px;
  padding:8px;
  background:#d9f99d;
  display:flex; flex-direction:column; gap:6px;
}
#tm-nblm-source-top{
  display:flex; align-items:center; justify-content:space-between; gap:8px;
}
#tm-nblm-source-count{ color:#14532d; font-size:12px; font-weight:700; }
#tm-nblm-source-actions{ display:flex; gap:6px; }
#tm-nblm-source-actions button{
  border:2px solid #111827;
  border-radius:999px;
  background:#fef3c7;
  color:#7c2d12;
  font-weight:700;
  padding:4px 8px; cursor:pointer;
}
#tm-nblm-source-list{
  max-height:160px; overflow:auto; display:flex; flex-direction:column; gap:4px;
}
.tm-nblm-source-item{
  display:grid; grid-template-columns:auto 1fr; gap:8px; align-items:center;
  border-radius:10px;
  padding:6px;
  background:#f7fee7;
  border:2px solid #a3e635;
}
.tm-nblm-source-item:hover{ background:#ecfccb; }
.tm-nblm-source-text{ min-width:0; display:flex; flex-direction:column; }
.tm-nblm-source-title{
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  color:#14532d;
}
.tm-nblm-source-id{
  color:#4d7c0f; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
#tm-nblm-config-wrap{
  border:2px solid #111827;
  border-radius:12px;
  padding:8px;
  background:#dbeafe;
  display:flex; flex-direction:column; gap:8px;
}
.tm-nblm-config-item{
  display:flex; flex-direction:column; gap:6px;
}
.tm-nblm-config-item label{
  color:#1e3a8a;
  font-size:12px;
  font-weight:700;
}
.tm-nblm-config-item textarea{
  width:100%; min-height:58px; max-height:140px; resize:vertical;
  border:2px solid #111827;
  border-radius:10px;
  padding:8px;
  background:#eff6ff;
  color:#111827;
}
.tm-nblm-config-item textarea::placeholder{
  color:#6b7280; opacity:1;
}
#tm-nblm-options{
  display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap;
}
#tm-nblm-options label{ display:flex; align-items:center; gap:6px; color:#7f1d1d; font-weight:700; }
#tm-nblm-actions{ display:flex; gap:8px; flex-wrap:wrap; }
#tm-nblm-actions button{
  border:2px solid #111827;
  border-radius:10px;
  padding:8px 10px;
  cursor:pointer;
  color:#fff;
}
#tm-nblm-ask{
  background:linear-gradient(180deg,#fde047,#f59e0b);
  color:#3f2400;
  font-weight:800;
  box-shadow:0 6px 14px rgba(146,64,14,.3);
}
#tm-nblm-ask:hover:not(:disabled){
  filter:brightness(1.03);
}
#tm-nblm-ask:active:not(:disabled){
  transform:translateY(1px);
}
#tm-nblm-actions button:disabled{
  opacity:.55; cursor:not-allowed;
}
#tm-nblm-status{
  font-size:12px;
  color:#7f1d1d;
  font-weight:700;
}
#tm-nblm-chat-context-wrap,
#tm-nblm-chat-context-fab{
  display:none !important;
}
#tm-nblm-ask-send-float{
  position:fixed;
  display:none;
  align-items:center;
  justify-content:center;
  width:var(--tm-nblm-ask-size, 40px);
  height:var(--tm-nblm-ask-size, 40px);
  border:2px solid #111827;
  border-radius:999px;
  padding:0;
  background:
    linear-gradient(180deg,#ef4444 0 47%, #111827 47% 53%, #f8fafc 53% 100%);
  color:#f8fafc;
  text-shadow:0 1px 0 rgba(0,0,0,.45);
  font:800 12px/1 "Trebuchet MS","Noto Sans SC","Microsoft YaHei",sans-serif;
  letter-spacing:.1px;
  box-shadow:0 8px 20px rgba(17,24,39,.36);
  white-space:nowrap;
  backdrop-filter:saturate(120%) blur(4px);
  -webkit-backdrop-filter:saturate(120%) blur(4px);
  z-index:12010;
  transform:translateZ(0);
  pointer-events:auto;
  transition:box-shadow .2s ease, transform .2s ease, opacity .2s ease;
  cursor:pointer;
  overflow:hidden;
}
#tm-nblm-ask-send-float.tm-show{
  display:inline-flex;
}
body.tm-nblm-panel-open #tm-nblm-ask-send-float{
  opacity:0;
  pointer-events:none;
  transform:translateY(8px) scale(.94);
}
#tm-nblm-ask-send-float::after{
  content:"";
  position:absolute;
  inset:-2px;
  border-radius:inherit;
  border:1px solid rgba(255,255,255,.45);
  opacity:0;
  transform:scale(.93);
  transition:opacity .2s ease, transform .24s ease;
  pointer-events:none;
}
#tm-nblm-ask-send-float:hover:not(:disabled):not(.tm-loading){
  transform:translateY(-1px);
  box-shadow:
    0 14px 28px rgba(17,24,39,.4),
    inset 0 1px 0 rgba(255,255,255,.42),
    inset 0 -12px 20px rgba(0,0,0,.25);
  filter:saturate(108%);
}
#tm-nblm-ask-send-float:hover:not(:disabled):not(.tm-loading)::after{
  opacity:1;
  transform:scale(1);
}
#tm-nblm-ask-send-float:active:not(:disabled):not(.tm-loading){
  transform:translateY(0) scale(.97);
}
#tm-nblm-ask-send-float.tm-loading{
  font-size:0;
  color:transparent;
  pointer-events:none;
  background:
    linear-gradient(180deg,#f87171 0 47%, #111827 47% 53%, #ffffff 53% 100%);
  background-size:180% 180%;
  animation:tm-nblm-ask-pulse 1.1s ease-in-out infinite, tm-nblm-ask-sheen 1.8s ease-in-out infinite;
}
#tm-nblm-ask-send-float.tm-loading::before{
  content:"";
  width:var(--tm-nblm-spinner-size, 16px);
  height:var(--tm-nblm-spinner-size, 16px);
  border:var(--tm-nblm-spinner-border, 2px) solid transparent;
  border-top-color:#fff;
  border-right-color:rgba(255,255,255,.92);
  border-radius:999px;
  animation:tm-nblm-ask-spin .75s linear infinite;
  filter:drop-shadow(0 0 4px rgba(255,255,255,.35));
}
#tm-nblm-ask-send-float.tm-loading::after{
  opacity:1;
  transform:scale(1);
  animation:tm-nblm-ask-ring 1.3s ease-out infinite;
}
#tm-nblm-ask-send-float:disabled{
  opacity:.6;
  cursor:not-allowed;
}
@keyframes tm-nblm-ask-spin{
  from{ transform:rotate(0deg); }
  to{ transform:rotate(360deg); }
}
@keyframes tm-nblm-ask-pulse{
  0%,100%{
    box-shadow:
      0 10px 22px rgba(17,24,39,.34),
      inset 0 1px 0 rgba(255,255,255,.34),
      inset 0 -10px 16px rgba(0,0,0,.28);
  }
  50%{
    box-shadow:
      0 14px 28px rgba(127,29,29,.46),
      inset 0 1px 0 rgba(255,255,255,.45),
      inset 0 -12px 18px rgba(0,0,0,.34);
  }
}
@keyframes tm-nblm-ask-sheen{
  0%{ background-position:0% 50%; }
  50%{ background-position:100% 50%; }
  100%{ background-position:0% 50%; }
}
@keyframes tm-nblm-ask-ring{
  0%{ opacity:.45; transform:scale(.88); }
  70%{ opacity:0; transform:scale(1.22); }
  100%{ opacity:0; transform:scale(1.22); }
}
#tm-nblm-chat-context{
  width:100%;
  flex:1 1 auto;
  min-height:74px;
  height:100%;
  resize:none;
  border:1px solid #cfd4dc;
  border-radius:8px;
  padding:8px;
  box-sizing:border-box;
  font:12px/1.45 "Noto Sans SC","Microsoft YaHei",sans-serif;
  background:#fff;
  color:#111827;
  cursor:text;
}
#tm-nblm-chat-context::placeholder{
  color:#6b7280;
}
`;
    document.head.appendChild(style);
  }

  function createUI() {
    ui.toggle = document.createElement("button");
    ui.toggle.id = "tm-nblm-toggle";
    ui.toggle.textContent = "NotebookLM";
    ui.toggle.setAttribute("aria-label", "NotebookLM 控制面板");
    ui.toggle.title = "NotebookLM 控制面板";

    ui.root = document.createElement("div");
    ui.root.id = "tm-nblm-root";

    const head = document.createElement("div");
    head.id = "tm-nblm-head";
    const title = document.createElement("div");
    title.id = "tm-nblm-title";
    title.textContent = "控制面板";
    const close = document.createElement("button");
    close.id = "tm-nblm-close";
    close.textContent = "收起";
    head.appendChild(title);
    head.appendChild(close);

    const body = document.createElement("div");
    body.id = "tm-nblm-body";

    const row = document.createElement("div");
    row.id = "tm-nblm-row";
    ui.notebookSelect = document.createElement("select");
    ui.notebookSelect.id = "tm-nblm-notebook";
    ui.reloadBtn = document.createElement("button");
    ui.reloadBtn.id = "tm-nblm-reload";
    ui.reloadBtn.textContent = "刷新";
    row.appendChild(ui.notebookSelect);
    row.appendChild(ui.reloadBtn);

    const sourceWrap = document.createElement("div");
    sourceWrap.id = "tm-nblm-source-wrap";
    const sourceTop = document.createElement("div");
    sourceTop.id = "tm-nblm-source-top";
    ui.sourceCount = document.createElement("div");
    ui.sourceCount.id = "tm-nblm-source-count";
    ui.sourceCount.textContent = "来源: 0";
    const sourceActions = document.createElement("div");
    sourceActions.id = "tm-nblm-source-actions";
    ui.selectAllBtn = document.createElement("button");
    ui.selectAllBtn.textContent = "全选";
    ui.clearAllBtn = document.createElement("button");
    ui.clearAllBtn.textContent = "全不选";
    sourceActions.appendChild(ui.selectAllBtn);
    sourceActions.appendChild(ui.clearAllBtn);
    sourceTop.appendChild(ui.sourceCount);
    sourceTop.appendChild(sourceActions);
    ui.sourceList = document.createElement("div");
    ui.sourceList.id = "tm-nblm-source-list";
    sourceWrap.appendChild(sourceTop);
    sourceWrap.appendChild(ui.sourceList);

    const configWrap = document.createElement("div");
    configWrap.id = "tm-nblm-config-wrap";

    const notebookPromptItem = document.createElement("div");
    notebookPromptItem.className = "tm-nblm-config-item";
    const notebookPromptLabel = document.createElement("label");
    notebookPromptLabel.htmlFor = "tm-nblm-notebook-system-prompt";
    notebookPromptLabel.textContent = "NotebookLM 系统提示词(可选)";
    ui.notebookSystemPromptInput = document.createElement("textarea");
    ui.notebookSystemPromptInput.id = "tm-nblm-notebook-system-prompt";
    ui.notebookSystemPromptInput.placeholder =
      "例如:请只基于已选来源回答,不确定时明确说明。";
    ui.notebookSystemPromptInput.value = state.notebookSystemPrompt || "";
    notebookPromptItem.appendChild(notebookPromptLabel);
    notebookPromptItem.appendChild(ui.notebookSystemPromptInput);

    const mergePromptItem = document.createElement("div");
    mergePromptItem.className = "tm-nblm-config-item";
    const mergePromptLabel = document.createElement("label");
    mergePromptLabel.htmlFor = "tm-nblm-merge-instruction-prompt";
    mergePromptLabel.textContent = "拼接提示词模板(用于背景内容)";
    ui.mergeInstructionPromptInput = document.createElement("textarea");
    ui.mergeInstructionPromptInput.id = "tm-nblm-merge-instruction-prompt";
    ui.mergeInstructionPromptInput.placeholder =
      "支持占位符:{{notebook}} {{question}} {{answer}}";
    ui.mergeInstructionPromptInput.value = state.mergeInstructionPrompt || "";
    mergePromptItem.appendChild(mergePromptLabel);
    mergePromptItem.appendChild(ui.mergeInstructionPromptInput);

    configWrap.appendChild(notebookPromptItem);
    configWrap.appendChild(mergePromptItem);

    const options = document.createElement("div");
    options.id = "tm-nblm-options";
    const left = document.createElement("div");
    left.style.display = "flex";
    left.style.gap = "12px";
    left.style.flexWrap = "wrap";
    const newConversationLabel = document.createElement("label");
    ui.newConversationCheckbox = document.createElement("input");
    ui.newConversationCheckbox.type = "checkbox";
    newConversationLabel.appendChild(ui.newConversationCheckbox);
    newConversationLabel.appendChild(document.createTextNode("每次新会话"));
    left.appendChild(newConversationLabel);
    options.appendChild(left);

    ui.status = document.createElement("div");
    ui.status.id = "tm-nblm-status";
    ui.status.textContent = "准备就绪";

    body.appendChild(row);
    body.appendChild(sourceWrap);
    body.appendChild(configWrap);
    body.appendChild(options);
    body.appendChild(ui.status);

    ui.root.appendChild(head);
    ui.root.appendChild(body);

    document.body.appendChild(ui.toggle);
    document.body.appendChild(ui.root);

    close.addEventListener("click", () => {
      state.panelOpen = false;
      syncPanelVisibility();
      persistState();
    });

    ui.toggle.addEventListener("click", () => {
      state.panelOpen = !state.panelOpen;
      syncPanelVisibility();
      persistState();
    });

    ui.reloadBtn.addEventListener("click", () => {
      void initializeData(true);
    });

    ui.notebookSelect.addEventListener("change", () => {
      state.selectedNotebookId = ui.notebookSelect.value;
      persistState();
      void onNotebookChanged();
    });

    ui.selectAllBtn.addEventListener("click", () => {
      setAllSourceSelection(true);
    });

    ui.clearAllBtn.addEventListener("click", () => {
      setAllSourceSelection(false);
    });

    ui.notebookSystemPromptInput.addEventListener("input", () => {
      state.notebookSystemPrompt = ui.notebookSystemPromptInput.value;
      persistState();
    });

    ui.mergeInstructionPromptInput.addEventListener("input", () => {
      state.mergeInstructionPrompt = ui.mergeInstructionPromptInput.value;
      persistState();
    });

  }

  function syncPanelVisibility() {
    if (!ui.root) return;
    const open = !!state.panelOpen;
    ui.root.classList.toggle("tm-hide", !open);
    document.body?.classList.toggle("tm-nblm-panel-open", open);
  }

  function ensurePanelUI() {
    const root = document.getElementById("tm-nblm-root");
    const toggle = document.getElementById("tm-nblm-toggle");
    const panelReady =
      root &&
      toggle &&
      root.querySelector("#tm-nblm-notebook") &&
      root.querySelector("#tm-nblm-status");

    if (panelReady) {
      ui.root = root;
      ui.toggle = toggle;
      ui.notebookSelect = root.querySelector("#tm-nblm-notebook");
      ui.reloadBtn = root.querySelector("#tm-nblm-reload");
      ui.status = root.querySelector("#tm-nblm-status");
      return;
    }

    root?.remove();
    toggle?.remove();
    createUI();
    syncPanelVisibility();
  }

  function setStatus(text) {
    ui.status.textContent = text;
  }

  function findChatSendButtonInForm(formEl) {
    if (!(formEl instanceof HTMLFormElement)) return null;
    const buttons = formEl.querySelectorAll("button");
    for (const btn of buttons) {
      if (!isLikelySendButton(btn)) continue;
      if (!isElementVisible(btn)) continue;
      return btn;
    }
    return null;
  }

  function findChatActionAnchorButtonInForm(formEl) {
    if (!(formEl instanceof HTMLFormElement)) return null;

    const sendBtn = findChatSendButtonInForm(formEl);
    if (sendBtn) return sendBtn;

    const buttons = [...formEl.querySelectorAll("button")].filter(
      (btn) => btn.id !== "tm-nblm-ask-send-float" && isElementVisible(btn)
    );
    for (const btn of buttons) {
      const marker = [
        btn.getAttribute("data-testid") || "",
        btn.getAttribute("aria-label") || "",
        btn.getAttribute("title") || "",
      ]
        .join(" ")
        .toLowerCase();
      if (
        marker.includes("voice") ||
        marker.includes("speech") ||
        marker.includes("microphone") ||
        marker.includes("语音") ||
        marker.includes("麦克风")
      ) {
        return btn;
      }
    }

    return buttons.length ? buttons[buttons.length - 1] : null;
  }

  function hideAskSendFloatingButton(btn) {
    if (!(btn instanceof HTMLButtonElement)) return;
    btn.classList.remove("tm-show");
  }

  function getRightActionClusterRect(formEl, anchorRect) {
    if (!(formEl instanceof HTMLFormElement)) return null;
    if (!anchorRect) return null;

    const anchorCenterY = anchorRect.top + anchorRect.height / 2;
    const rowTolerance = Math.max(20, Math.round(anchorRect.height * 1.25));
    const maxHorizontalSpan = 220;
    const clusterRects = [];

    for (const el of formEl.querySelectorAll("button")) {
      if (!(el instanceof HTMLButtonElement)) continue;
      if (el.id === "tm-nblm-ask-send-float") continue;
      if (!isElementVisible(el)) continue;
      const rect = el.getBoundingClientRect();
      if (rect.width <= 0 || rect.height <= 0) continue;

      const centerY = rect.top + rect.height / 2;
      if (Math.abs(centerY - anchorCenterY) > rowTolerance) continue;
      if (rect.left < anchorRect.left - maxHorizontalSpan) continue;
      if (rect.right > anchorRect.right + 24) continue;

      clusterRects.push(rect);
    }

    if (!clusterRects.length) return null;
    let left = Infinity;
    let right = -Infinity;
    for (const rect of clusterRects) {
      if (rect.left < left) left = rect.left;
      if (rect.right > right) right = rect.right;
    }
    if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
    return { left, right };
  }

  function positionAskSendFloatingButton(btn, anchorBtn, formEl) {
    if (!(btn instanceof HTMLButtonElement)) return;
    if (!(anchorBtn instanceof HTMLButtonElement)) {
      hideAskSendFloatingButton(btn);
      return;
    }

    const rect = anchorBtn.getBoundingClientRect();
    const btnSize = Math.max(34, Math.min(44, Math.round(rect.height)));
    const gap = 8;
    const viewportPad = 8;

    if (rect.width <= 0 || rect.height <= 0) {
      hideAskSendFloatingButton(btn);
      return;
    }

    btn.style.setProperty("--tm-nblm-ask-size", `${btnSize}px`);
    btn.style.setProperty(
      "--tm-nblm-spinner-size",
      `${Math.max(12, Math.round(btnSize * 0.42))}px`
    );
    btn.style.setProperty(
      "--tm-nblm-spinner-border",
      `${Math.max(2, Math.round(btnSize * 0.08))}px`
    );

    const cluster = getRightActionClusterRect(formEl, rect);
    let left = (cluster ? cluster.left : rect.left) - btnSize - gap;
    let top = rect.top + (rect.height - btnSize) / 2;

    if (left < viewportPad) {
      left = (cluster ? cluster.right : rect.right) + gap;
    }
    left = Math.max(viewportPad, Math.min(left, window.innerWidth - btnSize - viewportPad));
    top = Math.max(viewportPad, Math.min(top, window.innerHeight - btnSize - viewportPad));

    btn.style.left = `${Math.round(left)}px`;
    btn.style.top = `${Math.round(top)}px`;
    btn.classList.add("tm-show");
  }

  function ensureAskSendFloatingButton() {
    if (!isChatGPTPage()) return null;

    let btn = document.getElementById("tm-nblm-ask-send-float");
    if (!btn) {
      btn = document.createElement("button");
      btn.id = "tm-nblm-ask-send-float";
      btn.type = "button";
      btn.textContent = "查";
      btn.setAttribute("aria-label", "查背景并发送");
      btn.title = ASK_SEND_BUTTON_TITLE;
      btn.addEventListener("click", () => {
        if (chatAutoAskInFlight) return;
        const inputEl = findChatInputElement();
        if (!inputEl) {
          setStatus("未找到 ChatGPT 输入框");
          return;
        }
        const formEl = inputEl.closest("form");
        if (!(formEl instanceof HTMLFormElement)) {
          setStatus("未找到发送表单");
          return;
        }
        const questionText = extractQuestionTextFromChatInput(readInputText(inputEl));
        if (!questionText) {
          setStatus("请先在 ChatGPT 输入框中输入问题");
          return;
        }
        void autoAskNotebookForChatSubmit(formEl, inputEl, questionText);
      });
    }

    if (btn.parentElement !== document.body) {
      document.body.appendChild(btn);
    }

    const inputEl = findChatInputElement();
    const formEl = inputEl?.closest("form") || document.querySelector("form");
    if (!(formEl instanceof HTMLFormElement)) {
      hideAskSendFloatingButton(btn);
      return null;
    }

    const anchorBtn = findChatActionAnchorButtonInForm(formEl);
    if (!(anchorBtn instanceof HTMLButtonElement)) {
      hideAskSendFloatingButton(btn);
      return null;
    }

    positionAskSendFloatingButton(btn, anchorBtn, formEl);
    ui.askSendFloatingBtn = btn;
    return btn;
  }

  function setAskSendFloatingLoading(loading) {
    const btn =
      ui.askSendFloatingBtn ||
      document.getElementById("tm-nblm-ask-send-float");
    if (!(btn instanceof HTMLButtonElement)) return;
    const on = !!loading;
    btn.classList.toggle("tm-loading", on);
    btn.setAttribute("aria-busy", on ? "true" : "false");
    btn.title = on ? "NotebookLM 查询中..." : ASK_SEND_BUTTON_TITLE;
  }

  function setBusy(busy) {
    if (ui.askBtn) ui.askBtn.disabled = busy;
    ui.reloadBtn.disabled = busy;
    ui.notebookSelect.disabled = busy;
    if (ui.askSendFloatingBtn) {
      ui.askSendFloatingBtn.disabled = busy;
    }
  }

  function bindAskSendFloatingButtonPosition() {
    if (askSendFloatingPositionBound) return;
    askSendFloatingPositionBound = true;

    let timer = 0;
    const schedule = () => {
      if (timer) return;
      timer = window.setTimeout(() => {
        timer = 0;
        ensureAskSendFloatingButton();
      }, 80);
    };

    window.addEventListener("resize", schedule);
    window.addEventListener("scroll", schedule, true);
  }

  function getDefaultChatContextLayout() {
    const width = Math.max(360, Math.min(920, Math.floor(window.innerWidth * 0.62)));
    const height = 220;
    const x = Math.max(12, Math.floor((window.innerWidth - width) / 2));
    const y = Math.max(12, window.innerHeight - height - 24);
    return { x, y, width, height, borderRadius: 10 };
  }

  function normalizeChatContextLayout(rawLayout) {
    const defaults = getDefaultChatContextLayout();
    const base = rawLayout && typeof rawLayout === "object" ? rawLayout : defaults;
    const minWidth = 320;
    const minHeight = 140;
    const maxWidth = Math.max(minWidth, window.innerWidth - 12);
    const maxHeight = Math.max(minHeight, window.innerHeight - 12);
    const widthRaw = Number(base.width);
    const heightRaw = Number(base.height);
    const xRaw = Number(base.x);
    const yRaw = Number(base.y);
    const borderRadiusRaw = Number(base.borderRadius);
    const width = Math.max(
      minWidth,
      Math.min(maxWidth, Number.isFinite(widthRaw) ? widthRaw : defaults.width)
    );
    const height = Math.max(
      minHeight,
      Math.min(maxHeight, Number.isFinite(heightRaw) ? heightRaw : defaults.height)
    );
    const maxX = Math.max(0, window.innerWidth - width);
    const maxY = Math.max(0, window.innerHeight - height);
    const x = Math.max(0, Math.min(maxX, Number.isFinite(xRaw) ? xRaw : defaults.x));
    const y = Math.max(0, Math.min(maxY, Number.isFinite(yRaw) ? yRaw : defaults.y));
    const borderRadius = Math.max(
      0,
      Math.min(28, Number.isFinite(borderRadiusRaw) ? borderRadiusRaw : defaults.borderRadius)
    );
    return { x, y, width, height, borderRadius };
  }

  function applyChatContextLayout(wrapEl, layout) {
    if (!wrapEl || !layout) return;
    chatContextApplyingLayout = true;
    wrapEl.style.left = `${Math.round(layout.x)}px`;
    wrapEl.style.top = `${Math.round(layout.y)}px`;
    wrapEl.style.width = `${Math.round(layout.width)}px`;
    wrapEl.style.height = `${Math.round(layout.height)}px`;
    wrapEl.style.borderRadius = `${Math.round(layout.borderRadius)}px`;
    chatContextApplyingLayout = false;
  }

  function setChatContextLayout(nextLayout, persist = false) {
    state.chatContextLayout = normalizeChatContextLayout(nextLayout);
    if (ui.chatContextWrap) {
      applyChatContextLayout(ui.chatContextWrap, state.chatContextLayout);
    }
    updateChatContextFabPosition();
    if (persist) persistState();
  }

  function updateChatContextFabPosition() {
    if (!ui.chatContextFab) return;
    const anchor =
      state.chatContextFabPos && typeof state.chatContextFabPos === "object"
        ? state.chatContextFabPos
        : state.chatContextLayout || getDefaultChatContextLayout();
    const left = Number(anchor?.x);
    const top = Number(anchor?.y);
    if (Number.isFinite(left)) {
      ui.chatContextFab.style.left = `${Math.round(left)}px`;
    }
    if (Number.isFinite(top)) {
      ui.chatContextFab.style.top = `${Math.round(top)}px`;
    }
  }

  function setChatContextMinimized(minimized, persist = false) {
    state.chatContextMinimized = !!minimized;
    if (state.chatContextMinimized && !state.chatContextFabPos) {
      const rect = ui.chatContextWrap?.getBoundingClientRect();
      const base = state.chatContextLayout || getDefaultChatContextLayout();
      state.chatContextFabPos = {
        x: Math.round(rect?.left ?? base.x),
        y: Math.round(rect?.top ?? base.y),
      };
    }
    if (ui.chatContextWrap) {
      ui.chatContextWrap.classList.toggle("tm-minimized", state.chatContextMinimized);
    }
    if (ui.chatContextFab) {
      updateChatContextFabPosition();
      ui.chatContextFab.classList.toggle("tm-show", state.chatContextMinimized);
    }
    if (persist) persistState();
  }

  function bindChatContextFabDrag(fabEl) {
    if (!fabEl || fabEl.dataset.tmFabDragBound) return;
    fabEl.dataset.tmFabDragBound = "1";
    fabEl.addEventListener("mousedown", (event) => {
      if (event.button !== 0) return;
      event.preventDefault();

      const rect = fabEl.getBoundingClientRect();
      const startX = event.clientX;
      const startY = event.clientY;
      const startLeft = rect.left;
      const startTop = rect.top;
      let moved = false;
      const prevUserSelect = document.body.style.userSelect;
      document.body.style.userSelect = "none";

      const onMove = (moveEvent) => {
        const dx = moveEvent.clientX - startX;
        const dy = moveEvent.clientY - startY;
        if (!moved && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
          moved = true;
        }
        const left = Math.round(startLeft + dx);
        const top = Math.round(startTop + dy);
        fabEl.style.left = `${left}px`;
        fabEl.style.top = `${top}px`;
      };

      const onUp = () => {
        document.body.style.userSelect = prevUserSelect;
        window.removeEventListener("mousemove", onMove);
        window.removeEventListener("mouseup", onUp);
        if (!moved) return;

        chatContextFabJustDragged = true;
        window.setTimeout(() => {
          chatContextFabJustDragged = false;
        }, 90);

        const left = Number.parseInt(fabEl.style.left, 10);
        const top = Number.parseInt(fabEl.style.top, 10);
        if (Number.isFinite(left) && Number.isFinite(top)) {
          state.chatContextFabPos = { x: left, y: top };
          persistState();
        }
      };

      window.addEventListener("mousemove", onMove);
      window.addEventListener("mouseup", onUp);
    });
  }

  function isPointNearBorder(clientX, clientY, rect, edge = 16) {
    const nearLeft = clientX - rect.left <= edge;
    const nearRight = rect.right - clientX <= edge;
    const nearTop = clientY - rect.top <= edge;
    const nearBottom = rect.bottom - clientY <= edge;
    return nearLeft || nearRight || nearTop || nearBottom;
  }

  function bindChatContextDrag(wrapEl) {
    if (!wrapEl || wrapEl.dataset.tmDragBound) return;
    wrapEl.dataset.tmDragBound = "1";
    const dragEdge = 24;
    const resizeEdge = 6;

    wrapEl.addEventListener("dblclick", (event) => {
      const rect = wrapEl.getBoundingClientRect();
      if (!isPointNearBorder(event.clientX, event.clientY, rect, dragEdge)) return;
      setChatContextMinimized(true, true);
      event.preventDefault();
    });

    wrapEl.addEventListener("mousedown", (event) => {
      if (event.button !== 0) return;
      const rect = wrapEl.getBoundingClientRect();
      if (!isPointNearBorder(event.clientX, event.clientY, rect, dragEdge)) return;

      const resizeDirection = getChatContextResizeDirection(
        event.clientX,
        event.clientY,
        rect,
        resizeEdge
      );
      if (resizeDirection) return;

      const startX = event.clientX;
      const startY = event.clientY;
      const startLeft = rect.left;
      const startTop = rect.top;
      const prevUserSelect = document.body.style.userSelect;
      document.body.style.userSelect = "none";

      const onMove = (moveEvent) => {
        const next = {
          ...(state.chatContextLayout || getDefaultChatContextLayout()),
          x: startLeft + (moveEvent.clientX - startX),
          y: startTop + (moveEvent.clientY - startY),
        };
        setChatContextLayout(next, false);
      };

      const onUp = () => {
        document.body.style.userSelect = prevUserSelect;
        window.removeEventListener("mousemove", onMove);
        window.removeEventListener("mouseup", onUp);
        persistState();
      };

      window.addEventListener("mousemove", onMove);
      window.addEventListener("mouseup", onUp);
      event.preventDefault();
      event.stopPropagation();
    }, true);
  }

  function getChatContextResizeDirection(clientX, clientY, rect, edge = 16) {
    const nearLeft = clientX - rect.left <= edge;
    const nearRight = rect.right - clientX <= edge;
    const nearTop = clientY - rect.top <= edge;
    const nearBottom = rect.bottom - clientY <= edge;

    if (nearTop && nearLeft) return "nw";
    if (nearTop && nearRight) return "ne";
    if (nearBottom && nearLeft) return "sw";
    if (nearBottom && nearRight) return "se";
    if (nearTop) return "n";
    if (nearBottom) return "s";
    if (nearLeft) return "w";
    if (nearRight) return "e";
    return "";
  }

  function resizeCursorForDirection(direction) {
    if (!direction) return "default";
    if (direction === "n" || direction === "s") return "ns-resize";
    if (direction === "e" || direction === "w") return "ew-resize";
    if (direction === "ne" || direction === "sw") return "nesw-resize";
    if (direction === "nw" || direction === "se") return "nwse-resize";
    return "default";
  }

  function setChatContextResizeCursor(wrapEl, cursor) {
    if (wrapEl) wrapEl.style.cursor = cursor;
    if (ui.chatContextTextarea) {
      ui.chatContextTextarea.style.cursor = cursor === "default" ? "text" : cursor;
    }
  }

  function bindChatContextWindowLikeResize(wrapEl) {
    if (!wrapEl || wrapEl.dataset.tmWindowResizeBound) return;
    wrapEl.dataset.tmWindowResizeBound = "1";

    const resizeEdge = 6;
    const dragEdge = 24;
    let resizing = false;

    wrapEl.addEventListener("mousemove", (event) => {
      if (resizing) return;
      const rect = wrapEl.getBoundingClientRect();
      const direction = getChatContextResizeDirection(
        event.clientX,
        event.clientY,
        rect,
        resizeEdge
      );
      if (direction) {
        setChatContextResizeCursor(wrapEl, resizeCursorForDirection(direction));
      } else if (isPointNearBorder(event.clientX, event.clientY, rect, dragEdge)) {
        setChatContextResizeCursor(wrapEl, "move");
      } else {
        setChatContextResizeCursor(wrapEl, "default");
      }
    });

    wrapEl.addEventListener("mouseleave", () => {
      if (!resizing) setChatContextResizeCursor(wrapEl, "default");
    });

    wrapEl.addEventListener(
      "mousedown",
      (event) => {
        if (event.button !== 0) return;

        const rect = wrapEl.getBoundingClientRect();
        const direction = getChatContextResizeDirection(
          event.clientX,
          event.clientY,
          rect,
          resizeEdge
        );
        if (!direction) return;

        event.preventDefault();
        event.stopImmediatePropagation();
        resizing = true;
        setChatContextResizeCursor(wrapEl, resizeCursorForDirection(direction));
        const startX = event.clientX;
        const startY = event.clientY;
        const startLayout = state.chatContextLayout || getDefaultChatContextLayout();
        const base = {
          x: startLayout.x,
          y: startLayout.y,
          width: startLayout.width,
          height: startLayout.height,
          borderRadius: startLayout.borderRadius,
        };
        const prevUserSelect = document.body.style.userSelect;
        document.body.style.userSelect = "none";

        const onMove = (moveEvent) => {
          const dx = moveEvent.clientX - startX;
          const dy = moveEvent.clientY - startY;
          const next = { ...base };

          if (direction.includes("e")) next.width = base.width + dx;
          if (direction.includes("s")) next.height = base.height + dy;
          if (direction.includes("w")) {
            next.width = base.width - dx;
            next.x = base.x + dx;
          }
          if (direction.includes("n")) {
            next.height = base.height - dy;
            next.y = base.y + dy;
          }

          setChatContextLayout(next, false);
        };

        const onUp = () => {
          resizing = false;
          document.body.style.userSelect = prevUserSelect;
          window.removeEventListener("mousemove", onMove);
          window.removeEventListener("mouseup", onUp);
          persistState();
          setChatContextResizeCursor(wrapEl, "default");
        };

        window.addEventListener("mousemove", onMove);
        window.addEventListener("mouseup", onUp);
        event.stopPropagation();
      },
      true
    );
  }

  function bindChatContextResizeObserver(wrapEl) {
    if (!wrapEl) return;
    if (!chatContextResizeObserver) {
      chatContextResizeObserver = new ResizeObserver((entries) => {
        if (chatContextApplyingLayout) return;
        const entry = entries[0];
        if (!entry) return;
        const rect = entry.contentRect;
        const next = {
          ...(state.chatContextLayout || getDefaultChatContextLayout()),
          width: Math.round(rect.width),
          height: Math.round(rect.height),
        };
        setChatContextLayout(next, false);
        if (chatContextResizePersistTimer) {
          clearTimeout(chatContextResizePersistTimer);
        }
        chatContextResizePersistTimer = window.setTimeout(() => {
          persistState();
        }, 180);
      });
    }
    if (chatContextResizeObservedEl === wrapEl) return;
    if (chatContextResizeObservedEl) {
      chatContextResizeObserver.unobserve(chatContextResizeObservedEl);
    }
    chatContextResizeObserver.observe(wrapEl);
    chatContextResizeObservedEl = wrapEl;
  }

  function bindChatContextWindowResize() {
    if (chatContextWindowResizeBound) return;
    chatContextWindowResizeBound = true;
    window.addEventListener("resize", () => {
      if (!ui.chatContextWrap) return;
      setChatContextLayout(state.chatContextLayout || getDefaultChatContextLayout(), true);
    });
  }

  function nextReqId() {
    state.reqid += 100000;
    return state.reqid;
  }

  function extractByRegex(text, regex) {
    const m = text.match(regex);
    return m ? m[1] : "";
  }

  function isHttpOk(status) {
    return status >= 200 && status < 300;
  }

  function makeHttpError(message, status = 0, body = "") {
    const err = new Error(message);
    err.status = status;
    err.body = body;
    return err;
  }

  function httpRequest({ method, url, headers = {}, data = "", timeoutMs = 30000 }) {
    if (typeof GM_xmlhttpRequest === "function") {
      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method,
          url,
          headers,
          data,
          timeout: timeoutMs,
          responseType: "text",
          withCredentials: true,
          anonymous: false,
          onload: (resp) => resolve(resp),
          ontimeout: () => reject(makeHttpError(`请求超时: ${url}`)),
          onerror: () => reject(makeHttpError(`请求失败: ${url}`)),
          onabort: () => reject(makeHttpError(`请求中断: ${url}`)),
        });
      });
    }

    return fetch(url, {
      method,
      credentials: "include",
      headers,
      body: data || undefined,
    }).then(async (resp) => ({
      status: resp.status,
      responseText: await resp.text(),
      finalUrl: resp.url,
    }));
  }

  function parsePageTokensFromHtml(html) {
    const csrfToken = extractByRegex(html, /"SNlM0e"\s*:\s*"([^"]+)"/);
    const sessionId =
      extractByRegex(html, /"FdrFJe"\s*:\s*"([^"]+)"/) ||
      extractByRegex(html, /"f\.sid"\s*:\s*"([^"]+)"/);
    const bl =
      extractByRegex(html, /(boq_labs-tailwind-frontend_[0-9.]+_p\d+)/) || DEFAULT_BL;
    return { csrfToken, sessionId, bl };
  }

  async function getPageTokens(forceRefresh = false) {
    const freshEnough = Date.now() - tokenCache.fetchedAt < TOKEN_CACHE_TTL_MS;
    if (!forceRefresh && tokenCache.sessionId && freshEnough) {
      return {
        csrfToken: tokenCache.csrfToken,
        sessionId: tokenCache.sessionId,
        bl: tokenCache.bl || DEFAULT_BL,
      };
    }

    if (location.hostname === "notebooklm.google.com") {
      const localHtml =
        document.documentElement?.outerHTML ||
        document.documentElement?.innerHTML ||
        "";
      const localTokens = parsePageTokensFromHtml(localHtml);
      if (localTokens.sessionId) {
        tokenCache.csrfToken = localTokens.csrfToken || "";
        tokenCache.sessionId = localTokens.sessionId;
        tokenCache.bl = localTokens.bl || DEFAULT_BL;
        tokenCache.fetchedAt = Date.now();
        return {
          csrfToken: tokenCache.csrfToken,
          sessionId: tokenCache.sessionId,
          bl: tokenCache.bl || DEFAULT_BL,
        };
      }
    }

    const resp = await httpRequest({
      method: "GET",
      url: NOTEBOOKLM_HOME_URL,
    });
    const status = Number(resp?.status || 0);
    const html = String(resp?.responseText || "");
    const finalUrl = String(resp?.finalUrl || "").toLowerCase();

    if (!isHttpOk(status)) {
      throw makeHttpError(`加载 NotebookLM 页面失败: HTTP ${status}`, status, html);
    }

    const tokens = parsePageTokensFromHtml(html);
    if (tokens.sessionId) {
      tokenCache.csrfToken = tokens.csrfToken || "";
      tokenCache.sessionId = tokens.sessionId;
      tokenCache.bl = tokens.bl || DEFAULT_BL;
      tokenCache.fetchedAt = Date.now();
      return {
        csrfToken: tokenCache.csrfToken,
        sessionId: tokenCache.sessionId,
        bl: tokenCache.bl || DEFAULT_BL,
      };
    }

    const looksLikeGoogleLoginPage =
      finalUrl.includes("accounts.google.com") ||
      /ServiceLogin|signin\/v2\/identifier|identifierId/i.test(html);
    if (looksLikeGoogleLoginPage) {
      throw makeHttpError(
        "NotebookLM 未登录。请先在浏览器打开 https://notebooklm.google.com/ 完成登录。",
        status,
        html
      );
    }
    throw makeHttpError(
      "未读取到 NotebookLM 会话参数(f.sid)。请确认已登录,且浏览器未拦截跨站 Cookie。",
      status,
      html
    );
  }

  function stripAntiXssi(text) {
    return text.replace(/^\)\]\}'\r?\n/, "");
  }

  function parseChunkedResponse(raw) {
    const cleaned = stripAntiXssi(raw).trim();
    if (!cleaned) return [];
    const lines = cleaned.split("\n");
    const chunks = [];
    let i = 0;
    while (i < lines.length) {
      const line = lines[i].trim();
      if (!line) {
        i += 1;
        continue;
      }
      if (/^\d+$/.test(line)) {
        i += 1;
        if (i < lines.length) {
          try {
            chunks.push(JSON.parse(lines[i]));
          } catch (_) {
            // ignore malformed chunk
          }
        }
        i += 1;
      } else {
        try {
          chunks.push(JSON.parse(line));
        } catch (_) {
          // ignore malformed line
        }
        i += 1;
      }
    }
    return chunks;
  }

  function extractRpcResult(chunks, rpcId) {
    for (const chunk of chunks) {
      if (!Array.isArray(chunk)) continue;
      const items = chunk.length > 0 && Array.isArray(chunk[0]) ? chunk : [chunk];
      for (const item of items) {
        if (!Array.isArray(item) || item.length < 3) continue;
        if (item[0] === "er" && item[1] === rpcId) {
          throw new Error(`RPC 错误: ${item[2] ?? "unknown"}`);
        }
        if (item[0] === "wrb.fr" && item[1] === rpcId) {
          const payload = item[2];
          if (typeof payload === "string") {
            try {
              return JSON.parse(payload);
            } catch (_) {
              return payload;
            }
          }
          return payload;
        }
      }
    }
    return null;
  }

  async function rpcCall(rpcId, params, sourcePath = "/") {
    async function runWithTokens(forceRefresh) {
      const { csrfToken, sessionId } = await getPageTokens(forceRefresh);
      const query = new URLSearchParams({
        rpcids: rpcId,
        "source-path": sourcePath,
        "f.sid": sessionId,
        rt: "c",
      });

      const rpcRequest = [[[rpcId, JSON.stringify(params), null, "generic"]]];
      const bodyParts = [`f.req=${encodeURIComponent(JSON.stringify(rpcRequest))}`];
      if (csrfToken) bodyParts.push(`at=${encodeURIComponent(csrfToken)}`);
      const body = `${bodyParts.join("&")}&`;

      const resp = await httpRequest({
        method: "POST",
        url: `${BATCHEXECUTE_URL}?${query.toString()}`,
        headers: {
          "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
          "x-same-domain": "1",
        },
        data: body,
      });

      const status = Number(resp?.status || 0);
      const text = String(resp?.responseText || "");
      if (!isHttpOk(status)) {
        throw makeHttpError(`RPC HTTP ${status}`, status, text);
      }

      const chunks = parseChunkedResponse(text);
      const result = extractRpcResult(chunks, rpcId);
      if (result === null || result === undefined) {
        throw makeHttpError(`RPC ${rpcId} 返回空数据`, status, text);
      }
      return result;
    }

    try {
      return await runWithTokens(false);
    } catch (err) {
      const status = Number(err?.status || 0);
      if (![400, 401, 403].includes(status)) throw err;
      return await runWithTokens(true);
    }
  }

  function parseNotebook(raw) {
    if (!Array.isArray(raw)) return null;
    const id = typeof raw[2] === "string" ? raw[2] : "";
    if (!id) return null;
    const rawTitle = typeof raw[0] === "string" ? raw[0] : "";
    const title = rawTitle.replace("thought\n", "").trim() || "(未命名笔记本)";
    return { id, title };
  }

  function parseSource(raw) {
    if (!Array.isArray(raw) || raw.length === 0) return null;
    const sourceId = Array.isArray(raw[0]) ? raw[0][0] : raw[0];
    if (typeof sourceId !== "string" || !sourceId) return null;
    const title = typeof raw[1] === "string" && raw[1].trim() ? raw[1] : "未命名来源";
    return { id: sourceId, title };
  }

  async function fetchSourcesForNotebook(notebookId) {
    const result = await rpcCall(
      RPC.GET_NOTEBOOK,
      [notebookId, null, [2], null, 0],
      `/notebook/${notebookId}`
    );
    let rawSources = [];
    if (
      Array.isArray(result) &&
      Array.isArray(result[0]) &&
      Array.isArray(result[0][1])
    ) {
      rawSources = result[0][1];
    }
    return rawSources.map(parseSource).filter(Boolean);
  }

  async function loadNotebooks() {
    const result = await rpcCall(RPC.LIST_NOTEBOOKS, [null, 1, null, [2]], "/");
    const rawList = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
    const notebooks = Array.isArray(rawList) ? rawList.map(parseNotebook).filter(Boolean) : [];
    state.notebooks = notebooks;
    renderNotebookOptions();
  }

  async function loadSources(notebookId) {
    state.sources = await fetchSourcesForNotebook(notebookId);

    const selected = new Set(state.sourceSelectionByNotebook[notebookId] || []);
    const availableIds = new Set(state.sources.map((s) => s.id));
    const filtered = [...selected].filter((id) => availableIds.has(id));
    const effective = filtered.length > 0 ? filtered : state.sources.map((s) => s.id);
    state.sourceSelectionByNotebook[notebookId] = effective;
    persistState();
    renderSources();
  }

  async function validateSelectedSourceIds(notebookId, selectedSourceIds) {
    const liveSources = await fetchSourcesForNotebook(notebookId);
    state.sources = liveSources;

    const availableIds = new Set(liveSources.map((s) => s.id));
    const filtered = (selectedSourceIds || []).filter((id) => availableIds.has(id));
    state.sourceSelectionByNotebook[notebookId] = filtered;
    persistState();
    renderSources();
    return filtered;
  }

  function renderNotebookOptions() {
    ui.notebookSelect.replaceChildren();
    if (!state.notebooks.length) {
      const option = document.createElement("option");
      option.value = "";
      option.textContent = "没有可用笔记本";
      ui.notebookSelect.appendChild(option);
      ui.notebookSelect.value = "";
      state.selectedNotebookId = "";
      return;
    }

    for (const nb of state.notebooks) {
      const option = document.createElement("option");
      option.value = nb.id;
      option.textContent = nb.title;
      ui.notebookSelect.appendChild(option);
    }

    const exists = state.notebooks.some((nb) => nb.id === state.selectedNotebookId);
    if (!exists) state.selectedNotebookId = state.notebooks[0].id;
    ui.notebookSelect.value = state.selectedNotebookId;
  }

  function renderSources() {
    const notebookId = state.selectedNotebookId;
    const selected = new Set(state.sourceSelectionByNotebook[notebookId] || []);
    ui.sourceList.replaceChildren();

    for (const src of state.sources) {
      const item = document.createElement("label");
      item.className = "tm-nblm-source-item";

      const cb = document.createElement("input");
      cb.type = "checkbox";
      cb.value = src.id;
      cb.checked = selected.has(src.id);
      cb.addEventListener("change", () => {
        const set = new Set(state.sourceSelectionByNotebook[notebookId] || []);
        if (cb.checked) set.add(src.id);
        else set.delete(src.id);
        state.sourceSelectionByNotebook[notebookId] = [...set];
        persistState();
        updateSourceCount();
      });

      const text = document.createElement("span");
      text.className = "tm-nblm-source-text";
      const title = document.createElement("span");
      title.className = "tm-nblm-source-title";
      title.textContent = src.title;
      const sid = document.createElement("span");
      sid.className = "tm-nblm-source-id";
      sid.textContent = src.id;
      text.appendChild(title);
      text.appendChild(sid);

      item.appendChild(cb);
      item.appendChild(text);
      ui.sourceList.appendChild(item);
    }

    updateSourceCount();
  }

  function updateSourceCount() {
    const notebookId = state.selectedNotebookId;
    const selected = state.sourceSelectionByNotebook[notebookId] || [];
    ui.sourceCount.textContent = `来源: 已选 ${selected.length} / 总计 ${state.sources.length}`;
  }

  function setAllSourceSelection(selectAll) {
    const notebookId = state.selectedNotebookId;
    if (!notebookId) return;
    state.sourceSelectionByNotebook[notebookId] = selectAll
      ? state.sources.map((s) => s.id)
      : [];
    persistState();
    renderSources();
  }

  function buildConversationHistory(conversationId) {
    const turns = state.historyByConversation[conversationId];
    if (!Array.isArray(turns) || turns.length === 0) return null;
    const history = [];
    for (const turn of turns) {
      history.push([turn.answer, null, 2]);
      history.push([turn.query, null, 1]);
    }
    return history;
  }

  async function askNotebook({ notebookId, question, sourceIds, forceNewConversation }) {
    const sourceArray = sourceIds.map((id) => [[id]]);

    let conversationId = null;
    if (!forceNewConversation) {
      conversationId = state.conversationByNotebook[notebookId] || null;
    }
    if (!conversationId) conversationId = crypto.randomUUID();

    const conversationHistory =
      forceNewConversation || !state.historyByConversation[conversationId]
        ? null
        : buildConversationHistory(conversationId);

    async function runWithTokens(forceRefresh) {
      const { csrfToken, sessionId, bl } = await getPageTokens(forceRefresh);
      const params = [
        sourceArray,
        question,
        conversationHistory,
        [2, null, [1]],
        conversationId,
      ];
      const fReq = JSON.stringify([null, JSON.stringify(params)]);
      const bodyParts = [`f.req=${encodeURIComponent(fReq)}`];
      if (csrfToken) bodyParts.push(`at=${encodeURIComponent(csrfToken)}`);
      const body = `${bodyParts.join("&")}&`;

      const query = new URLSearchParams({
        hl: "en",
        _reqid: String(nextReqId()),
        rt: "c",
        "f.sid": sessionId,
        bl: bl || DEFAULT_BL,
      });

      const resp = await httpRequest({
        method: "POST",
        url: `${QUERY_URL}?${query.toString()}`,
        headers: {
          "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
          "x-same-domain": "1",
        },
        data: body,
      });

      const status = Number(resp?.status || 0);
      const rawText = String(resp?.responseText || "");
      if (!isHttpOk(status)) {
        throw makeHttpError(`提问请求失败: HTTP ${status}`, status, rawText);
      }

      const answer = parseAskResponse(rawText);
      return { answer, conversationId, rawText };
    }

    try {
      return await runWithTokens(false);
    } catch (err) {
      const status = Number(err?.status || 0);
      if (![400, 401, 403].includes(status)) throw err;
      return await runWithTokens(true);
    }
  }

  function isLikelyNotebookThinkingText(text) {
    const normalized = String(text || "").trim().toLowerCase();
    if (!normalized) return false;
    if (
      /^\*\*(synthesizing|analyzing|integrating|reviewing|planning|drafting)\b/.test(
        normalized
      )
    ) {
      return true;
    }
    if (
      /^i'?m now (integrating|synthesizing|analyzing|reviewing|combining)\b/.test(
        normalized
      ) ||
      /^let me (integrate|synthesize|analyze|review|combine)\b/.test(normalized)
    ) {
      return true;
    }
    return false;
  }

  function parseAskResponse(rawText) {
    const cleaned = stripAntiXssi(rawText);
    const lines = cleaned.trim().split("\n");
    let lastAnswerAnyText = "";
    let lastAnswerNonThinkingText = "";

    function processChunk(jsonStr) {
      let data;
      try {
        data = JSON.parse(jsonStr);
      } catch (_) {
        return;
      }
      if (!Array.isArray(data)) return;

      for (const item of data) {
        if (!Array.isArray(item) || item.length < 3 || item[0] !== "wrb.fr") continue;
        if (typeof item[2] !== "string") continue;

        let inner;
        try {
          inner = JSON.parse(item[2]);
        } catch (_) {
          continue;
        }
        if (!Array.isArray(inner) || !Array.isArray(inner[0])) continue;

        const first = inner[0];
        const text = typeof first[0] === "string" ? first[0] : "";
        if (!text) continue;

        let isAnswer = false;
        if (text.length > 20 && Array.isArray(first[4])) {
          const tail = first[4][first[4].length - 1];
          if (tail === 1) isAnswer = true;
        }
        if (!isAnswer) continue;

        const isThinking = isLikelyNotebookThinkingText(text);
        lastAnswerAnyText = text;
        if (!isThinking) {
          lastAnswerNonThinkingText = text;
        }
      }
    }

    let i = 0;
    while (i < lines.length) {
      const line = lines[i].trim();
      if (!line) {
        i += 1;
        continue;
      }
      if (/^\d+$/.test(line)) {
        i += 1;
        if (i < lines.length) processChunk(lines[i]);
        i += 1;
      } else {
        processChunk(line);
        i += 1;
      }
    }
    return lastAnswerNonThinkingText || lastAnswerAnyText || "(no answer text extracted)";
  }

  function isChatGPTPage() {
    const host = location.hostname;
    return host === "chat.openai.com" || host === "chatgpt.com" || host.endsWith(".chatgpt.com");
  }

  function isElementVisible(el) {
    if (!el) return false;
    const style = window.getComputedStyle(el);
    return style.display !== "none" && style.visibility !== "hidden";
  }

  function readInputText(inputEl) {
    if (
      inputEl instanceof HTMLTextAreaElement ||
      inputEl instanceof HTMLInputElement
    ) {
      return inputEl.value || "";
    }
    return inputEl.innerText || inputEl.textContent || "";
  }

  function findChatInputElement() {
    const selectors = [
      "#prompt-textarea",
      'textarea[data-testid="prompt-textarea"]',
      'div[data-testid="prompt-textarea"]',
      'div[contenteditable="true"][id="prompt-textarea"]',
      'div[contenteditable="true"]',
    ];

    for (const selector of selectors) {
      const nodes = document.querySelectorAll(selector);
      for (const node of nodes) {
        if (!isElementVisible(node)) continue;
        if (selector === 'div[contenteditable="true"]' && !node.closest("form")) continue;
        return node;
      }
    }
    return null;
  }

  function findChatInputElementInForm(formEl) {
    if (!(formEl instanceof HTMLFormElement)) return null;

    const selectors = [
      "#prompt-textarea",
      'textarea[data-testid="prompt-textarea"]',
      'div[data-testid="prompt-textarea"]',
      'div[contenteditable="true"][id="prompt-textarea"]',
      'div[contenteditable="true"]',
    ];

    for (const selector of selectors) {
      const nodes = formEl.querySelectorAll(selector);
      for (const node of nodes) {
        if (!isElementVisible(node)) continue;
        return node;
      }
    }
    return null;
  }

  function ensureChatContextDock() {
    if (!isChatGPTPage()) return null;
    let wrap = document.getElementById("tm-nblm-chat-context-wrap");
    let textarea = wrap?.querySelector("#tm-nblm-chat-context") || null;
    let fab = document.getElementById("tm-nblm-chat-context-fab");

    if (!wrap) {
      const inputEl = findChatInputElement();
      if (!inputEl) return null;

      wrap = document.createElement("div");
      wrap.id = "tm-nblm-chat-context-wrap";
    }
    if (!textarea) {
      textarea = document.createElement("textarea");
      textarea.id = "tm-nblm-chat-context";
      textarea.readOnly = true;
      textarea.placeholder = "NotebookLM 查询结果将显示在这里";
      wrap.appendChild(textarea);
    }

    const oldDragBar = wrap.querySelector("#tm-nblm-chat-context-drag");
    if (oldDragBar) {
      oldDragBar.remove();
    }

    if (!fab) {
      fab = document.createElement("button");
      fab.id = "tm-nblm-chat-context-fab";
      fab.type = "button";
      fab.textContent = "N";
      fab.title = "打开 NotebookLM 背景框";
      fab.addEventListener("click", (event) => {
        if (chatContextFabJustDragged) {
          event.preventDefault();
          event.stopPropagation();
          return;
        }
        if (state.chatContextFabPos) {
          const next = { ...(state.chatContextLayout || getDefaultChatContextLayout()) };
          const x = Number(state.chatContextFabPos.x);
          const y = Number(state.chatContextFabPos.y);
          if (Number.isFinite(x)) next.x = x;
          if (Number.isFinite(y)) next.y = y;
          setChatContextLayout(next, false);
        }
        setChatContextMinimized(false, true);
      });
    }

    if (wrap.parentElement !== document.body) {
      document.body.appendChild(wrap);
    }
    if (fab.parentElement !== document.body) {
      document.body.appendChild(fab);
    }

    ui.chatContextWrap = wrap;
    ui.chatContextFab = fab;
    bindChatContextFabDrag(fab);
    if (!state.chatContextLayout) {
      state.chatContextLayout = getDefaultChatContextLayout();
    }
    setChatContextLayout(state.chatContextLayout, false);

    if (textarea) {
      if (textarea.value !== state.chatContextText) {
        textarea.value = state.chatContextText || "";
      }
    }

    bindChatContextWindowLikeResize(wrap);
    bindChatContextDrag(wrap);
    bindChatContextWindowResize();
    setChatContextMinimized(state.chatContextMinimized, false);

    ui.chatContextTextarea = textarea;
    return wrap;
  }

  function setChatContextText(text) {
    state.chatContextText = text || "";
    const wrap = ensureChatContextDock();
    if (!wrap || !ui.chatContextTextarea) return false;
    if (ui.chatContextTextarea.value !== state.chatContextText) {
      ui.chatContextTextarea.value = state.chatContextText;
    }
    persistState();
    return true;
  }

  function getChatContextText() {
    return (ui.chatContextTextarea?.value ?? state.chatContextText ?? "").trim();
  }

  function setInputText(inputEl, text) {
    if (
      inputEl instanceof HTMLTextAreaElement ||
      inputEl instanceof HTMLInputElement
    ) {
      const proto = Object.getPrototypeOf(inputEl);
      const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
      if (descriptor?.set) descriptor.set.call(inputEl, text);
      else inputEl.value = text;
      inputEl.dispatchEvent(new Event("input", { bubbles: true }));
      inputEl.dispatchEvent(new Event("change", { bubbles: true }));
      inputEl.focus();
      inputEl.setSelectionRange?.(text.length, text.length);
      return;
    }

    inputEl.focus();
    const selection = window.getSelection();
    if (selection) {
      const range = document.createRange();
      range.selectNodeContents(inputEl);
      selection.removeAllRanges();
      selection.addRange(range);
    }

    let inserted = false;
    try {
      inserted = document.execCommand("insertText", false, text);
    } catch (_) {
      inserted = false;
    }
    if (!inserted) {
      inputEl.textContent = text;
    }
    inputEl.dispatchEvent(
      new InputEvent("input", {
        bubbles: true,
        cancelable: true,
        data: text,
        inputType: "insertText",
      })
    );
    inputEl.dispatchEvent(new Event("change", { bubbles: true }));
  }

  function buildNotebookQuestion(questionText) {
    const finalQuestion = String(questionText || "").trim();
    if (!finalQuestion) return "";

    const systemPrompt = String(state.notebookSystemPrompt || "").trim();
    if (!systemPrompt) return finalQuestion;

    return [
      "请优先遵循以下系统提示词,再回答用户问题:",
      "",
      "【系统提示词】",
      systemPrompt,
      "",
      "【用户问题】",
      finalQuestion,
    ].join("\n");
  }

  function renderMergePromptTemplate(template, data) {
    const rawTemplate = String(template || "").trim();
    if (!rawTemplate) {
      return String(data?.answer || "").trim();
    }
    return rawTemplate.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
      const value = data && Object.prototype.hasOwnProperty.call(data, key) ? data[key] : "";
      return String(value ?? "");
    });
  }

  function buildMergedPrompt(contextText, questionText) {
    return [
      CONTEXT_BLOCK_TITLE,
      contextText,
      "",
      QUESTION_BLOCK_TITLE,
      questionText
    ].join("\n");
  }

  function isMergedPrompt(text) {
    if (!text) return false;
    return text.includes(CONTEXT_BLOCK_TITLE) && text.includes(QUESTION_BLOCK_TITLE);
  }

  function extractQuestionTextFromChatInput(rawText) {
    const text = (rawText || "").trim();
    if (!text) return "";
    if (!isMergedPrompt(text)) return text;

    const idx = text.indexOf(QUESTION_BLOCK_TITLE);
    if (idx < 0) return text;
    return text.slice(idx + QUESTION_BLOCK_TITLE.length).trim();
  }

  function getQuestionFromChatInput() {
    if (!isChatGPTPage()) return "";
    const inputEl = findChatInputElement();
    if (!inputEl) return "";
    return extractQuestionTextFromChatInput(readInputText(inputEl));
  }

  function sleep(ms) {
    return new Promise((resolve) => {
      window.setTimeout(resolve, ms);
    });
  }

  function pickCurrentChatInput(formEl, fallbackInputEl) {
    const candidates = [
      findChatInputElementInForm(formEl),
      findChatInputElement(),
      fallbackInputEl,
    ];
    for (const el of candidates) {
      if (!(el instanceof Element)) continue;
      if (!el.isConnected) continue;
      if (!isElementVisible(el)) continue;
      return el;
    }
    return null;
  }

  function isMergedPromptAppliedOnInput(inputEl, expectedQuestionText = "") {
    if (!(inputEl instanceof Element)) return false;
    const raw = readInputText(inputEl);
    if (!isMergedPrompt(raw)) return false;

    const expected = String(expectedQuestionText || "").trim();
    if (!expected) return true;
    const extracted = extractQuestionTextFromChatInput(raw);
    return extracted === expected;
  }

  async function applyMergedPromptWithRetry(
    formEl,
    fallbackInputEl,
    mergedText,
    expectedQuestionText
  ) {
    const totalAttempts = 4;
    for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
      const inputEl = pickCurrentChatInput(formEl, fallbackInputEl);
      if (!inputEl) {
        await sleep(80);
        continue;
      }

      setInputText(inputEl, mergedText);
      await sleep(attempt === 0 ? 80 : 140);

      const latestInput = pickCurrentChatInput(formEl, inputEl) || inputEl;
      if (isMergedPromptAppliedOnInput(latestInput, expectedQuestionText)) {
        return { ok: true, inputEl: latestInput };
      }
    }

    return {
      ok: false,
      inputEl: pickCurrentChatInput(formEl, fallbackInputEl),
    };
  }

  function rememberAskResult({
    notebookId,
    sourceIds,
    question,
    answer,
    conversationId,
    rawResponse,
  }) {
    const notebook = state.notebooks.find((n) => n.id === notebookId);

    state.conversationByNotebook[notebookId] = conversationId;
    if (!state.historyByConversation[conversationId]) {
      state.historyByConversation[conversationId] = [];
    }
    state.historyByConversation[conversationId].push({
      query: question,
      answer,
    });

    state.lastResult = {
      timestamp: new Date().toISOString(),
      notebookId,
      notebookTitle: notebook?.title || "",
      sourceIds,
      question,
      answer,
      conversationId,
      rawResponse,
    };
    state.lastResult.chatgptPrompt = buildChatGPTContextPrompt(state.lastResult);
    return state.lastResult;
  }

  function submitChatForm(formEl) {
    if (!(formEl instanceof HTMLFormElement)) return;
    if (typeof formEl.requestSubmit === "function") {
      formEl.requestSubmit();
      return;
    }
    const submitBtn = formEl.querySelector('button[type="submit"]');
    if (submitBtn instanceof HTMLButtonElement) {
      submitBtn.click();
      return;
    }
    formEl.submit();
  }

  async function autoAskNotebookForChatSubmit(formEl, inputEl, questionText) {
    const notebookId = state.selectedNotebookId;
    let sourceIds = notebookId ? state.sourceSelectionByNotebook[notebookId] || [] : [];

    if (!notebookId || !questionText) {
      if (!notebookId) setStatus("请先选择笔记本");
      else if (!questionText) setStatus("请先在 ChatGPT 输入框中输入问题");
      return;
    }

    chatAutoAskInFlight = true;
    setAskSendFloatingLoading(true);
    setBusy(true);
    setStatus("正在自动向 NotebookLM 提问...");

    try {
      sourceIds = await validateSelectedSourceIds(notebookId, sourceIds);
      if (!sourceIds.length) {
        setStatus("已移除失效来源,请至少选择一个来源");
        return;
      }

      const notebookQuestion = buildNotebookQuestion(questionText);
      const result = await askNotebook({
        notebookId,
        question: notebookQuestion,
        sourceIds,
        forceNewConversation: ui.newConversationCheckbox.checked,
      });

      rememberAskResult({
        notebookId,
        sourceIds,
        question: questionText,
        answer: result.answer,
        conversationId: result.conversationId,
        rawResponse: result.rawText,
      });

      const injected = injectPromptToChatInput(state.lastResult.chatgptPrompt);
      const finalContext = getChatContextText() || state.lastResult.chatgptPrompt || "";
      const merged = buildMergedPrompt(finalContext, questionText);
      const mergeResult = await applyMergedPromptWithRetry(
        formEl,
        inputEl,
        merged,
        questionText
      );

      if (!mergeResult.ok) {
        setStatus("自动提问完成,但拼接未生效,已取消发送");
        return;
      }

      await sleep(120);
      const submitForm = mergeResult.inputEl?.closest("form") || formEl;
      submitChatForm(submitForm);
      setStatus(injected.ok ? "自动提问完成,已拼接背景并发送" : `自动提问完成,但${injected.message}`);
    } catch (err) {
      console.error("[TM NotebookLM]", err);
      setStatus(`自动提问失败: ${err.message || err}`);
    } finally {
      chatAutoAskInFlight = false;
      setAskSendFloatingLoading(false);
      setBusy(false);
    }
  }

  function isLikelySendButton(btn) {
    if (!(btn instanceof HTMLButtonElement)) return false;
    if ((btn.getAttribute("type") || "").toLowerCase() === "submit") return true;
    const testid = (btn.getAttribute("data-testid") || "").toLowerCase();
    const aria = (btn.getAttribute("aria-label") || "").toLowerCase();
    const title = (btn.getAttribute("title") || "").toLowerCase();
    if (testid.includes("send")) return true;
    if (aria.includes("send") || aria.includes("发送")) return true;
    if (title.includes("send") || title.includes("发送")) return true;
    return false;
  }

  function startChatContextObserver() {
    if (!isChatGPTPage() || chatContextObserver) return;
    let scheduled = false;
    chatContextObserver = new MutationObserver(() => {
      if (scheduled) return;
      scheduled = true;
      window.setTimeout(() => {
        scheduled = false;

        ensurePanelUI();
        const dockMissing = !ui.chatContextWrap || !ui.chatContextWrap.isConnected;
        const fabMissing = !ui.chatContextFab || !ui.chatContextFab.isConnected;

        ensureAskSendFloatingButton();
        if (dockMissing || fabMissing) {
          ensureChatContextDock();
        }
      }, 250);
    });
    chatContextObserver.observe(document.body, { childList: true, subtree: true });
  }

  function buildChatGPTContextPrompt(result) {
    const template = String(state.mergeInstructionPrompt || "");
    const notebookName = result.notebookTitle || result.notebookId || "";
    let rendered = renderMergePromptTemplate(template, {
      notebook: notebookName,
      notebookTitle: result.notebookTitle || "",
      notebookId: result.notebookId || "",
      question: result.question || "",
      answer: result.answer || "",
      context: result.answer || "",
    }).trim();

    const hasAnswerPlaceholder = /{{\s*(answer|context)\s*}}/.test(template);
    if (!hasAnswerPlaceholder && result.answer) {
      rendered = [rendered, result.answer].filter(Boolean).join("\n\n").trim();
    }

    if (!rendered) return result.answer || "";
    if (rendered.includes("{{") || rendered.includes("}}")) {
      return result.answer || "";
    }
    return rendered;
  }

  function injectPromptToChatInput(promptText) {
    if (!isChatGPTPage()) {
      return { ok: false, message: "当前页面不是 ChatGPT,未写入背景框" };
    }

    const ok = setChatContextText(promptText);
    if (!ok) {
      return { ok: false, message: "未找到背景框挂载位置" };
    }
    return { ok: true, message: "已写入背景框,发送时会自动拼接到问题" };
  }

  async function handleAsk() {
    const notebookId = state.selectedNotebookId;
    const question = getQuestionFromChatInput();
    const notebookQuestion = buildNotebookQuestion(question);
    if (!notebookId) {
      setStatus("请先选择笔记本");
      return;
    }
    if (!question) {
      setStatus("请先在 ChatGPT 输入框中输入问题");
      return;
    }

    let sourceIds = state.sourceSelectionByNotebook[notebookId] || [];

    setBusy(true);
    setStatus("正在提问...");

    try {
      sourceIds = await validateSelectedSourceIds(notebookId, sourceIds);
      if (!sourceIds.length) {
        setStatus("已移除失效来源,请至少选择一个来源");
        return;
      }

      const result = await askNotebook({
        notebookId,
        question: notebookQuestion,
        sourceIds,
        forceNewConversation: ui.newConversationCheckbox.checked,
      });

      rememberAskResult({
        notebookId,
        sourceIds,
        question,
        answer: result.answer,
        conversationId: result.conversationId,
        rawResponse: result.rawText,
      });
      const statusParts = ["提问完成"];
      const injected = injectPromptToChatInput(state.lastResult.chatgptPrompt);
      if (injected.ok) statusParts.push("已写入背景框");
      else statusParts.push(injected.message);

      setStatus(statusParts.join(","));
    } catch (err) {
      console.error("[TM NotebookLM]", err);
      setStatus(`提问失败: ${err.message || err}`);
    } finally {
      setBusy(false);
    }
  }

  async function onNotebookChanged() {
    const notebookId = state.selectedNotebookId;
    if (!notebookId) {
      state.sources = [];
      renderSources();
      return;
    }
    setStatus("加载来源...");
    try {
      await loadSources(notebookId);
      setStatus("来源已加载");
    } catch (err) {
      console.error("[TM NotebookLM]", err);
      state.sources = [];
      renderSources();
      setStatus(`加载来源失败: ${err.message || err}`);
    }
  }

  async function initializeData(forceRefresh = false) {
    if (forceRefresh) {
      state.notebooks = [];
      state.sources = [];
      renderNotebookOptions();
      renderSources();
    }
    setBusy(true);
    setStatus("加载笔记本...");
    try {
      await loadNotebooks();
      if (!state.selectedNotebookId && state.notebooks.length > 0) {
        state.selectedNotebookId = state.notebooks[0].id;
      }
      ui.notebookSelect.value = state.selectedNotebookId;
      await onNotebookChanged();
      setStatus("准备就绪");
    } catch (err) {
      console.error("[TM NotebookLM]", err);
      setStatus(`初始化失败: ${err.message || err}`);
    } finally {
      setBusy(false);
      persistState();
    }
  }

  function mount() {
    loadPersistedState();
    injectStyles();
    ensurePanelUI();
    if (isChatGPTPage()) {
      ensureAskSendFloatingButton();
      bindAskSendFloatingButtonPosition();
      ensureChatContextDock();
      startChatContextObserver();
    }
    syncPanelVisibility();
    void initializeData(false);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", mount, { once: true });
  } else {
    mount();
  }
})();


// ===== END 2.js =====

// ===== BEGIN 3.js =====

// ==UserScript==
// @name         Thriller
// @namespace    http://tampermonkey.net/merged_tools
// @version      4.5.2_1.2.0_Merged
// @description  Thriller
// @author       Moe
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js
// @run-at       document-idle
// @grant        none
// ==/UserScript==

// ===================== 0. 闂備礁鎲$敮鐐存櫠濡も偓閿曘垽鍩勯崘銊х獮闁哄鐗勯崝宥呪枍韫囨洍鍋撳▓鍨灀闁稿鎹囬弻娑滅疀閺囥劌濮曠紓浣介哺閻熲晛鐣峰Ο琛℃瀻闁逛即娼ф慨銈夋⒑閸濆嫬鏆旈柛搴㈠絻閿曘垽鏁撻悩铏珫?=====================
const THRILLER_EXPIRES_AT = '3036-01-31T00:00:00+09:00';

// 失效后是否弹窗提示(true/false)
const THRILLER_EXPIRE_ALERT = false;

(function thrillerExpiryGuard() {
    function parseExpires(s) {
        if (!s) return NaN;

        // 支持只写日期:'YYYY-MM-DD' -> 默认当天 00:00:00 失效
        // 如果你想“到当天结束才失效”,把 00:00:00 改成 23:59:59.999
        if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
            return new Date(`${s}T00:00:00+09:00`).getTime();
            // return new Date(`${s}T23:59:59.999+09:00`).getTime(); // ← 当天结束才失效
        }

        const t = new Date(s).getTime();
        return t;
    }

    const expiresMs = parseExpires(THRILLER_EXPIRES_AT);
    const expired = Number.isFinite(expiresMs) && Date.now() >= expiresMs;

    if (expired) {
        window.__THRILLER_DISABLED__ = true;

        const msg = `[Thriller] 已到期,脚本已禁用(expiresAt=${THRILLER_EXPIRES_AT})`;
        console.warn(msg);

        if (THRILLER_EXPIRE_ALERT) {
            if (!window.__THRILLER_EXPIRE_ALERTED__) {
                window.__THRILLER_EXPIRE_ALERTED__ = true;
                alert('Thriller 脚本已到期,已自动禁用。');
            }
        }
    } else {
        window.__THRILLER_DISABLED__ = false;
    }
})();











/**
 * ==============================================================================
 * 婵犵妲呴崹顏堝焵椤掑啯鐝柛瀣ㄥ劚閳藉骞橀姘闂備焦瀵х粙鎺楊敄瀹稿獘tGPT 濠电偞鍨堕幐鎾磻閹剧粯鈷戞繛鍡樺劤閺嬫稒銇勯敂钘夌祷閻?(v4.5.2 闂備礁鎲¢崹鐔煎磻閹剧粯鐓曢柡宥冨妿婢а囨煙娓氬灝濡界€垫澘瀚濂稿炊瑜滈弳?
 * 闂備礁鎲″濠氬窗閺囥垹绀傛慨妞诲亾闁轰礁绉撮~婵嬵敆婢跺﹥顔撻梻?JSON闂備焦瀵х粙鎴︽嚐椤栫倛鍥箵閹规娅g槐鎺懳熸导娆戠闂佽娴烽弫鎼併€佹繝鍥ㄥ剨濞寸姴顑嗛弲顒勬倵濞戞凹娓?濠电偞鍨堕幐鎼佸箹椤愩儯浜归柛娑橈功閻熷湱鎲告惔銊ョ;闁规崘绉ぐ鎺濇晩闁芥ê顦弸娆撴煟? * ==============================================================================
 */
(() => {
    'use strict';
    if (window.__THRILLER_DISABLED__) return;
    console.log('>>> [Module 1] Loading: ChatGPT Revive...');

    // ===================== 1. 闂備礁鎼粔鍫曗€﹂崼銏㈢处濡わ絽鍟惌妤呮煕鐏炲墽鈯曟繛?=====================
    // [设置] 是否默认开启遮罩 (true: 开启, false: 关闭) - 代码内开关
    const ENABLE_OVERLAY_DEFAULT = true;
    const SKIPPED_CONTENT_TYPES = new Set(['thoughts', 'reasoning_recap', 'model_editable_context']);

    // ===================== 2. 闂備胶顭堢换鍫ュ礉瀹€鍕剳妞ゆ帒瀚崑鎰版煠閸濄儺鏆柛?=====================
    let loadedMessages = [];
    let lastThreadTitle = '';
    let lastThreadId = 'chat_history';

    // ===================== 3. IndexedDB 闂備浇妗ㄩ懗鑸垫櫠濡も偓閻e灚鎷呯憴鍕妳?=====================
    const IDB_NAME = 'resu_immortal_db';
    const IDB_VER = 2;
    const STORE_THREADS = 'threads';
    const STORE_MSG = 'messages';
    const STORE_SETTINGS = 'settings';
    const KEY_URL_MAP = 'resu_url_mappings';
    const KEY_SESSION_MAP = 'resu_session_mappings';
    let idb = null;
    let dbQueue = Promise.resolve();

    function withDbLock(fn) { dbQueue = dbQueue.then(fn, fn); return dbQueue; }

    function idbReq(req) {
        return new Promise((resolve, reject) => {
            req.onsuccess = () => resolve(req.result);
            req.onerror = () => reject(req.error);
        });
    }
    async function idbOpen() {
        if (idb) return idb;
        idb = await new Promise((resolve, reject) => {
            const req = indexedDB.open(IDB_NAME, IDB_VER);
            req.onerror = () => reject(req.error);
            req.onupgradeneeded = () => {
                const db = req.result;
                if (!db.objectStoreNames.contains(STORE_THREADS)) {
                    const st = db.createObjectStore(STORE_THREADS, { keyPath: 'threadId' });
                    st.createIndex('updatedAt', 'updatedAt', { unique: false });
                }
                if (!db.objectStoreNames.contains(STORE_MSG)) {
                    const sm = db.createObjectStore(STORE_MSG, { keyPath: 'id', autoIncrement: true });
                    sm.createIndex('threadId', 'threadId', { unique: false });
                    sm.createIndex('thread_seq', ['threadId', 'seq'], { unique: true });
                }
                if (!db.objectStoreNames.contains(STORE_SETTINGS)) {
                    db.createObjectStore(STORE_SETTINGS, { keyPath: 'key' });
                }
            };
            req.onsuccess = () => resolve(req.result);
        });
        return idb;
    }
    async function settingsGet(key) {
        const db = await idbOpen();
        const tx = db.transaction([STORE_SETTINGS], 'readonly');
        const st = tx.objectStore(STORE_SETTINGS);
        const row = await idbReq(st.get(key));
        await new Promise((resolve) => { tx.oncomplete = resolve; });
        return row ? row.value : null;
    }
    async function settingsSet(key, value) {
        const db = await idbOpen();
        const tx = db.transaction([STORE_SETTINGS], 'readwrite');
        const st = tx.objectStore(STORE_SETTINGS);
        st.put({ key, value });
        await new Promise((resolve, reject) => {
            tx.oncomplete = resolve;
            tx.onerror = () => reject(tx.error);
            tx.onabort = () => reject(tx.error);
        });
    }
    async function ensureThread(threadId, titleFromImport) {
        const db = await idbOpen();
        const tx = db.transaction([STORE_THREADS], 'readwrite');
        const st = tx.objectStore(STORE_THREADS);
        const now = Date.now();
        const cur = await idbReq(st.get(threadId));
        let next;
        if (cur) {
            const hasTitle = cur.title && String(cur.title).trim();
            next = { ...cur, title: hasTitle ? cur.title : (titleFromImport || threadId), updatedAt: now };
        } else {
            next = { threadId, title: titleFromImport || threadId, createdAt: now, updatedAt: now, nextSeq: 0 };
        }
        st.put(next);
        await new Promise((resolve, reject) => {
            tx.oncomplete = resolve;
            tx.onerror = () => reject(tx.error);
            tx.onabort = () => reject(tx.error);
        });
        return next;
    }
    async function bumpSeq(threadId) {
        const db = await idbOpen();
        const tx = db.transaction([STORE_THREADS], 'readwrite');
        const st = tx.objectStore(STORE_THREADS);
        const thr = await idbReq(st.get(threadId));
        if (!thr) throw new Error('thread not found: ' + threadId);
        const seq = thr.nextSeq || 0;
        thr.nextSeq = seq + 1;
        thr.updatedAt = Date.now();
        st.put(thr);
        await new Promise((resolve, reject) => {
            tx.oncomplete = resolve;
            tx.onerror = () => reject(tx.error);
            tx.onabort = () => reject(tx.error);
        });
        return seq;
    }
    async function addMsg(threadId, role, content, ts, source, extra = null) {
        return withDbLock(async () => {
            const db = await idbOpen();
            await ensureThread(threadId);
            const seq = await bumpSeq(threadId);
            const tx = db.transaction([STORE_MSG], 'readwrite');
            const sm = tx.objectStore(STORE_MSG);
            const payload = {
                threadId, seq, role, content,
                ts: ts || Date.now(),
                source: source || 'live',
            };
            if (extra && typeof extra === 'object') {
                if (typeof extra.sender === 'string' && extra.sender.trim()) payload.sender = extra.sender.trim();
                if (typeof extra.thinking === 'string' && extra.thinking.trim()) payload.thinking = extra.thinking.trim();
            }
            const id = await idbReq(sm.add(payload));
            await new Promise((resolve, reject) => {
                tx.oncomplete = resolve;
                tx.onerror = () => reject(tx.error);
                tx.onabort = () => reject(tx.error);
            });
            return id;
        });
    }
    async function clearThread(threadId) {
        return withDbLock(async () => {
            const db = await idbOpen();
            const tx = db.transaction([STORE_MSG], 'readwrite');
            const sm = tx.objectStore(STORE_MSG);
            const idx = sm.index('threadId');
            await new Promise((resolve, reject) => {
                const range = IDBKeyRange.only(threadId);
                const curReq = idx.openCursor(range);
                curReq.onsuccess = () => {
                    const c = curReq.result;
                    if (!c) return resolve();
                    c.delete();
                    c.continue();
                };
                curReq.onerror = () => reject(curReq.error);
            });
            await new Promise((resolve, reject) => {
                tx.oncomplete = resolve;
                tx.onerror = () => reject(tx.error);
                tx.onabort = () => reject(tx.error);
            });
            const tx2 = db.transaction([STORE_THREADS], 'readwrite');
            const st = tx2.objectStore(STORE_THREADS);
            const thr = await idbReq(st.get(threadId));
            if (thr) {
                thr.nextSeq = 0;
                thr.updatedAt = Date.now();
                st.put(thr);
            }
            await new Promise((resolve, reject) => {
                tx2.oncomplete = resolve;
                tx2.onerror = () => reject(tx2.error);
                tx2.onabort = () => reject(tx2.error);
            });
        });
    }
    async function getFullThreadMessages(threadId) {
        const db = await idbOpen();
        const tx = db.transaction([STORE_MSG], 'readonly');
        const sm = tx.objectStore(STORE_MSG);
        const idx = sm.index('thread_seq');
        const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]);
        const req = idx.getAll(range);
        const msgs = await idbReq(req);
        await new Promise((resolve) => { tx.oncomplete = resolve; });
        msgs.sort((a, b) => a.seq - b.seq);
        return msgs;
    }

    // ===================== 4. 闂佽娴烽弫鎼佸储瑜斿畷鐢割敇閵忥紕鍔甸梺鍝勫缁绘帞鏁? =====================
    function scrapeNewMessagesFromPage() {
        const scraped = [];
        const articles = document.querySelectorAll('article:not([data-resu-injected="true"])');
        for (const art of articles) {
            const userEl = art.querySelector('[data-message-author-role="user"]');
            const assistEl = art.querySelector('[data-message-author-role="assistant"]');
            let role = '';
            if (userEl) role = 'user';
            else if (assistEl) role = 'assistant';
            else continue;
            let content = '';
            if (role === 'user') {
                const textDiv = art.querySelector('.whitespace-pre-wrap');
                content = textDiv ? textDiv.innerText : art.innerText;
            } else {
                const mdDiv = art.querySelector('.markdown');
                if (mdDiv) {
                    const clone = mdDiv.cloneNode(true);
                    const details = clone.querySelectorAll('details');
                    details.forEach(d => d.remove());
                    content = clone.innerText;
                } else {
                    if (art.querySelector('.text-red-500')) continue;
                    content = art.innerText;
                }
            }
            content = content.trim();
            content = content.replace(/^(?:闁诲骸婀遍…鍫濐嚕閸洖鐒垫い鎺嶈兌缁犳岸鏌嶈閸撴瑩宕滈悜绯穙ught for|Thinking)\s*\d+\s*(?:s|缂傚倷绀侀ˇ鐗堢箾婵垁conds?)\s*(\n+)?/i, '');
            const pollutionPatterns = [
                /Is this conversation helpful so far\?/i,
                /这个回答有帮助吗?/i,
                /Was this response better or worse/i
            ];
            let isTrash = false;
            for (const p of pollutionPatterns) {
                if (p.test(content)) {
                    if (content.length < 100) isTrash = true;
                    else content = content.replace(p, '');
                }
            }
            if (isTrash) continue;
            content = content.replace(/Copy code|复制代码/gi, '');
            if (role === 'assistant') {
                content = content.replace(/^(ChatGPT|Assistant)([::]|\\s)+/i, '');
            }
            if (role === 'user') {
                content = content.replace(/^(You|你说|你)([::]|\\s)+/i, '');
            }
            content = content.replace(/\[\d+\]/g, '');
            content = content.trim();
            if (content) {
                scraped.push({ role, content });
            }
        }
        return scraped;
    }
    async function exportThread(threadId) {
        const db = await idbOpen();
        await ensureThread(threadId);
        const txT = db.transaction([STORE_THREADS], 'readonly');
        const st = txT.objectStore(STORE_THREADS);
        const thr = await idbReq(st.get(threadId));
        await new Promise((resolve) => { txT.oncomplete = resolve; });
        setStatus('⏳ 正在抓取网页新对话…');
        const newMessages = scrapeNewMessagesFromPage();
        const finalMessages = [...loadedMessages, ...newMessages];
        return {
            title: thr?.title || threadId,
            exported_at: new Date().toISOString(),
            total_messages: finalMessages.length,
            messages: finalMessages.map(m => ({ role: m.role, content: m.content }))
        };
    }

    // ===================== 5. 闂備礁鎼崐绋棵洪敐鍛瀻闁靛繆鈧磭绐為梺鑽ゅ枑婢瑰棗顫?=====================
    const FS = { supported: typeof window.showDirectoryPicker === 'function', dirHandle: null, fileList: [] };
    async function ensureDirPermission(dirHandle, mode = 'readwrite') {
        try {
            const q = await dirHandle.queryPermission({ mode });
            if (q === 'granted') return true;
            const r = await dirHandle.requestPermission({ mode });
            return r === 'granted';
        } catch { return false; }
    }
    async function pickLibraryFolder() {
        if (!FS.supported) { setStatus('⚠️ 当前浏览器不支持文件夹访问'); return; }
        try {
            const dir = await window.showDirectoryPicker();
            const ok = await ensureDirPermission(dir, 'readwrite');
            if (!ok) { setStatus('⚠️ 权限未授权'); return; }
            FS.dirHandle = dir;
            await settingsSet('libraryDir', dir);
            setStatus('✅ 已选择库文件夹');
            await refreshLibraryList();
        } catch { setStatus('(已取消选择)'); }
    }
    async function loadSavedFolderIfAny() {
        if (!FS.supported) return;
        const saved = await settingsGet('libraryDir');
        if (!saved) return;
        FS.dirHandle = saved;
        const ok = await ensureDirPermission(saved, 'readwrite');
        if (!ok) { setStatus('⚠️ 需重新授权文件夹'); return; }
        await refreshLibraryList();
    }
    async function refreshLibraryList() {
        if (!FS.dirHandle) { setStatus('⚠️ 未选择文件夹'); return; }
        const ok = await ensureDirPermission(FS.dirHandle, 'read');
        if (!ok) { setStatus('⚠️ 无读取权限'); return; }
        const list = [];
        try {
            for await (const [name, handle] of FS.dirHandle.entries()) {
                if (handle.kind === 'file' && name.toLowerCase().endsWith('.json')) list.push({ name, handle });
            }
        } catch { setStatus('❌ 遍历文件夹失败'); return; }
        list.sort((a, b) => a.name.localeCompare(b.name));
        FS.fileList = list;
        updateLibraryMenu();
        setStatus(`✅ 库文件夹:${list.length} 个 JSON`);
    }
    function updateLibraryMenu() {
        const sel = $p('#resu-lib-select');
        sel.innerHTML = '';
        if (!FS.fileList.length) {
            const opt = document.createElement('option');
            opt.value = '';
            opt.textContent = '(闂備礁鎼崐绋棵洪敐鍛瀻闁靛繆鈧磭绐為梻渚囧亝缁嬫牜绮堢€n剛纾?';
            sel.appendChild(opt);
            return;
        }
        for (const f of FS.fileList) {
            const opt = document.createElement('option');
            opt.value = f.name;
            opt.textContent = f.name;
            sel.appendChild(opt);
        }
    }
    async function writeJsonToFolderIfPossible(filename, jsonString) {
        if (!FS.supported || !FS.dirHandle) return false;
        const ok = await ensureDirPermission(FS.dirHandle, 'readwrite');
        if (!ok) return false;
        try {
            const fh = await FS.dirHandle.getFileHandle(filename, { create: true });
            const w = await fh.createWritable();
            await w.write(jsonString);
            await w.close();
            return true;
        } catch { return false; }
    }
    function downloadJson(obj, filename) {
        const blob = new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" });
        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), 1000);
    }

    // ===================== 6. JSON 闂佽崵鍠愰悷杈╁緤妤e啯鍊甸柦妯侯樈閸熷懘鏌曟径鍫濆姎鐎?=====================
    function extractTitleFromJson(raw) {
        try {
            if (!raw || typeof raw !== 'object') return '';
            const candidates = [
                raw.title, raw.conversation_title, raw.thread_title, raw.name,
                raw?.thread?.title, raw?.meta?.title, raw?.metadata?.title,
            ];
            for (const v of candidates) if (typeof v === 'string' && v.trim()) return v.trim();
            return '';
        } catch { return ''; }
    }
    function safeFileName(name) {
        let s = (name || '').toString().trim();
        if (!s) return 'chat_history';
        s = s.replace(/[\\/:\*?"<>|]/g, ' ');
        s = s.replace(/[\u0000-\u001F\u007F]/g, ' ');
        s = s.replace(/\s+/g, ' ').trim();
        s = s.replace(/[. ]+$/g, '');
        if (!s) s = 'chat_history';
        if (s.length > 120) s = s.slice(0, 120).trim();
        return s || 'chat_history';
    }
    function fnv1a32(str) {
        let h = 0x811c9dc5;
        for (let i = 0; i < str.length; i++) {
            h ^= str.charCodeAt(i);
            h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
        }
        return ('0000000' + h.toString(16)).slice(-8);
    }
    function parseJsonToMessages(raw) {
        if (!raw) return [];
        if (Array.isArray(raw) && raw.length > 0 && raw[0] && raw[0].mapping) {
            return parseChatGptConversation(raw[0]);
        }
        if (raw && typeof raw === 'object' && raw.mapping) {
            return parseChatGptConversation(raw);
        }
        const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.messages) ? raw.messages : []);
        const out = [];
        for (const item of arr) {
            const normalized = normalizeGenericMessage(item);
            if (normalized) out.push(normalized);
        }
        return out;
    }

    function normalizeGenericMessage(item) {
        if (!item || typeof item !== 'object') return null;
        const roleRaw = typeof item.author === 'string' ? item.author : item.author?.role;
        const role = normalizeMessageRole(item.role || roleRaw || '');
        if (!role) return null;
        const sender = deriveSenderForGeneric(item, role);
        const content = extractTextForGeneric(item, role).trim();
        if (!content) return null;
        if (role === 'assistant' && isNonAnswerAssistantContent(content)) return null;
        return { role, sender, content };
    }

    function deriveSenderForGeneric(item, role) {
        if (typeof item.sender === 'string' && item.sender.trim()) return item.sender.trim();
        if (typeof item.model === 'string' && item.model.trim() && role === 'assistant') {
            const model = item.model.trim();
            const gptMatch = model.match(/gpt-(.+)/i);
            if (gptMatch) return `GPT-${gptMatch[1]}`;
            return model;
        }
        if (role === 'assistant') return 'assistant';
        return 'user';
    }

    function extractThinkingForGeneric(item) {
        if (typeof item.thinking === 'string' && item.thinking.trim()) return item.thinking.trim();
        if (!Array.isArray(item.content)) return '';
        const chunks = [];
        for (const part of item.content) {
            if (!part || typeof part !== 'object') continue;
            if (part.type === 'think' && typeof part.think === 'string' && part.think.trim()) {
                chunks.push(part.think.trim());
            }
        }
        return chunks.join('\n\n').trim();
    }

    function extractTextForGeneric(item, role) {
        if (typeof item.content === 'string') return item.content;
        if (typeof item.text === 'string') return item.text;
        if (Array.isArray(item.content)) {
            const chunks = [];
            for (const part of item.content) {
                if (typeof part === 'string') {
                    if (part.trim()) chunks.push(part.trim());
                    continue;
                }
                if (!part || typeof part !== 'object') continue;
                if (part.type === 'text' && typeof part.text === 'string') chunks.push(part.text);
                else if (typeof part.text === 'string') chunks.push(part.text);
                else if (typeof part.value === 'string') chunks.push(part.value);
            }
            return chunks.join('\n\n');
        }
        if (item.content && typeof item.content === 'object') {
            if (item.content.content_type) {
                return formatMessageText({
                    author: { role },
                    content: item.content,
                    metadata: item.metadata || {},
                });
            }
            if (typeof item.content.text === 'string') return item.content.text;
            if (Array.isArray(item.content.parts)) return extractTextFromParts(item.content.parts);
        }
        if (Array.isArray(item.parts)) return extractTextFromParts(item.parts);
        return '';
    }

    function parseChatGptConversation(conv) {
        const mapping = conv?.mapping || {};
        const path = resolveConversationPath(conv, mapping);
        const out = [];

        for (const id of path) {
            const node = mapping[id];
            const message = node?.message;
            if (!message) continue;
            if (shouldSkipMessage(message)) continue;
            if (!hasRenderablePayload(message)) continue;

            const role = normalizeMessageRole(message.author?.role || '');
            if (!role) continue;
            if (role === 'assistant' && shouldSkipAssistantInternalMessage(message)) continue;
            const model = message.metadata?.model_slug || '';
            let sender = role;
            if (role === 'assistant') {
                const gptMatch = model.match(/gpt-(.+)/i);
                sender = gptMatch ? `GPT-${gptMatch[1]}` : (model || 'assistant');
            }

            const content = formatMessageText(message).trim();
            if (!content) continue;
            if (role === 'assistant' && isNonAnswerAssistantContent(content)) continue;
            const ts = parseChatGptTimestamp(message.create_time || conv.create_time) || Date.now();
            out.push({ role, sender, content, ts });
        }

        out.sort((a, b) => a.ts - b.ts);
        return out.map(({ ts, ...rest }) => rest);
    }

    function resolveConversationPath(conv, mapping) {
        const current = conv?.current_node;
        const path = [];
        const seen = new Set();
        let nodeId = current;
        while (nodeId && mapping[nodeId] && !seen.has(nodeId)) {
            seen.add(nodeId);
            path.push(nodeId);
            nodeId = mapping[nodeId].parent;
        }
        if (path.length > 0) {
            path.reverse();
            return path;
        }
        const ids = Object.keys(mapping);
        ids.sort((a, b) => {
            const ta = Number(mapping[a]?.message?.create_time || 0);
            const tb = Number(mapping[b]?.message?.create_time || 0);
            return ta - tb;
        });
        return ids;
    }

    function normalizeMessageRole(role) {
        const value = String(role || '').toLowerCase().trim();
        if (value === 'user') return 'user';
        if (value === 'assistant') return 'assistant';
        return '';
    }

    function shouldSkipAssistantInternalMessage(messageData) {
        if (!messageData || messageData.author?.role !== 'assistant') return false;
        const recipient = String(messageData.recipient || '').trim().toLowerCase();
        if (recipient && recipient !== 'all') return true;
        const channel = String(messageData.channel || '').trim().toLowerCase();
        if (channel === 'commentary') return true;
        const contentType = String(messageData.content?.content_type || '').toLowerCase();
        if (contentType === 'execution_output') return true;
        if (contentType === 'code' && (recipient && recipient !== 'all')) return true;
        const reasoningStatus = String(messageData.metadata?.reasoning_status || '').toLowerCase();
        if (reasoningStatus === 'is_reasoning' && contentType === 'code') return true;
        return false;
    }

    function isNonAnswerAssistantContent(messageText) {
        const text = String(messageText || '').trim();
        if (!text) return false;
        if (/^Execution Output:/i.test(text)) return true;
        if (looksLikeToolCallJson(text)) return true;
        if (looksLikeSandboxPythonScript(text)) return true;
        if (looksLikeSandboxExecutionTuple(text)) return true;
        return false;
    }

    function stripOuterCodeFence(text) {
        const source = String(text || '').trim();
        const m = source.match(/^```[a-z0-9_-]*\n([\s\S]*?)\n```$/i);
        return m ? m[1].trim() : source;
    }

    function looksLikeToolCallJson(text) {
        const source = stripOuterCodeFence(text);
        if (!(source.startsWith('{') && source.endsWith('}'))) return false;
        let parsed = null;
        try {
            parsed = JSON.parse(source);
        } catch {
            return false;
        }
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
        const keys = Object.keys(parsed).map((k) => String(k || '').toLowerCase());
        const toolKeys = new Set([
            'image_query',
            'search_query',
            'open',
            'click',
            'find',
            'screenshot',
            'sports',
            'finance',
            'weather',
            'time',
            'response_length',
            'tool_uses',
            'shell_command',
            'parallel',
        ]);
        return keys.some((k) => toolKeys.has(k) || k.endsWith('_query') || k.endsWith('_queries'));
    }

    function looksLikeSandboxPythonScript(text) {
        const source = stripOuterCodeFence(text);
        if (!/\/mnt\/data/i.test(source)) return false;
        if (/^from pathlib import Path\b/m.test(source)) return true;
        if (/\b(base|path)\s*=\s*Path\(\s*['"]\/mnt\/data['"]\s*\)/i.test(source)) return true;
        if (/\bwrite_text\(/i.test(source) && /\bPath\(/.test(source)) return true;
        return false;
    }

    function looksLikeSandboxExecutionTuple(text) {
        const source = stripOuterCodeFence(text);
        return /^\(\s*['"]\/mnt\/data\/[\s\S]*\)\s*$/i.test(source);
    }

    function parseChatGptTimestamp(value) {
        if (typeof value !== 'number' || Number.isNaN(value)) return null;
        return value * 1000;
    }

    function shouldSkipMessage(messageData) {
        if (!messageData) return true;
        if (messageData.author?.role === 'system') return true;
        if (messageData.metadata?.is_visually_hidden_from_conversation === true) return true;
        const contentType = messageData.content?.content_type;
        return SKIPPED_CONTENT_TYPES.has(contentType);
    }

    function hasRenderablePayload(messageData) {
        const content = messageData?.content;
        if (!content) return false;
        const contentType = content.content_type;
        if (contentType === 'code' || contentType === 'execution_output') {
            return typeof content.text === 'string' && content.text.trim().length > 0;
        }
        if (Array.isArray(content.parts)) {
            return extractTextFromParts(content.parts).trim().length > 0;
        }
        if (typeof content.text === 'string' && content.text.trim().length > 0) {
            return true;
        }
        if (Array.isArray(messageData.metadata?.content_references)) {
            return messageData.metadata.content_references.some((ref) => {
                return buildReferenceReplacement(ref).trim().length > 0;
            });
        }
        return false;
    }

    function formatMessageText(messageData) {
        const content = messageData.content || {};
        const contentType = content.content_type;
        const isText = contentType === 'text' || contentType === 'multimodal_text';
        let messageText = '';

        if (isText && Array.isArray(content.parts)) {
            messageText = extractTextFromParts(content.parts);
        } else if (contentType === 'code') {
            messageText = `\`\`\`${content.language || ''}\n${content.text || ''}\n\`\`\``;
        } else if (contentType === 'execution_output') {
            messageText = `Execution Output:\n> ${content.text || ''}`;
        } else if (Array.isArray(content.parts)) {
            messageText = extractTextFromParts(content.parts);
        } else if (typeof content.text === 'string') {
            messageText = content.text;
        } else if (Array.isArray(messageData.metadata?.content_references)) {
            messageText = messageData.metadata.content_references
                .map((reference) => buildReferenceReplacement(reference))
                .filter(Boolean)
                .join('\n');
        }

        if (messageData.author?.role === 'assistant') {
            messageText = processAssistantMessage(messageData, messageText);
        }
        return String(messageText || '').trim();
    }

    function processAssistantMessage(messageData, messageText) {
        let result = typeof messageText === 'string' ? messageText : '';
        result = processContentReferences(messageData, result);
        return result;
    }

    function extractTextFromParts(parts) {
        const segments = [];
        for (const part of parts) {
            if (typeof part === 'string') {
                segments.push(part);
                continue;
            }
            if (!part || typeof part !== 'object') continue;
            const objectPartText = extractObjectPartText(part);
            if (objectPartText) segments.push(objectPartText);
        }
        return joinTextSegments(segments).trim();
    }

    function joinTextSegments(segments) {
        let out = '';
        for (const seg of segments) {
            const text = String(seg ?? '');
            if (!text) continue;
            if (!out) {
                out = text;
                continue;
            }
            if (/[ \t\n]$/.test(out) || /^[ \t\n]/.test(text)) {
                out += text;
            } else {
                out += '\n' + text;
            }
        }
        return out;
    }

    function extractObjectPartText(part) {
        if (typeof part.text === 'string') return part.text;
        if (typeof part.value === 'string') return part.value;
        if (Array.isArray(part.parts)) return extractTextFromParts(part.parts);

        const contentType = String(part.content_type || part.type || '').toLowerCase();
        const imageUrl = extractFirstUrl([part.url, part.image_url, part.thumbnail_url, part.content_url, part.href]);
        if (contentType.includes('image')) {
            if (imageUrl) return `![image](${imageUrl})`;
            const pointer = part.asset_pointer || part.file_id || part.id || '';
            return pointer ? `[Image: ${String(pointer)}]` : '[Image]';
        }
        if (contentType.includes('audio')) return '[Audio]';
        if (contentType.includes('video')) return '[Video]';
        if (contentType.includes('file')) {
            const fileLabel = part.filename || part.name || part.file_id || part.id || 'file';
            return `[File: ${String(fileLabel)}]`;
        }
        if (imageUrl) return imageUrl;
        return '';
    }

    function processContentReferences(messageData, messageText) {
        const references = messageData.metadata?.content_references;
        if (!Array.isArray(references) || references.length === 0) return messageText;

        let result = messageText;
        const sortedReferences = [...references].sort((a, b) => {
            const aStart = typeof a?.start_idx === 'number' ? a.start_idx : -1;
            const bStart = typeof b?.start_idx === 'number' ? b.start_idx : -1;
            return bStart - aStart;
        });

        for (const reference of sortedReferences) {
            const matchedText = typeof reference?.matched_text === 'string' ? reference.matched_text : '';
            const start = reference?.start_idx;
            const end = reference?.end_idx;
            const replacement = buildReferenceReplacement(reference);
            if (!replacement) {
                if (matchedText && matchedText.trim().length > 0 && result.includes(matchedText)) {
                    result = result.replace(matchedText, '');
                    continue;
                }
                if (
                    typeof start === 'number' &&
                    typeof end === 'number' &&
                    start >= 0 &&
                    end >= start &&
                    start <= result.length &&
                    end <= result.length
                ) {
                    result = result.slice(0, start) + result.slice(end);
                }
                continue;
            }
            if (matchedText && matchedText.trim().length > 0 && result.includes(matchedText)) {
                result = result.replace(matchedText, replacement);
                continue;
            }

            if (
                typeof start === 'number' &&
                typeof end === 'number' &&
                start >= 0 &&
                end >= start &&
                start <= result.length &&
                end <= result.length
            ) {
                result = result.slice(0, start) + replacement + result.slice(end);
                continue;
            }

            if (matchedText && result.includes(matchedText)) {
                result = result.replace(matchedText, replacement);
            }
        }
        return result;
    }

    function buildReferenceReplacement(reference) {
        if (!reference || typeof reference !== 'object') return '';
        const type = String(reference.type || '').toLowerCase();
        const hasImageRef =
            type === 'image_v2' ||
            (Array.isArray(reference.refs) && reference.refs.some((item) => item?.ref_type === 'image'));
        if (hasImageRef) return buildImageReferenceReplacement(reference);
        if (type === 'grouped_webpages') return '';
        if (type === 'sources_footnote') return '';
        return '';
    }

    function buildImageReferenceReplacement(reference) {
        const markdownImages = [];
        const images = Array.isArray(reference.images) ? reference.images : [];
        for (const image of images.slice(0, 8)) {
            const src = image?.thumbnail_url || image?.content_url || image?.url;
            if (!src) continue;
            const title = sanitizeMarkdownLabel(image?.title || 'image');
            const link = extractFirstUrl([image?.url, image?.content_url]);
            if (link && link !== src) markdownImages.push(`[![${title}](${src})](${link})`);
            else markdownImages.push(`![${title}](${src})`);
        }
        if (markdownImages.length > 0) return `\n${markdownImages.join('\n')}\n`;
        if (typeof reference.alt === 'string' && reference.alt.trim()) return reference.alt.trim();
        if (typeof reference.prompt_text === 'string' && reference.prompt_text.trim()) {
            const promptText = reference.prompt_text.replace(/^(\s*##\s*Images:\s*)/i, '').trim();
            if (promptText) return `\n${promptText}\n`;
        }
        return '';
    }

    function buildGroupedWebpagesReplacement(reference) {
        const links = [];
        const items = Array.isArray(reference.items) ? reference.items : [];
        const fallbackItems = Array.isArray(reference.fallback_items) ? reference.fallback_items : [];
        for (const item of [...items, ...fallbackItems]) {
            const url = extractFirstUrl([item?.url]);
            if (!url) continue;
            const title = sanitizeMarkdownLabel(item?.title || item?.attribution || 'source');
            links.push(`[${title}](${url})`);
            if (links.length >= 5) break;
        }
        if (links.length === 1) return ` (${links[0]})`;
        if (links.length > 1) return ` (${links.join(' | ')})`;
        if (typeof reference.alt === 'string' && reference.alt.trim()) return reference.alt.trim();
        return '';
    }

    function buildSourcesFootnoteReplacement(reference) {
        const sources = Array.isArray(reference.sources) ? reference.sources : [];
        if (sources.length === 0) return '';
        const lines = [];
        for (const source of sources.slice(0, 8)) {
            const url = extractFirstUrl([source?.url]);
            if (!url) continue;
            const title = sanitizeMarkdownLabel(source?.title || source?.attribution || 'source');
            lines.push(`- [${title}](${url})`);
        }
        if (lines.length === 0) return '';
        return `\n\nSources:\n${lines.join('\n')}\n`;
    }

    function sanitizeMarkdownLabel(value) {
        return String(value || '')
            .replace(/[\[\]\r\n]+/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }

    function extractFirstUrl(candidates) {
        for (const candidate of candidates) {
            if (typeof candidate !== 'string') continue;
            const url = candidate.trim();
            if (/^(https?:\/\/|sandbox:|blob:|data:image\/)/i.test(url)) return url;
        }
        return '';
    }

    // ===================== 7. DOM 闂備胶鎳撻悘婵堢矓瀹曞洨绀婇柡鍐ㄧ墕閸愨偓缂備礁顑堝▔鏇熶繆?=====================
    const sleep = (ms) => new Promise(r => setTimeout(r, ms));

    async function renderLoadedMessagesOnPage(messages, timeoutMs = 10000) {
        const start = Date.now();
        let lastErr = null;
        while (Date.now() - start < timeoutMs) {
            if (canRenderVisualHistory()) {
                try {
                    renderVisualHistory(messages);
                    return;
                } catch (err) {
                    lastErr = err;
                }
            }
            await sleep(120);
        }
        if (lastErr) throw lastErr;
        throw new Error('未找到可渲染区域');
    }

    // ===================== 8. 婵犵數鍋為幐绋款嚕閸洘鍋傞悗锝庝憾閸ゆ洟鏌¢崶鈺佹瀾闁?=====================
    function findHistoryHostNode() {
        const firstArticle = document.querySelector('main article[data-turn-id], main article');
        if (firstArticle?.parentElement) return firstArticle.parentElement;
        return (
            document.querySelector('main #thread') ||
            document.querySelector('main') ||
            document.body
        );
    }

    function ensureVisualHistoryCanvas() {
        const host = findHistoryHostNode();
        if (!host) return null;
        let canvas = document.getElementById('resu-lc-thread');
        if (!canvas) {
            canvas = document.createElement('section');
            canvas.id = 'resu-lc-thread';
            canvas.className = 'resu-lc-thread';
            canvas.setAttribute('data-resu-injected', 'true');
            if (host.firstChild) host.insertBefore(canvas, host.firstChild);
            else host.appendChild(canvas);
        }
        return canvas;
    }

    function canRenderVisualHistory() {
        return !!findHistoryHostNode();
    }

    function ensureKatexStylesheet() {
        if (document.getElementById('resu-katex-css')) return;
        const style = document.createElement('style');
        style.id = 'resu-katex-css';
        style.textContent = `
          .resu-lc-math-block math {
            display: block;
            overflow-x: auto;
          }
          .resu-lc-markdown math {
            font-size: 1.04em;
          }
        `;
        document.head.appendChild(style);
    }

    function renderVisualHistory(messages) {
        ensureKatexStylesheet();
        const canvas = ensureVisualHistoryCanvas();
        if (!canvas) {
            console.warn('[Resu] render failed: mount not found');
            return;
        }
        const normalized = Array.isArray(messages) ? messages : [];
        canvas.innerHTML = '';
        const frag = document.createDocumentFragment();

        for (const raw of normalized) {
            const role = normalizeMessageRole(raw?.role || raw?.author || '');
            if (!role) continue;
            const content = String(raw?.content || '').trim();
            const sender = (typeof raw?.sender === 'string' && raw.sender.trim())
                ? raw.sender.trim()
                : (role === 'assistant' ? 'assistant' : 'anonymous');
            if (!content) continue;
            if (role === 'assistant' && isNonAnswerAssistantContent(content)) continue;

            if (role === 'user') {
                frag.appendChild(createUserTurnNode(content));
                continue;
            }
            frag.appendChild(createAssistantTurnNode({ role, sender, content }));
        }

        canvas.appendChild(frag);
    }

    function createUserTurnNode(content) {
        const turn = document.createElement('article');
        turn.className = 'resu-lc-turn resu-lc-turn-user group';
        turn.setAttribute('data-resu-injected', 'true');
        turn.innerHTML = `
          <div class="resu-lc-msg-shell">
            <div class="resu-lc-msg-inner">
              <div class="resu-lc-msg-main">
                <div class="resu-lc-text-message" dir="auto">
                  <div class="resu-lc-markdown">${renderMarkdownMessage(content || '')}</div>
                </div>
              </div>
            </div>
          </div>
        `;
        bindTurnActions(turn);
        return turn;
    }

    function createAssistantTurnNode(msg) {
        const turn = document.createElement('article');
        turn.className = 'resu-lc-turn resu-lc-turn-assistant group';
        turn.setAttribute('data-resu-injected', 'true');

        turn.innerHTML = `
          <div class="resu-lc-msg-shell">
            <div class="resu-lc-msg-inner">
              <div class="resu-lc-msg-main resu-lc-agent-turn">
                <div class="resu-lc-text-message" dir="auto">
                  <div class="resu-lc-markdown">${renderMarkdownMessage(msg.content || '')}</div>
                </div>
              </div>
            </div>
          </div>
        `;
        bindTurnActions(turn);
        return turn;
    }

    function renderCopyIconSvg() {
        return `
          <svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="none" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="icon-md-heavy" aria-hidden="true">
            <path fill="currentColor" fill-rule="evenodd" d="M7 5a3 3 0 0 1 3-3h9a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-2v2a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3h2zm2 2h5a3 3 0 0 1 3 3v5h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1zM5 9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-9a1 1 0 0 0-1-1z" clip-rule="evenodd"></path>
          </svg>
        `;
    }

    function bindTurnActions(root) {
        const codeButtons = root.querySelectorAll('.resu-copy-code');
        codeButtons.forEach((btn) => {
            btn.addEventListener('click', async () => {
                const encoded = btn.getAttribute('data-resu-code') || '';
                let decoded = '';
                try {
                    decoded = decodeURIComponent(encoded);
                } catch {
                    decoded = encoded;
                }
                await copyTextToClipboard(decoded);
                btn.classList.add('is-copied');
                const label = btn.querySelector('.resu-copy-code-live');
                if (label) label.textContent = 'Copied';
                setTimeout(() => {
                    btn.classList.remove('is-copied');
                    const resetLabel = btn.querySelector('.resu-copy-code-live');
                    if (resetLabel) resetLabel.textContent = 'Copy code';
                }, 1300);
            });
        });
    }

    async function copyTextToClipboard(text) {
        const value = String(text || '');
        if (!value) return;
        try {
            if (navigator?.clipboard?.writeText) {
                await navigator.clipboard.writeText(value);
                return;
            }
        } catch {}
        try {
            const ta = document.createElement('textarea');
            ta.value = value;
            ta.setAttribute('readonly', 'readonly');
            ta.style.position = 'fixed';
            ta.style.opacity = '0';
            ta.style.left = '-9999px';
            document.body.appendChild(ta);
            ta.focus();
            ta.select();
            document.execCommand('copy');
            ta.remove();
        } catch {}
    }

    function renderMarkdownMessage(markdownText) {
        const text = String(markdownText || '').replace(/\r\n?/g, '\n');
        if (!text.trim()) return '';

        const codeBlocks = [];
        const withCodePlaceholders = text.replace(/```([^\n`]*)\n([\s\S]*?)```/g, (_m, lang, code) => {
            const token = `@@RESU_CODE_${codeBlocks.length}@@`;
            codeBlocks.push({ lang: String(lang || '').trim(), code: String(code || '') });
            return `\n${token}\n`;
        });

        const lines = withCodePlaceholders.split('\n');
        const html = [];
        let i = 0;

        while (i < lines.length) {
            const line = lines[i];
            const trimmed = line.trim();
            if (!trimmed) {
                i++;
                continue;
            }

            const codeToken = trimmed.match(/^@@RESU_CODE_(\d+)@@$/);
            if (codeToken) {
                const idx = Number(codeToken[1]);
                const block = codeBlocks[idx];
                const langRaw = String(block?.lang || 'unknown').trim() || 'unknown';
                const lang = escapeHtml(langRaw);
                const code = escapeHtml(block?.code || '');
                const encoded = escapeAttribute(encodeURIComponent(block?.code || ''));
                html.push(`
                  <div class="resu-code-root relative w-full rounded-md bg-gray-900 text-xs text-white/80">
                    <div class="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
                      <span class="">${lang}</span>
                      <div class="flex items-center justify-center gap-4">
                        <button type="button" class="resu-copy-code ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white" data-resu-code="${encoded}" aria-label="Copy code">
                          ${renderCopyIconSvg()}
                          <span class="relative">
                            <span class="invisible">Copy code</span>
                            <span class="resu-copy-code-live absolute inset-0 flex items-center">Copy code</span>
                          </span>
                        </button>
                      </div>
                    </div>
                    <div class="overflow-y-auto p-4">
                      <code class="hljs language-${lang} !whitespace-pre resu-code-content">${code}</code>
                    </div>
                    <div class="resu-code-float absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150 pointer-events-none opacity-0">
                      <button type="button" class="resu-copy-code cursor-pointer flex items-center justify-center rounded p-1.5 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white" data-resu-code="${encoded}" aria-label="Copy code">
                        ${renderCopyIconSvg()}
                      </button>
                    </div>
                  </div>
                `);
                i++;
                continue;
            }

            const gallery = [];
            let j = i;
            while (j < lines.length) {
                const parsed = parseImageMarkdownLine(lines[j].trim());
                if (!parsed) break;
                gallery.push(parsed);
                j++;
            }
            if (gallery.length > 0) {
                html.push(renderImageGallery(gallery));
                i = j;
                continue;
            }

            if (trimmed === '\\[') {
                const mathLines = [];
                let k = i + 1;
                while (k < lines.length && lines[k].trim() !== '\\]') {
                    mathLines.push(lines[k]);
                    k++;
                }
                if (k < lines.length && lines[k].trim() === '\\]') {
                    html.push(renderMathBlock(mathLines.join('\n')));
                    i = k + 1;
                    continue;
                }
            }
            if (trimmed === '$$') {
                const mathLines = [];
                let k = i + 1;
                while (k < lines.length && lines[k].trim() !== '$$') {
                    mathLines.push(lines[k]);
                    k++;
                }
                if (k < lines.length && lines[k].trim() === '$$') {
                    html.push(renderMathBlock(mathLines.join('\n')));
                    i = k + 1;
                    continue;
                }
            }
            const singleBlockMath = trimmed.match(/^\\\[(.+)\\\]$/);
            if (singleBlockMath) {
                html.push(renderMathBlock(singleBlockMath[1]));
                i++;
                continue;
            }

            const heading = trimmed.match(/^(#{1,6})\s+(.+)$/);
            if (heading) {
                const level = Math.max(1, Math.min(6, heading[1].length));
                html.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
                i++;
                continue;
            }

            if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
                html.push('<hr />');
                i++;
                continue;
            }

            if (trimmed.startsWith('>')) {
                const quoteLines = [];
                while (i < lines.length && lines[i].trim().startsWith('>')) {
                    quoteLines.push(lines[i].replace(/^\s*>\s?/, ''));
                    i++;
                }
                html.push(`<blockquote>${quoteLines.map((q) => renderInlineMarkdown(q)).join('<br />')}</blockquote>`);
                continue;
            }

            if (isTableHeader(lines, i)) {
                const { tableHtml, nextIndex } = consumeTable(lines, i);
                html.push(tableHtml);
                i = nextIndex;
                continue;
            }

            if (/^[-*+]\s+/.test(trimmed)) {
                const { listHtml, nextIndex } = consumeList(lines, i, 'ul');
                html.push(listHtml);
                i = nextIndex;
                continue;
            }
            if (/^\d+\.\s+/.test(trimmed)) {
                const { listHtml, nextIndex } = consumeList(lines, i, 'ol');
                html.push(listHtml);
                i = nextIndex;
                continue;
            }

            const paraLines = [];
            while (i < lines.length && !isBlockStart(lines[i])) {
                if (lines[i].trim()) paraLines.push(lines[i]);
                i++;
            }
            html.push(`<p>${paraLines.map((p) => renderInlineMarkdown(p)).join('<br />')}</p>`);
        }

        return html.join('\n');
    }

    function renderMathBlock(expression) {
        const mathHtml = renderMathExpression(expression, true);
        return `<div class="resu-lc-math-block">${mathHtml}</div>`;
    }

    function renderMathExpression(expression, displayMode = false) {
        const expr = String(expression || '').trim();
        if (!expr) return '';
        const katexLib = window?.katex;
        if (katexLib && typeof katexLib.renderToString === 'function') {
            try {
                return katexLib.renderToString(expr, {
                    throwOnError: false,
                    strict: 'ignore',
                    displayMode,
                    // Use MathML to avoid relying on external KaTeX CSS blocked by CSP.
                    output: 'mathml',
                });
            } catch {}
        }
        return `<code class="resu-lc-math-raw">${escapeHtml(expr)}</code>`;
    }

    function parseImageMarkdownLine(line) {
        if (!line) return null;
        const linked = line.match(/^\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)$/);
        if (linked) {
            const alt = linked[1] || 'image';
            const src = safeUrl(linked[2]);
            const href = safeUrl(linked[3]);
            if (!src) return null;
            return { alt, src, href: href || src };
        }
        const plain = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
        if (plain) {
            const alt = plain[1] || 'image';
            const src = safeUrl(plain[2]);
            if (!src) return null;
            return { alt, src, href: src };
        }
        return null;
    }

    function renderImageGallery(images) {
        const items = images.map((img) => {
            const alt = escapeHtml(img.alt || 'image');
            const src = escapeAttribute(img.src);
            const href = escapeAttribute(img.href || img.src);
            return `<a href="${href}" target="_blank" rel="noopener noreferrer"><img src="${src}" alt="${alt}" loading="lazy" /></a>`;
        });
        return `<div class="resu-lc-gallery">${items.join('')}</div>`;
    }

    function consumeList(lines, startIndex, listType) {
        const isOrdered = listType === 'ol';
        const chunks = [];
        let i = startIndex;
        while (i < lines.length) {
            const trimmed = lines[i].trim();
            if (!trimmed) break;
            const marker = isOrdered ? /^\d+\.\s+(.+)$/ : /^[-*+]\s+(.+)$/;
            const m = trimmed.match(marker);
            if (!m) break;
            chunks.push(`<li>${renderInlineMarkdown(m[1])}</li>`);
            i++;
        }
        return { listHtml: `<${listType}>${chunks.join('')}</${listType}>`, nextIndex: i };
    }

    function isTableHeader(lines, index) {
        if (index + 1 >= lines.length) return false;
        const header = lines[index].trim();
        const sep = lines[index + 1].trim();
        if (!header.includes('|')) return false;
        return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(sep);
    }

    function splitTableRow(line) {
        const raw = line.trim().replace(/^\|/, '').replace(/\|$/, '');
        return raw.split('|').map((v) => v.trim());
    }

    function consumeTable(lines, startIndex) {
        const headCells = splitTableRow(lines[startIndex]);
        let i = startIndex + 2;
        const bodyRows = [];
        while (i < lines.length) {
            const line = lines[i].trim();
            if (!line || !line.includes('|')) break;
            bodyRows.push(splitTableRow(line));
            i++;
        }

        const headHtml = `<tr>${headCells.map((cell) => `<th>${renderInlineMarkdown(cell)}</th>`).join('')}</tr>`;
        const bodyHtml = bodyRows
            .map((row) => `<tr>${row.map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`).join('')}</tr>`)
            .join('');
        return {
            tableHtml: `<div class="resu-lc-table-wrap"><table><thead>${headHtml}</thead><tbody>${bodyHtml}</tbody></table></div>`,
            nextIndex: i,
        };
    }

    function isBlockStart(line) {
        const trimmed = line.trim();
        if (!trimmed) return true;
        if (/^@@RESU_CODE_(\d+)@@$/.test(trimmed)) return true;
        if (/^(#{1,6})\s+/.test(trimmed)) return true;
        if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) return true;
        if (trimmed === '\\[' || trimmed === '$$' || /^\\\[(.+)\\\]$/.test(trimmed)) return true;
        if (trimmed.startsWith('>')) return true;
        if (/^[-*+]\s+/.test(trimmed)) return true;
        if (/^\d+\.\s+/.test(trimmed)) return true;
        if (parseImageMarkdownLine(trimmed)) return true;
        return false;
    }

    function renderInlineMarkdown(text) {
        const source = String(text || '');
        const mathTokens = [];
        let prepared = source.replace(/\\\(([\s\S]+?)\\\)/g, (_m, expr) => {
            const token = `@@RESU_MATH_${mathTokens.length}@@`;
            mathTokens.push(String(expr || '').trim());
            return token;
        });
        prepared = prepared.replace(/(^|[^\\])\$([^\n$]+?)\$/g, (_m, prefix, expr) => {
            const token = `@@RESU_MATH_${mathTokens.length}@@`;
            mathTokens.push(String(expr || '').trim());
            return `${prefix}${token}`;
        });

        let out = escapeHtml(prepared);
        out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
            const src = safeUrl(url);
            if (!src) return escapeHtml(_m);
            return `<img src="${escapeAttribute(src)}" alt="${alt || 'image'}" loading="lazy" />`;
        });
        out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
            const href = safeUrl(url);
            if (!href) return escapeHtml(_m);
            return `<a href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
        });
        out = out.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`);
        out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
        out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
        out = out.replace(/~~([^~]+)~~/g, '<del>$1</del>');
        out = out.replace(/@@RESU_MATH_(\d+)@@/g, (_m, idxText) => {
            const idx = Number(idxText);
            if (!Number.isFinite(idx) || idx < 0 || idx >= mathTokens.length) return '';
            return renderMathExpression(mathTokens[idx], false);
        });
        return out;
    }

    function safeUrl(value) {
        const url = String(value || '').trim();
        if (/^(https?:\/\/|sandbox:|blob:|data:image\/)/i.test(url)) return url;
        if (url.startsWith('/')) return url;
        return '';
    }

    function escapeHtml(value) {
        return String(value || '')
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }

    function escapeAttribute(value) {
        return escapeHtml(value).replace(/`/g, '&#96;');
    }


    // ===================== 9. 闂備礁缍婇弲鎻掝渻閹烘梻涓嶆繛鍡樻尭缁€宀勬煛瀹ュ繐顩柣鐔村灪缁绘盯骞橀搹顐㈡闂?=====================
    function getConversationIdFromPath(pathname = window.location.pathname) {
        const text = String(pathname || '');
        const m = text.match(/\/c\/([^/?#]+)/i);
        return m ? m[1] : '';
    }

    function normalizeConversationPath(pathname = window.location.pathname) {
        const conversationId = getConversationIdFromPath(pathname);
        return conversationId ? `/c/${conversationId}` : '';
    }

    async function bindUrlToThread(threadId) {
        const currentPath = normalizeConversationPath(window.location.pathname);
        const conversationId = getConversationIdFromPath(currentPath);
        if (!conversationId) {
            setStatus('⚠️ 当前页面没有会话ID,无法绑定');
            return;
        }

        const savedByPath = await settingsGet(KEY_URL_MAP) || {};
        savedByPath[currentPath] = threadId;
        await settingsSet(KEY_URL_MAP, savedByPath);

        const savedBySession = await settingsGet(KEY_SESSION_MAP) || {};
        savedBySession[conversationId] = threadId;
        await settingsSet(KEY_SESSION_MAP, savedBySession);

        console.log(`[Resu] Bound Session ${conversationId} (${currentPath}) to Thread ${threadId}`);
        setStatus('✅ 已绑定会话ID,刷新可自动恢复');
    }
    async function checkAndAutoRender() {
        const currentPath = normalizeConversationPath(window.location.pathname);
        const conversationId = getConversationIdFromPath(currentPath);
        if (!conversationId) return;

        const savedByPath = await settingsGet(KEY_URL_MAP) || {};
        const savedBySession = await settingsGet(KEY_SESSION_MAP) || {};
        const threadId = savedByPath[currentPath] || savedBySession[conversationId];
        if (!threadId) return;

        // 兼容旧数据:如果只命中了 session 映射,补写路径映射,便于后续快速恢复
        if (!savedByPath[currentPath]) {
            savedByPath[currentPath] = threadId;
            await settingsSet(KEY_URL_MAP, savedByPath);
        }

        if (document.getElementById('resu-lc-thread')) return;
        console.log(`[Resu] Found auto-restore for session ${conversationId} -> ${threadId}`);
        setStatus('⏳ 正在自动恢复历史...');
        try {
            const msgs = await getFullThreadMessages(threadId);
            if (msgs && msgs.length > 0) {
                loadedMessages = msgs;
                lastThreadId = threadId;
                let retries = 0;
                const autoTimer = setInterval(() => {
                    retries++;
                    if (canRenderVisualHistory()) {
                        clearInterval(autoTimer);
                        renderVisualHistory(msgs);
                        setStatus(`✅ 自动恢复成功 (${msgs.length}条)`);
                    } else if (retries > 60) {
                        clearInterval(autoTimer);
                        setStatus('⚠️ 自动恢复等待中 (未找到模板)');
                    }
                }, 250);
            }
        } catch (e) {
            console.error(e);
            setStatus('❌ 自动恢复出错');
        }
    }
    let lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            setTimeout(checkAndAutoRender, 1500);
        }
    }).observe(document, { subtree: true, childList: true });

    // ===================== 10. 本地渲染主流程 =====================
    async function renderOnlyFlow(messages) {
        showOverlay('正在渲染…', '正在将存档渲染到当前页面...');
        await renderLoadedMessagesOnPage(messages, 10000);
        try {
            await bindUrlToThread(lastThreadId);
        } catch (e) {
            console.warn('[Resu] 会话ID绑定失败(不影响渲染):', e);
        }
        return { totalParts: 1 };
    }

    // ===================== 11. 闂備礁婀遍搹搴ㄥ储妤e喛缍栭柟鐑樺焾濞尖晠鏌涘┑鍕姕闁哄懏鎮傞獮?=====================
    async function seedFromLoadedJson(threadId, msgs, titleFromImport) {
        await idbOpen();
        await ensureThread(threadId, titleFromImport);
        await clearThread(threadId);
        for (const m of msgs) {
            await addMsg(threadId, m.role, m.content, Date.now(), 'base', {
                sender: m.sender || '',
            });
        }
    }
    async function loadJsonObjectToState(raw, inferredName) {
        const msgs = parseJsonToMessages(raw);
        if (!msgs.length) {
            setStatus('❌ 解析失败(无消息)');
            loadedMessages = [];
            $p('#resu-preview').value = '';
            disableActions();
            return false;
        }
        const importedTitle = extractTitleFromJson(raw) || inferredName || 'chat_history';
        const base = safeFileName(importedTitle);
        const hint = `${inferredName || ''}|${raw?.exported_at || ''}|${msgs.length}|${JSON.stringify(raw)?.length || 0}`;
        const threadId = `${base}__${fnv1a32(hint)}`;
        loadedMessages = msgs;
        lastThreadTitle = importedTitle;
        lastThreadId = threadId;
        const jsonStr = JSON.stringify(msgs);
        $p('#resu-preview').value =
            `已载入:${loadedMessages.length} 条消息\n` +
            `标题:${importedTitle}\n` +
            `模式:本地渲染(不发送消息) + 自动恢复\n` +
            `压缩后体积:${jsonStr.length} 字符\n`;
        setStatus('⏳ 写入库…');
        await seedFromLoadedJson(threadId, loadedMessages, importedTitle);
        setStatus(`✅ 已载入:${loadedMessages.length} 条`);
        enableActions();
        return true;
    }
    // ===================== 12. UI 构建 (重构版) =====================
    // 注入样式
    const styleEl = document.createElement('style');
    styleEl.textContent = `
    .resu-wrapper {
      --resu-poke-red: #6942d6;
      --resu-poke-red-dark: #3e277f;
      --resu-poke-yellow: #ff63d4;
      --resu-poke-yellow-dark: #ca43aa;
      --resu-poke-blue: #88d9ff;
      --resu-poke-blue-dark: #3f77c7;
      --resu-poke-ink: #130f24;
      --resu-poke-cream: #edf7ff;
      --resu-poke-border: #090b14;
      font-family: "Trebuchet MS", "Segoe UI", Tahoma, sans-serif;
    }
    .resu-toggle-btn {
        position: fixed;
        top: 50%;
        right: 14px;
        transform: translateY(-50%);
        width: 60px;
        height: 60px;
        border: 4px solid var(--resu-poke-border);
        border-radius: 999px;
        cursor: pointer;
        z-index: 999990;
        display: flex;
        align-items: center;
        justify-content: center;
        background:
          radial-gradient(circle at 27% 21%, #ff63d4 0 12px, transparent 13px),
          radial-gradient(circle at 73% 21%, #ff63d4 0 12px, transparent 13px),
          linear-gradient(180deg, var(--resu-poke-red) 0 46%, var(--resu-poke-border) 46% 53%, var(--resu-poke-cream) 53% 100%);
        box-shadow: 0 10px 20px rgba(0,0,0,0.38), inset 0 0 0 2px rgba(255,255,255,0.22);
        transition: transform 0.18s ease, filter 0.2s ease;
        overflow: hidden;
    }
    .resu-toggle-btn::before {
        content: '';
        position: absolute;
        width: 20px;
        height: 20px;
        border-radius: 50%;
        border: 3px solid var(--resu-poke-border);
        background: #dff4ff;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        box-shadow: inset 0 0 0 3px #a8d8ef;
    }
    .resu-toggle-btn::after {
        content: 'M';
        position: absolute;
        top: 8px;
        left: 50%;
        transform: translateX(-50%);
        color: #dee3ff;
        font-size: 15px;
        font-weight: 900;
        letter-spacing: 0.5px;
        text-shadow: 0 1px 0 rgba(0,0,0,0.45);
    }
    .resu-toggle-btn svg { display: none; }
    .resu-toggle-btn:hover {
        transform: translateY(-50%) scale(1.06);
        filter: saturate(1.08);
    }
    .resu-panel {
        position: fixed;
        top: 10px;
        right: 10px;
        width: min(356px, calc(100vw - 20px));
        color: var(--resu-poke-ink);
        z-index: 999999;
        border: 4px solid var(--resu-poke-border);
        border-radius: 10px;
        background:
          linear-gradient(180deg, var(--resu-poke-red) 0 56px, #160f31 56px 63px, var(--resu-poke-cream) 63px 100%);
        box-shadow: 0 16px 44px rgba(0,0,0,0.4), inset 0 0 0 2px rgba(255,255,255,0.24);
        display: none;
        flex-direction: column;
        padding: 10px 12px 12px;
        animation: resuSlideIn 0.28s cubic-bezier(0.22, 1, 0.36, 1);
        overflow: hidden;
        isolation: isolate;
        gap: 6px;
    }
    .resu-panel::before {
      content: '';
      position: absolute;
      inset: 5px;
      border: 2px solid rgba(255,255,255,0.58);
      border-radius: 6px;
      pointer-events: none;
      opacity: 0.45;
    }
    .resu-panel::after {
      content: '';
      position: absolute;
      left: 12px;
      right: 12px;
      top: 61px;
      height: 1px;
      background: rgba(255,255,255,0.35);
      pointer-events: none;
    }
    @keyframes resuSlideIn { from { opacity: 0; transform: translateX(20px) scale(0.97); } to { opacity: 1; transform: translateX(0) scale(1); } }
    .resu-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin: -10px -12px 4px -12px;
      padding: 7px 10px 8px;
      border-bottom: 2px solid rgba(255,255,255,0.2);
      background: linear-gradient(180deg, rgba(255,255,255,0.09) 0%, rgba(255,255,255,0) 100%);
      color: #fff;
    }
    .resu-title {
      display: inline-flex;
      align-items: center;
      gap: 7px;
      font-weight: 900;
      font-size: 14px;
      color: #f6f8ff;
      letter-spacing: 0.25px;
      text-transform: none;
      text-shadow: 0 2px 0 rgba(0,0,0,0.3);
    }
    .resu-title::before {
      content: '';
      width: 14px;
      height: 14px;
      border-radius: 50%;
      border: 2px solid #1d103b;
      background: linear-gradient(180deg, #a78cff 0 48%, #1d103b 48% 58%, #dcecff 58% 100%);
      box-shadow: inset 0 0 0 1px rgba(255,255,255,0.35);
      flex: 0 0 14px;
    }
    .resu-close {
      border: 3px solid var(--resu-poke-border);
      background: #dff2ff;
      color: var(--resu-poke-red-dark);
      cursor: pointer;
      width: 30px;
      height: 30px;
      border-radius: 999px;
      font-weight: 900;
      line-height: 1;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      box-shadow: 0 2px 0 rgba(0,0,0,0.28);
    }
    .resu-close:hover { background: #cce7fb; }
    .resu-section-label {
      color: #f7f1ff;
      font-size: 10px;
      margin: 4px 0 4px 0;
      font-weight: 900;
      text-transform: uppercase;
      letter-spacing: 0.9px;
      background: linear-gradient(180deg, #7f62e3 0%, #4f36a8 100%);
      border: 2px solid #261453;
      border-radius: 6px;
      padding: 4px 8px;
      box-shadow: inset 0 1px 0 rgba(255,255,255,0.3);
    }
    .resu-btn {
      border: 3px solid #120d26;
      border-radius: 8px;
      cursor: pointer;
      font-size: 12px;
      font-weight: 900;
      padding: 9px 12px;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      transition: transform 0.14s ease, box-shadow 0.14s ease, filter 0.2s ease;
      box-shadow: inset 0 1px 0 rgba(255,255,255,0.35), 0 3px 0 rgba(0,0,0,0.25);
      text-shadow: 0 1px 0 rgba(0,0,0,0.26);
    }
    .resu-btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
    .resu-btn:hover:not(:disabled) { transform: translateY(-1px); filter: saturate(1.08) brightness(1.02); }
    .resu-btn:active:not(:disabled) { transform: translateY(1px); box-shadow: inset 0 1px 0 rgba(255,255,255,0.25), 0 1px 0 rgba(0,0,0,0.28); }
    .resu-btn-primary {
      background: linear-gradient(180deg, var(--resu-poke-yellow) 0%, var(--resu-poke-yellow-dark) 100%);
      color: #fff;
      width: 100%;
      margin-top: 0;
      padding: 10px;
      font-size: 13px;
    }
    .resu-btn-secondary {
      background: linear-gradient(180deg, #7a59e3 0%, var(--resu-poke-red-dark) 100%);
      color: #fff;
    }
    .resu-btn-danger {
      background: linear-gradient(180deg, #9245d6 0%, #6c2fa8 100%);
      color: #fff;
    }
    .resu-btn-export {
      background: linear-gradient(180deg, var(--resu-poke-blue) 0%, var(--resu-poke-blue-dark) 100%);
      color: #fff;
    }
    .resu-row {
      display: flex;
      gap: 8px;
      align-items: stretch;
    }
    .resu-row-tight { margin-top: 10px; }
    .resu-card .resu-row:first-child { margin-bottom: 8px; }
    .resu-row > .resu-btn {
      min-height: 44px;
    }
    .resu-btn-fill { flex: 1; }
    .resu-btn-block { width: 100%; }
    .resu-btn-icon {
      width: 48px;
      flex: 0 0 48px;
      padding: 0;
      font-size: 15px;
    }
    .resu-panel-body {
      display: flex;
      flex-direction: column;
      gap: 10px;
      margin-top: 2px;
    }
    .resu-card {
      border: 2px solid #2b1c59;
      border-radius: 8px;
      background: linear-gradient(180deg, rgba(255,255,255,0.32) 0%, rgba(255,255,255,0.18) 100%);
      padding: 9px;
      box-shadow: inset 0 1px 0 rgba(255,255,255,0.3);
    }
    .resu-card-status {
      padding: 6px 8px 8px;
    }
    .resu-divider {
      height: 2px;
      border-radius: 2px;
      background: linear-gradient(90deg, rgba(80,55,170,0.1) 0%, rgba(80,55,170,0.85) 18%, rgba(80,55,170,0.85) 82%, rgba(80,55,170,0.1) 100%);
      margin: 1px 4px 0;
    }
    .resu-select {
      width: 100%;
      background: linear-gradient(180deg, #ffffff 0%, #e9f4ff 100%);
      color: #151734;
      border: 3px solid #2e205e;
      padding: 8px 10px;
      border-radius: 8px;
      outline: none;
      margin: 8px 0;
      box-shadow: inset 0 1px 0 rgba(255,255,255,0.92), 0 2px 0 rgba(0,0,0,0.15);
      font-weight: 700;
      min-height: 44px;
    }
    #resu-status {
      font-size: 11px;
      color: #2f2b66;
      text-align: left;
      margin-top: 0;
      height: 20px;
      line-height: 18px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      font-weight: 700;
      border: 2px solid #2b1c59;
      border-radius: 6px;
      background: linear-gradient(180deg, #f7fcff 0%, #dcecff 100%);
      padding: 0 8px;
    }
    .resu-debug { margin-top: 6px; }
    #resu-preview {
      width: 100%;
      height: 100px;
      background: #1a1431;
      border: 3px solid #2f2259;
      color: #c5eeff;
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace;
      font-size: 10px;
      padding: 8px;
      border-radius: 8px;
      resize: none;
      outline: none;
      box-shadow: inset 0 0 0 1px rgba(168,216,239,0.25);
    }
    details > summary {
      color: #2f2b66;
      font-size: 11px;
      cursor: pointer;
      margin-top: 4px;
      list-style: none;
      font-weight: 800;
      border: 2px solid #2b1c59;
      border-radius: 6px;
      background: linear-gradient(180deg, #f7fbff 0%, #ddeeff 100%);
      padding: 4px 8px;
      display: inline-block;
    }
    .resu-debug > summary { margin-top: 0; }
    .resu-panel > * {
      position: relative;
      z-index: 1;
    }
    details > summary:hover {
      color: #fff;
      background: linear-gradient(180deg, #7e63e3 0%, #5037aa 100%);
    }
    .resu-lc-thread {
      --resu-lc-text: #ececf1;
      --resu-lc-text-secondary: #a1a1aa;
      --resu-lc-border: #3f3f46;
      --resu-lc-surface: #171717;
      --resu-lc-surface-hover: #262626;
      padding: 6px 0 26px;
      color: var(--resu-lc-text);
    }
    .resu-lc-turn {
      width: 100%;
      margin: 0;
      color: inherit;
      padding: 0.2rem 0;
    }
    .resu-lc-msg-shell {
      width: 100%;
      border: 0;
      background: transparent;
    }
    .resu-lc-msg-inner {
      position: relative;
      margin: 0 auto;
      display: flex;
      width: 100%;
      padding: 0.08rem 1rem;
      justify-content: center;
    }
    @media (min-width: 640px) {
      .resu-lc-msg-inner { padding: 0.08rem 1.5rem; }
    }
    @media (min-width: 1024px) {
      .resu-lc-msg-inner { padding: 0.08rem 4rem; }
    }
    .resu-lc-msg-main {
      position: relative;
      display: flex;
      width: 100%;
      max-width: 48rem;
      min-width: 0;
      flex-direction: column;
      gap: 0.15rem;
    }
    .resu-lc-turn-user .resu-lc-msg-main {
      width: 100%;
      align-items: flex-end;
    }
    .resu-lc-turn-assistant .resu-lc-msg-main {
      width: 100%;
      align-items: flex-start;
    }
    .resu-lc-sender { display: none; }
    .resu-lc-text-message {
      display: flex;
      min-height: 18px;
      flex-direction: column;
      align-items: stretch;
      width: 100%;
      gap: 0.4rem;
      overflow: visible;
    }
    .resu-lc-turn-user .resu-lc-text-message {
      align-items: flex-end;
    }
    .resu-lc-turn-assistant .resu-lc-text-message {
      align-items: flex-start;
    }
    .resu-lc-markdown {
      width: 100%;
      max-width: 100%;
      word-break: break-word;
      line-height: 1.62;
      font-size: 0.99rem;
      color: var(--resu-lc-text);
      border: 0;
      border-radius: 0;
      padding: 0;
      box-shadow: none;
      background: transparent;
    }
    .resu-lc-turn-assistant .resu-lc-markdown {
      width: 100%;
      background: transparent;
      border: 0;
      border-radius: 0;
      padding: 0;
      box-shadow: none;
      margin-right: auto;
    }
    .resu-lc-turn-user .resu-lc-markdown {
      width: fit-content;
      max-width: min(100%, var(--resu-user-chat-width, 70%));
      background: #323232d9;
      color: #ececec;
      border: 0;
      border-radius: 18px;
      padding: 0.62rem 1rem;
      box-shadow: none;
      margin-left: auto;
    }
    .resu-lc-markdown p { margin: 0 0 0.5rem 0; white-space: pre-wrap; }
    .resu-lc-markdown p:last-child { margin-bottom: 0; }
    .resu-lc-markdown h1,
    .resu-lc-markdown h2,
    .resu-lc-markdown h3,
    .resu-lc-markdown h4,
    .resu-lc-markdown h5,
    .resu-lc-markdown h6 {
      margin: 1rem 0 0.5rem 0;
      color: #f8fafc;
      line-height: 1.28;
      font-weight: 700;
    }
    .resu-lc-markdown h1 { font-size: 1.35rem; }
    .resu-lc-markdown h2 { font-size: 1.2rem; }
    .resu-lc-markdown h3 { font-size: 1.08rem; }
    .resu-lc-markdown ul, .resu-lc-markdown ol { margin: 0 0 0.65rem 1.2rem; }
    .resu-lc-markdown li { margin: 0.2rem 0; }
    .resu-lc-markdown img {
      display: block;
      max-width: min(420px, 100%);
      height: auto;
      border-radius: 0.75rem;
      border: 1px solid #3f3f46;
      margin: 0.2rem 0;
    }
    .resu-lc-markdown a {
      color: #8ab4ff;
      text-decoration: underline;
      text-underline-offset: 2px;
      text-decoration-color: rgba(138,180,255,0.5);
    }
    .resu-lc-markdown a:hover { color: #b6d1ff; }
    .resu-lc-markdown code:not(.resu-code-content) {
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace;
      background: #202123;
      border: 1px solid #3f3f46;
      border-radius: 0.38rem;
      padding: 0.06rem 0.3rem;
      font-size: 0.9em;
      color: #e5e7eb;
    }
    .resu-lc-markdown pre {
      width: 100%;
      margin: 0 0 0.75rem 0;
      padding: 0;
      border: 0;
      background: transparent;
      overflow: visible;
    }
    .resu-code-root { margin: 0 0 0.75rem 0; }
    .resu-code-root .icon-md-heavy {
      stroke-width: 2;
      width: 1.125rem;
      height: 1.125rem;
    }
    .resu-code-content {
      display: block;
      white-space: pre !important;
      font-size: calc(0.85 * var(--markdown-font-size, 1rem));
      line-height: 1.5;
      color: inherit;
      background: transparent !important;
      border: 0 !important;
      padding: 0 !important;
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace;
    }
    .resu-code-root:hover .resu-code-float,
    .resu-code-root:focus-within .resu-code-float {
      pointer-events: auto;
      opacity: 1;
    }
    .resu-copy-code.is-copied .resu-copy-code-live { color: #d1fae5; }
    .resu-lc-math-block {
      width: 100%;
      overflow-x: auto;
      margin: 0 0 0.75rem 0;
      padding: 0.2rem 0;
    }
    .resu-lc-math-block .katex-display {
      margin: 0;
      padding: 0;
    }
    .resu-lc-markdown .katex {
      color: #ececf1;
      font-size: 1.04em;
    }
    .resu-lc-math-raw {
      display: inline-block;
      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace;
      background: #202123;
      border: 1px solid #3f3f46;
      border-radius: 0.3rem;
      padding: 0.12rem 0.3rem;
      color: #e5e7eb;
    }
    .resu-lc-markdown blockquote {
      margin: 0 0 0.7rem 0;
      border-left: 2px solid #4b5563;
      padding: 0.15rem 0 0.15rem 0.75rem;
      color: #d4d4d8;
      background: rgba(24,24,27,0.55);
      border-radius: 0 0.45rem 0.45rem 0;
    }
    .resu-lc-markdown hr { border: 0; border-top: 1px solid #3f3f46; margin: 0.8rem 0; }
    .resu-lc-table-wrap {
      width: 100%;
      overflow-x: auto;
      margin: 0 0 0.75rem 0;
      border: 1px solid #3f3f46;
      border-radius: 0.6rem;
    }
    .resu-lc-table-wrap table { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
    .resu-lc-table-wrap th,
    .resu-lc-table-wrap td {
      border-bottom: 1px solid #3f3f46;
      padding: 0.45rem 0.6rem;
      text-align: left;
      vertical-align: top;
    }
    .resu-lc-table-wrap th { background: #23252b; color: #f3f4f6; font-weight: 700; }
    .resu-lc-table-wrap tr:last-child td { border-bottom: 0; }
    .resu-lc-gallery {
      display: flex;
      flex-wrap: wrap;
      gap: 0.55rem;
      margin: 0 0 0.75rem 0;
    }
    .resu-lc-gallery a {
      display: block;
      width: min(320px, 100%);
      border-radius: 0.75rem;
      overflow: hidden;
      border: 1px solid #3f3f46;
      background: #18181b;
      line-height: 0;
    }
    .resu-lc-gallery img {
      display: block;
      width: 100%;
      height: auto;
      max-height: 320px;
      object-fit: cover;
      margin: 0;
    }
    @media (max-width: 560px) {
      .resu-panel {
        top: 8px;
        right: 8px;
        left: 8px;
        width: auto;
      }
      .resu-toggle-btn {
        right: 8px;
        width: 56px;
        height: 56px;
      }
    }
  `;
    document.head.appendChild(styleEl);
    const wrapper = document.createElement('div');
    wrapper.className = 'resu-wrapper';
    wrapper.innerHTML = `
    <!-- 悬浮开关 -->
    <div id="resu-toggle" class="resu-toggle-btn" title="打开控制面板">
       <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
    </div>
    <!-- 主面板 -->
    <div id="resu-panel" class="resu-panel">
      <div class="resu-header">
        <div class="resu-title">控制面板</div>
        <button id="resu-close" class="resu-close" title="收起">✕</button>
      </div>
      <div class="resu-panel-body">
        <div class="resu-card">
          <div class="resu-row">
            <button id="resu-lib-pick" class="resu-btn resu-btn-secondary resu-btn-fill">📁 绑定库目录</button>
            <button id="resu-lib-refresh" class="resu-btn resu-btn-secondary resu-btn-icon" title="刷新文件列表">🔄</button>
          </div>
          <select id="resu-lib-select" class="resu-select"><option value="">(未绑定或文件夹为空)</option></select>
          <button id="resu-lib-load" class="resu-btn resu-btn-secondary resu-btn-block">📥 读取选中存档</button>
        </div>
        <div class="resu-divider"></div>
        <div class="resu-card">
          <button id="resu-send" disabled class="resu-btn resu-btn-primary resu-btn-block">🧩 渲染聊天记录</button>
          <div class="resu-row resu-row-tight">
            <button id="resu-export" disabled class="resu-btn resu-btn-export resu-btn-fill">💾 备份当前对话</button>
            <button id="resu-clear" disabled class="resu-btn resu-btn-danger resu-btn-fill">🗑️ 清除缓存</button>
          </div>
        </div>
        <div class="resu-card resu-card-status">
          <div id="resu-status">等待操作...</div>
          <details class="resu-debug">
            <summary>调试控制台</summary>
            <textarea id="resu-preview" readonly></textarea>
          </details>
        </div>
      </div>
    </div>
  `;
    document.body.appendChild(wrapper);
    const $p = (sel) => wrapper.querySelector(sel);
    const setStatus = (msg) => {
        const el = $p('#resu-status');
        if (!el) return;
        el.textContent = msg;
        el.title = msg;
    };

    // UI 交互逻辑
    const toggleBtn = $p('#resu-toggle');
    const mainPanel = $p('#resu-panel');
    const closeBtn = $p('#resu-close');
    toggleBtn.addEventListener('click', () => {
        mainPanel.style.display = 'flex';
        toggleBtn.style.display = 'none';
    });
    closeBtn.addEventListener('click', () => {
        mainPanel.style.display = 'none';
        toggleBtn.style.display = 'flex';
    });
    function enableActions() {
        $p('#resu-send').disabled = false;
        $p('#resu-export').disabled = false;
        $p('#resu-clear').disabled = false;
    }
    function disableActions() {
        $p('#resu-send').disabled = true;
        $p('#resu-export').disabled = true;
        $p('#resu-clear').disabled = true;
    }

    // ===================== 13. 闂傚倷绶¢崜锕傚窗濮樿泛纾?=====================
    const overlayState = { prevOverflow: null, enabled: true, runtime: null };
    function overlayOn() { return (overlayState.runtime ?? overlayState.enabled) === true; }
    function ensureOverlay() {
        let ov = document.getElementById('resu-overlay');
        const hasValidNodes = ov &&
            ov.querySelector('#resu-ov-title') &&
            ov.querySelector('#resu-ov-sub') &&
            ov.querySelector('#resu-spin');
        if (hasValidNodes) return ov;
        if (ov) ov.remove();
        ov = document.createElement('div');
        ov.id = 'resu-overlay';
        ov.style.cssText = `position:fixed; inset:0; background:rgba(0,0,0,0.95); z-index:1000000; display:none; align-items:center; justify-content:center; backdrop-filter: blur(4px);`;
        ov.innerHTML = `
      <div style="width:min(500px,90vw); background:#161227; border:1px solid #4a3398; border-radius:16px; padding:24px; box-shadow:0 20px 50px rgba(0,0,0,0.8); color:#fff; text-align:center;">
        <div id="resu-spin" style="margin:0 auto 16px; width:32px; height:32px; border:4px solid rgba(255,255,255,.14); border-top-color:#7b55e8; border-radius:50%; animation:resuSpin 1s linear infinite;"></div>
        <div id="resu-ov-title" style="font-weight:700; font-size:16px; margin-bottom:8px;">正在处理</div>
        <div id="resu-ov-sub" style="color:#b7a7ef; font-size:13px;">请稍候...</div>
      </div>
    `;
        const style = document.createElement('style');
        style.textContent = `@keyframes resuSpin{from{transform:rotate(0)}to{transform:rotate(360deg)}}`;
        ov.appendChild(style);
        document.body.appendChild(ov);
        return ov;
    }
    function getOverlayParts() {
        try {
            let ov = ensureOverlay();
            if (!ov) return null;
            let titleEl = ov.querySelector('#resu-ov-title');
            let subEl = ov.querySelector('#resu-ov-sub');
            let spinEl = ov.querySelector('#resu-spin');
            if (!titleEl || !subEl || !spinEl) {
                ov.remove();
                ov = ensureOverlay();
                if (!ov) return null;
                titleEl = ov.querySelector('#resu-ov-title');
                subEl = ov.querySelector('#resu-ov-sub');
                spinEl = ov.querySelector('#resu-spin');
                if (!titleEl || !subEl || !spinEl) return null;
            }
            return { ov, titleEl, subEl, spinEl };
        } catch (e) {
            console.warn('[Resu] overlay init failed:', e);
            return null;
        }
    }
    function lockScroll() {
        if (overlayState.prevOverflow == null) {
            overlayState.prevOverflow = document.documentElement.style.overflow;
            document.documentElement.style.overflow = 'hidden';
        }
    }
    function unlockScroll() {
        if (overlayState.prevOverflow != null) {
            document.documentElement.style.overflow = overlayState.prevOverflow;
            overlayState.prevOverflow = null;
        }
    }
    function showOverlay(title, sub) {
        if (!overlayOn()) return;
        const parts = getOverlayParts();
        if (!parts) return;
        const { ov, titleEl, subEl, spinEl } = parts;
        ov.style.display = 'flex';
        lockScroll();
        titleEl.textContent = title || '正在渲染…';
        subEl.textContent = sub || '请稍候';
        spinEl.style.display = 'block';
        spinEl.style.animation = 'resuSpin 1s linear infinite';
        spinEl.style.borderColor = 'rgba(255,255,255,.1)';
        spinEl.style.borderTopColor = '#7b55e8';
    }
    function showOverlaySuccess(title, sub) {
        if (!overlayOn()) return;
        const parts = getOverlayParts();
        if (!parts) return;
        const { ov, titleEl, subEl, spinEl } = parts;
        ov.style.display = 'flex';
        lockScroll();
        titleEl.textContent = title || '✅ 操作成功';
        subEl.textContent = sub || '已完成';
        spinEl.style.display = 'block';
        spinEl.style.animation = 'none';
        spinEl.style.borderColor = 'rgba(136,217,255,.35)';
        spinEl.style.borderTopColor = '#88d9ff';
    }
    function showOverlayFail(title, sub) {
        if (!overlayOn()) return;
        const parts = getOverlayParts();
        if (!parts) return;
        const { ov, titleEl, subEl, spinEl } = parts;
        ov.style.display = 'flex';
        lockScroll();
        titleEl.textContent = title || '⚠️ 操作失败';
        subEl.textContent = sub || '已取消';
        spinEl.style.display = 'none';
    }
    function hideOverlay() {
        const ov = document.getElementById('resu-overlay');
        if (ov) ov.style.display = 'none';
        unlockScroll();
    }

    // ===================== 14. 闂備礁鎲$敮妤冩崲閸岀儑缍栭柟鐗堟緲缁€宀勬煛瀹ュ繐顩柣鐔村灩闇夋繝濠傚枤閸庡孩绻涢崱鎰伄缂佽鲸甯¢獮瀣攽閸喐鐦?=====================
    async function loadJsonFromLibrarySelected() {
        if (!FS.dirHandle || !FS.fileList.length) { setStatus('⚠️ 请先点击刷新按钮'); return; }
        const name = $p('#resu-lib-select').value;
        const item = FS.fileList.find(x => x.name === name);
        if (!item) { setStatus('⚠️ 请选择一个有效文件'); return; }
        try {
            const file = await item.handle.getFile();
            const text = await file.text();
            const raw = JSON.parse(text);
            const inferredName = file.name.replace(/\.json$/i, '');
            await loadJsonObjectToState(raw, inferredName);
        } catch (e) {
            console.error(e);
            setStatus('❌ 载入失败,文件损坏?');
        }
    }
    $p('#resu-lib-pick').addEventListener('click', async () => { await pickLibraryFolder(); });
    $p('#resu-lib-refresh').addEventListener('click', async () => { await refreshLibraryList(); });
    $p('#resu-lib-load').addEventListener('click', async () => { await loadJsonFromLibrarySelected(); });
    $p('#resu-export').addEventListener('click', async () => {
        try {
            if (!lastThreadId) return;
            const data = await exportThread(lastThreadId);
            const filename = `${safeFileName(lastThreadTitle)}.json`;
            const jsonString = JSON.stringify(data, null, 2);
            const wrote = await writeJsonToFolderIfPossible(filename, jsonString);
            if (wrote) setStatus(`✅ 已导出到库:${filename}`);
            else { downloadJson(data, filename); setStatus('✅ 已下载文件'); }
        } catch (e) { setStatus('❌ 导出失败'); }
    });
    $p('#resu-clear').addEventListener('click', async () => {
        try {
            if (!lastThreadId) return;
            if (!confirm('确定要清空当前线程的缓存吗?')) return;
            await clearThread(lastThreadId);
            setStatus('🧹 缓存已清空');
        } catch (e) { setStatus('❌ 清空失败'); }
    });
    $p('#resu-send').addEventListener('click', async () => {
        if (!loadedMessages.length) {
            setStatus('⚠️ 请先读取一个存档');
            return;
        }
        try {
            await renderOnlyFlow(loadedMessages);
            showOverlaySuccess('✅ 渲染成功', '已绑定会话ID,刷新可自动恢复');
            setTimeout(() => { hideOverlay(); setStatus('✅ 渲染完成(仅本地)'); }, 1200);
        } catch (e) {
            console.error(e);
            const msg = String(e?.message || e || '未知错误');
            showOverlayFail('❌ 渲染失败', msg);
            setTimeout(() => { hideOverlay(); setStatus(`❌ 渲染失败:${msg}`); }, 2200);
        }
    });

    // ===================== 15. 初始化 =====================
    (async () => {
        disableActions();
        // [设置] 强制默认值
        overlayState.enabled = ENABLE_OVERLAY_DEFAULT;
        if (FS.supported) await loadSavedFolderIfAny();
        else setStatus('就绪 (无文件夹支持)');
        setTimeout(checkAndAutoRender, 1000);
    })().catch(() => setStatus('⚠️ 初始化出错'));
})();


// ===== END 3.js =====

// ===== MERGED UI OVERRIDES (STYLE ONLY) =====
(() => {
    'use strict';
    const STYLE_ID = 'merged-unified-pokeball-ui';
    function injectStyle() {
        if (document.getElementById(STYLE_ID)) return;
        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = `
            :root {
                --merged-ball-right: 12px;
                --merged-ball-bottom: 14px;
                --merged-ball-size: 48px;
                --merged-ball-gap: 2px;
                --merged-ball-border: #111827;
                --merged-ui-bg1: #fff8e7;
                --merged-ui-bg2: #f1e3c2;
                --merged-ui-card: #fffdf7;
                --merged-ui-border: #262626;
                --merged-ui-text: #1c1c1c;
                --merged-ui-muted: #4b4b4b;
                --merged-ui-input-bg: #ffffff;
                --merged-ui-input-text: #1a1a1a;
                --merged-ui-btn-main: #ee1515;
                --merged-ui-btn-main-2: #b30f0f;
                --merged-ui-btn-text: #ffffff;
                --merged-ui-red: #ee1515;
                --merged-ui-red-2: #b30f0f;
                --merged-ui-focus: rgba(238, 21, 21, 0.24);
                --merged-ui-radius-sm: 10px;
                --merged-ui-radius-md: 14px;
            }

            html body #tm-nblm-toggle,
            html body #resu-toggle.resu-toggle-btn,
            html body #gpt-rescue-btn.cge-theme-greatball-fab,
            html body #gpt-rescue-btn.cge-ball-great {
                position: fixed !important;
                left: auto !important;
                top: auto !important;
                right: var(--merged-ball-right) !important;
                width: var(--merged-ball-size) !important;
                height: var(--merged-ball-size) !important;
                margin: 0 !important;
                padding: 0 !important;
                box-sizing: border-box !important;
                display: inline-flex !important;
                align-items: center !important;
                justify-content: center !important;
                border: 2px solid var(--merged-ball-border) !important;
                border-radius: 999px !important;
                box-shadow: 0 5px 12px rgba(17, 24, 39, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.62) !important;
            }

            html body #tm-nblm-toggle {
                bottom: var(--merged-ball-bottom) !important;
                z-index: 12030 !important;
                background: linear-gradient(180deg, #ef4444 0 47%, #111827 47% 53%, #f8fafc 53% 100%) !important;
            }
            html body #tm-nblm-toggle::before {
                width: 16px !important;
                height: 16px !important;
                border-width: 2px !important;
            }
            html body #tm-nblm-toggle::after {
                width: 6px !important;
                height: 6px !important;
            }

            html body #resu-toggle.resu-toggle-btn {
                transform: none !important;
                bottom: calc(var(--merged-ball-bottom) + var(--merged-ball-size) + var(--merged-ball-gap)) !important;
                z-index: 12020 !important;
                background:
                    radial-gradient(circle at 27% 21%, #ffd84a 0 8px, transparent 9px),
                    radial-gradient(circle at 73% 21%, #ffd84a 0 8px, transparent 9px),
                    linear-gradient(180deg, #2f3442 0 47%, #111827 47% 53%, #f8fafc 53% 100%) !important;
            }
            html body #resu-toggle.resu-toggle-btn::before {
                width: 16px !important;
                height: 16px !important;
                border-width: 2px !important;
            }
            html body #resu-toggle.resu-toggle-btn::after {
                top: 6px !important;
                font-size: 10px !important;
                color: #ffe89a !important;
            }
            html body #resu-toggle.resu-toggle-btn:hover {
                transform: none !important;
            }

            html body #gpt-rescue-btn.cge-theme-greatball-fab,
            html body #gpt-rescue-btn.cge-ball-great {
                bottom: calc(var(--merged-ball-bottom) + var(--merged-ball-size) + var(--merged-ball-gap) + var(--merged-ball-size) + var(--merged-ball-gap)) !important;
                z-index: 12010 !important;
            }
            html body #gpt-rescue-btn .cge-ball-label {
                font-size: 10px !important;
                top: 75% !important;
            }

            html body #tm-nblm-toggle:hover,
            html body #gpt-rescue-btn.cge-theme-greatball-fab:hover,
            html body #gpt-rescue-btn.cge-ball-great:hover {
                transform: none !important;
            }

            /* Unified non-pokeball UI theme */
            html body #export-dialog.cge-theme-greatball-dialog,
            html body #tm-nblm-root,
            html body .resu-panel {
                border-color: var(--merged-ui-border) !important;
                color: var(--merged-ui-text) !important;
                font-family: "Trebuchet MS", "Verdana", "Noto Sans SC", sans-serif !important;
            }

            html body #export-dialog.cge-theme-greatball-dialog {
                background: linear-gradient(180deg, var(--merged-ui-bg1) 0%, var(--merged-ui-bg2) 100%) !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog h2 {
                background: linear-gradient(180deg, var(--merged-ui-red), var(--merged-ui-red-2)) !important;
                border-color: var(--merged-ui-border) !important;
                color: #fffdf8 !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog .cge-space-card {
                background: var(--merged-ui-card) !important;
                border-color: var(--merged-ui-border) !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog p,
            html body #export-dialog.cge-theme-greatball-dialog [style*="color: #666"] {
                color: var(--merged-ui-muted) !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog input,
            html body #export-dialog.cge-theme-greatball-dialog select {
                background: var(--merged-ui-input-bg) !important;
                color: var(--merged-ui-input-text) !important;
                border-color: var(--merged-ui-border) !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #select-team-btn,
            html body #export-dialog.cge-theme-greatball-dialog #start-team-export-btn,
            html body #export-dialog.cge-theme-greatball-dialog #schedule-start-btn,
            html body #export-dialog.cge-theme-greatball-dialog #schedule-pick-btn,
            html body #export-dialog.cge-theme-greatball-dialog #schedule-run-now-btn,
            html body #export-dialog.cge-theme-greatball-dialog #export-selected-btn {
                background: linear-gradient(180deg, var(--merged-ui-btn-main), var(--merged-ui-btn-main-2)) !important;
                border-color: #6f0d0d !important;
                color: var(--merged-ui-btn-text) !important;
                text-shadow: none !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #conv-list label,
            html body #export-dialog.cge-theme-greatball-dialog #conv-list [style*="color: #666"] {
                color: var(--merged-ui-input-text) !important;
            }

            html body #tm-nblm-root {
                background: linear-gradient(180deg, var(--merged-ui-bg1) 0%, var(--merged-ui-bg2) 100%) !important;
                box-shadow: 0 14px 30px rgba(30, 24, 14, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.8) !important;
            }
            html body #tm-nblm-head {
                background: linear-gradient(180deg, var(--merged-ui-red), var(--merged-ui-red-2)) !important;
                border-bottom-color: var(--merged-ui-border) !important;
            }
            html body #tm-nblm-title {
                color: #fffdf8 !important;
                text-shadow: none !important;
            }
            html body #tm-nblm-body {
                background-image:
                    radial-gradient(rgba(32, 32, 32, 0.05) 1px, transparent 1px),
                    linear-gradient(180deg, #fff8e7 0%, #f3e6c8 100%) !important;
                background-size: 8px 8px, auto !important;
            }
            html body #tm-nblm-source-wrap,
            html body #tm-nblm-config-wrap,
            html body .tm-nblm-source-item {
                background: var(--merged-ui-card) !important;
                border-color: var(--merged-ui-border) !important;
            }
            html body #tm-nblm-source-count,
            html body .tm-nblm-source-title,
            html body .tm-nblm-source-id,
            html body .tm-nblm-config-item label,
            html body #tm-nblm-options label,
            html body #tm-nblm-status {
                color: var(--merged-ui-muted) !important;
            }
            html body #tm-nblm-root select,
            html body #tm-nblm-root textarea,
            html body #tm-nblm-root input[type="text"],
            html body #tm-nblm-root button:not(#tm-nblm-toggle):not(#tm-nblm-ask-send-float) {
                border-color: var(--merged-ui-border) !important;
            }
            html body #tm-nblm-notebook,
            html body #tm-nblm-reload,
            html body #tm-nblm-body textarea,
            html body #tm-nblm-body input[type="text"] {
                background: var(--merged-ui-input-bg) !important;
                color: var(--merged-ui-input-text) !important;
            }
            html body #tm-nblm-ask {
                background: linear-gradient(180deg, var(--merged-ui-btn-main), var(--merged-ui-btn-main-2)) !important;
                color: var(--merged-ui-btn-text) !important;
                border-color: #6f0d0d !important;
                text-shadow: none !important;
            }

            html body .resu-panel {
                background: linear-gradient(180deg, var(--merged-ui-red) 0 56px, #202020 56px 63px, var(--merged-ui-bg1) 63px 100%) !important;
                box-shadow: 0 16px 32px rgba(30, 24, 14, 0.32), inset 0 0 0 2px rgba(255, 255, 255, 0.38) !important;
            }
            html body .resu-header {
                background: linear-gradient(180deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0) 100%) !important;
                border-bottom-color: rgba(255, 255, 255, 0.38) !important;
            }
            html body .resu-title {
                color: #fffdf8 !important;
            }
            html body .resu-card {
                background: var(--merged-ui-card) !important;
                border-color: var(--merged-ui-border) !important;
            }
            html body .resu-select,
            html body .resu-preview,
            html body #resu-preview {
                background: var(--merged-ui-input-bg) !important;
                color: var(--merged-ui-input-text) !important;
                border-color: var(--merged-ui-border) !important;
            }
            html body .resu-btn-primary,
            html body .resu-btn-secondary,
            html body .resu-btn-export,
            html body .resu-btn-danger {
                background: linear-gradient(180deg, var(--merged-ui-btn-main), var(--merged-ui-btn-main-2)) !important;
                border-color: #6f0d0d !important;
                color: var(--merged-ui-btn-text) !important;
                text-shadow: none !important;
            }
            html body #resu-status,
            html body .resu-debug > summary {
                color: var(--merged-ui-input-text) !important;
                background: var(--merged-ui-input-bg) !important;
                border-color: var(--merged-ui-border) !important;
            }

            /* UI polish: spacing, feedback, readability */
            html body #export-dialog.cge-theme-greatball-dialog {
                border-radius: var(--merged-ui-radius-md) !important;
                box-shadow: 0 22px 44px rgba(20, 16, 10, 0.28) !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog .cge-space-card {
                border-radius: var(--merged-ui-radius-sm) !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog button {
                min-height: 36px !important;
                border-radius: 9px !important;
                font-weight: 800 !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #conv-list {
                scrollbar-width: thin;
                scrollbar-color: #8c8c8c #f6f0df;
            }
            html body #export-dialog.cge-theme-greatball-dialog #conv-list label {
                border-radius: 9px !important;
                transition: background-color 0.14s ease, border-color 0.14s ease !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #conv-list label:hover {
                background: #fff4d8 !important;
                border-color: #b79a63 !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog input[type="checkbox"] {
                accent-color: var(--merged-ui-red);
            }
            html body #export-dialog.cge-theme-greatball-dialog #conv-status,
            html body #export-dialog.cge-theme-greatball-dialog #schedule-status,
            html body #export-dialog.cge-theme-greatball-dialog #schedule-selection-summary,
            html body #export-dialog.cge-theme-greatball-dialog #filename-preview,
            html body #export-dialog.cge-theme-greatball-dialog #download-fixed-file-status {
                color: var(--merged-ui-muted) !important;
            }

            html body #tm-nblm-root {
                border-radius: 18px !important;
            }
            html body #tm-nblm-body {
                scrollbar-width: thin;
                scrollbar-color: #8c8c8c #f6f0df;
            }
            html body #tm-nblm-root button:not(#tm-nblm-toggle):not(#tm-nblm-ask-send-float) {
                min-height: 34px !important;
                border-radius: 9px !important;
                font-weight: 800 !important;
            }
            html body #tm-nblm-root input[type="checkbox"] {
                accent-color: var(--merged-ui-red);
            }
            html body #tm-nblm-source-list {
                scrollbar-width: thin;
                scrollbar-color: #8c8c8c #f6f0df;
            }

            html body .resu-panel {
                border-radius: 12px !important;
            }
            html body .resu-panel .resu-btn {
                min-height: 40px !important;
                border-width: 2px !important;
            }
            html body .resu-panel .resu-panel-body {
                scrollbar-width: thin;
                scrollbar-color: #8c8c8c #f6f0df;
            }

            /* Alignment audit: component size + text baseline + row alignment */
            html body #export-dialog.cge-theme-greatball-dialog {
                text-align: left !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog h2 {
                line-height: 1.2 !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog button,
            html body #export-dialog.cge-theme-greatball-dialog select,
            html body #export-dialog.cge-theme-greatball-dialog input:not([type="checkbox"]):not([type="radio"]) {
                box-sizing: border-box !important;
                min-height: 36px !important;
                line-height: 1.2 !important;
                padding-top: 7px !important;
                padding-bottom: 7px !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog button {
                display: inline-flex !important;
                align-items: center !important;
                justify-content: center !important;
                text-align: center !important;
                vertical-align: middle !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog .cge-picker-row,
            html body #export-dialog.cge-theme-greatball-dialog .cge-picker-footer,
            html body #export-dialog.cge-theme-greatball-dialog .cge-space-actions,
            html body #export-dialog.cge-theme-greatball-dialog .cge-team-footer {
                align-items: center !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog .cge-picker-row span {
                line-height: 1.2 !important;
                align-self: center !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog .cge-picker-row label {
                display: inline-flex !important;
                align-items: center !important;
                gap: 6px !important;
                line-height: 1.2 !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #workspace-id-list label {
                display: flex !important;
                align-items: center !important;
                min-height: 38px !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #workspace-id-list input[type="radio"] {
                margin: 0 !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #conv-list label {
                align-items: flex-start !important;
                gap: 10px !important;
                padding: 9px 10px !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #conv-list label input[type="checkbox"] {
                width: 16px !important;
                height: 16px !important;
                margin-top: 2px !important;
                flex: 0 0 16px !important;
            }
            html body #export-dialog.cge-theme-greatball-dialog #conv-list label > div {
                min-width: 0 !important;
            }

            html body #tm-nblm-root {
                text-align: left !important;
            }
            html body #tm-nblm-row,
            html body #tm-nblm-source-top,
            html body #tm-nblm-source-actions,
            html body #tm-nblm-options {
                align-items: center !important;
            }
            html body #tm-nblm-root button:not(#tm-nblm-toggle):not(#tm-nblm-ask-send-float) {
                box-sizing: border-box !important;
                display: inline-flex !important;
                align-items: center !important;
                justify-content: center !important;
                line-height: 1.2 !important;
                text-align: center !important;
            }
            html body #tm-nblm-notebook,
            html body #tm-nblm-reload,
            html body #tm-nblm-root textarea,
            html body #tm-nblm-root input[type="text"] {
                box-sizing: border-box !important;
                line-height: 1.25 !important;
            }
            html body #tm-nblm-notebook,
            html body #tm-nblm-reload {
                min-height: 34px !important;
            }
            html body #tm-nblm-source-actions button {
                min-height: 30px !important;
                padding: 4px 10px !important;
            }
            html body .tm-nblm-source-item {
                align-items: flex-start !important;
                padding: 7px 8px !important;
            }
            html body .tm-nblm-source-item input[type="checkbox"] {
                width: 16px !important;
                height: 16px !important;
                margin-top: 2px !important;
                flex: 0 0 16px !important;
            }
            html body #tm-nblm-options label {
                line-height: 1.2 !important;
            }

            html body .resu-panel {
                text-align: left !important;
            }
            html body .resu-panel .resu-header {
                min-height: 42px !important;
            }
            html body .resu-panel .resu-btn {
                box-sizing: border-box !important;
                display: flex !important;
                align-items: center !important;
                justify-content: center !important;
                text-align: center !important;
                line-height: 1.2 !important;
            }
            html body .resu-panel .resu-title,
            html body .resu-panel .resu-close {
                line-height: 1 !important;
            }
            html body .resu-panel .resu-select,
            html body .resu-panel #resu-preview {
                box-sizing: border-box !important;
                line-height: 1.3 !important;
            }
            html body .resu-panel #resu-status {
                min-height: 24px !important;
                height: auto !important;
                display: flex !important;
                align-items: center !important;
                line-height: 1.2 !important;
                padding-top: 2px !important;
                padding-bottom: 2px !important;
            }
            html body .resu-panel details > summary {
                display: inline-flex !important;
                align-items: center !important;
                min-height: 30px !important;
                line-height: 1.2 !important;
            }

            html body #export-dialog.cge-theme-greatball-dialog button:focus-visible,
            html body #export-dialog.cge-theme-greatball-dialog input:focus-visible,
            html body #export-dialog.cge-theme-greatball-dialog select:focus-visible,
            html body #tm-nblm-root button:focus-visible,
            html body #tm-nblm-root input:focus-visible,
            html body #tm-nblm-root textarea:focus-visible,
            html body #tm-nblm-root select:focus-visible,
            html body .resu-panel button:focus-visible,
            html body .resu-panel select:focus-visible,
            html body .resu-panel textarea:focus-visible {
                outline: none !important;
                box-shadow: 0 0 0 3px var(--merged-ui-focus) !important;
            }

            @media (prefers-reduced-motion: reduce) {
                html body #export-dialog.cge-theme-greatball-dialog *,
                html body #tm-nblm-root *,
                html body .resu-panel *,
                html body #tm-nblm-toggle,
                html body #resu-toggle.resu-toggle-btn,
                html body #gpt-rescue-btn.cge-theme-greatball-fab,
                html body #gpt-rescue-btn.cge-ball-great {
                    animation: none !important;
                    transition: none !important;
                }
            }

            @media (max-width: 900px) {
                :root {
                    --merged-ball-right: 8px;
                    --merged-ball-bottom: 10px;
                    --merged-ball-size: 44px;
                    --merged-ball-gap: 2px;
                }
                html body #tm-nblm-toggle::before,
                html body #resu-toggle.resu-toggle-btn::before {
                    width: 14px !important;
                    height: 14px !important;
                }
                html body #gpt-rescue-btn .cge-ball-label {
                    font-size: 9px !important;
                }
            }
        `;
        document.head.appendChild(style);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', injectStyle, { once: true });
        return;
    }
    injectStyle();
})();