Multi-Provider Conversation/Chat Markdown Export/Download

Export AI chat conversations to Markdown. Supports Claude.ai, Grok.com, and LMArena.ai

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Multi-Provider Conversation/Chat Markdown Export/Download
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      9.0
// @author       Piknockyou (vibe-coded)
// @license      AGPL-3.0
// @description  Export AI chat conversations to Markdown. Supports Claude.ai, Grok.com, and LMArena.ai
// @match        *://claude.ai/*
// @match        *://grok.com/*
// @match        *://lmarena.ai/*
// @icon         data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='2'><path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/><polyline points='7 10 12 15 17 10'/><line x1='12' y1='15' x2='12' y2='3'/></svg>
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      claude.ai
// @connect      grok.com
// @connect      lmarena.ai
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // ═══════════════════════════════════════════════════════════════════════════
    // CONFIGURATION & PROVIDER REFERENCE
    // ═══════════════════════════════════════════════════════════════════════════
    //
    // This is the single source of truth for:
    //   1. All configuration flags and their default values
    //   2. Provider support matrix (which flags apply to which provider)
    //   3. Provider-specific behavior notes
    //   4. Guide for adding new providers
    //
    // ───────────────────────────────────────────────────────────────────────────
    // PROVIDER SUPPORT MATRIX
    // ───────────────────────────────────────────────────────────────────────────
    //
    // Legend:  C = Claude    G = Grok    L = LMArena
    //          ✓ = Supported            · = Not applicable / No effect
    //
    // MESSAGE CONTENT                              C   G   L   Notes
    // ─────────────────────────────────────────────────────────────────
    // INCLUDE_USER_MESSAGES                        ✓   ✓   ✓
    // INCLUDE_ASSISTANT_MESSAGES                   ✓   ✓   ✓
    // INCLUDE_THINKING                             ✓   ·   ✓   [1]
    // COLLAPSIBLE_THINKING                         ✓   ·   ✓
    // INCLUDE_ATTACHMENTS                          ✓   ✓   ·
    // COLLAPSIBLE_ATTACHMENTS                      ✓   ✓   ·
    // INCLUDE_ARTIFACTS                            ✓   ·   ·
    // COLLAPSIBLE_ARTIFACTS                        ✓   ·   ·
    // INCLUDE_CODE_BLOCKS                          ✓   ✓   ✓
    // COLLAPSIBLE_CODE_BLOCKS                      ✓   ✓   ✓
    //
    // WEB SEARCH                                   C   G   L   Notes
    // ─────────────────────────────────────────────────────────────────
    // INCLUDE_SEARCH_QUERIES                       ✓   ·   ·   [2]
    // INCLUDE_SEARCH_RESULTS                       ✓   ·   ·   [2]
    // COLLAPSIBLE_SEARCH_RESULTS                   ✓   ·   ·
    // INCLUDE_SOURCES                              ✓   ·   ✓   [3]
    // INCLUDE_SOURCES_LIST                         ✓   ✓   ✓
    // COLLAPSIBLE_SOURCES_LIST                     ✓   ✓   ✓
    //
    // METADATA                                     C   G   L   Notes
    // ─────────────────────────────────────────────────────────────────
    // INCLUDE_MODEL_INFO                           ·   ✓   ✓   [4]
    // INCLUDE_THINKING_DURATION                    ·   ✓   ·
    // INCLUDE_TIMESTAMPS                           ✓   ✓   ✓
    // INCLUDE_MESSAGE_IDS                          ✓   ✓   ✓
    //
    // HEADER & FORMATTING                          C   G   L   Notes
    // ─────────────────────────────────────────────────────────────────
    // INCLUDE_HEADER                               ✓   ✓   ✓
    // INCLUDE_ACTIVE_LEAF_INFO                     ✓   ✓   ·   [5]
    // INCLUDE_TURN_NUMBERS                         ✓   ✓   ✓
    // COLLAPSIBLE_MESSAGES                         ✓   ✓   ✓
    //
    // BRANCHING                                    C   G   L   Notes
    // ─────────────────────────────────────────────────────────────────
    // CLAUDE_EXPORT_ALL_REGENERATIONS              ✓   ·   ·
    // GROK_EXPORT_ALL_REGENERATIONS                ·   ✓   ·
    //
    // ───────────────────────────────────────────────────────────────────────────
    // PROVIDER-SPECIFIC BEHAVIOR NOTES
    // ───────────────────────────────────────────────────────────────────────────
    //
    // [1] THINKING
    //     Claude:  "thinking" content blocks in API response
    //     LMArena: "reasoning" field on assistant messages
    //     Grok:    No thinking content; use INCLUDE_THINKING_DURATION instead
    //
    // [2] SEARCH QUERIES & RESULTS (Claude only)
    //     Queries: tool_use blocks with name="web_search"
    //     Results: tool_result blocks containing fetched URLs
    //     These appear BEFORE the answer text in execution order
    //
    // [3] INLINE CITATIONS (INCLUDE_SOURCES)
    //     Claude:  [[Title]](url) superscripts at end_index positions
    //     LMArena: [1], [2], [3] numeric superscripts at charLocation positions
    //     Grok:    No position data; inline citations not possible (list only)
    //
    // [4] MODEL INFO
    //     Claude:  Model name not reliably exposed in API
    //     Grok:    Available in response.model field
    //     LMArena: Scraped from DOM (API only provides UUIDs)
    //              - Direct mode: Sequential assistant index mapping
    //              - Battle mode: participantPosition 'a'/'b' mapping
    //              - DOM uses flex-col-reverse; reversed for direct mode
    //
    // [5] ACTIVE LEAF INFO
    //     Claude:  current_leaf_message_uuid (conversation tree position)
    //     Grok:    Active rid from URL parameter (?rid=...)
    //     LMArena: Linear conversation structure, no branching concept
    //
    // ───────────────────────────────────────────────────────────────────────────
    // ADDING A NEW PROVIDER — CHECKLIST
    // ───────────────────────────────────────────────────────────────────────────
    //
    // When implementing a new provider, complete ALL of the following steps:
    //
    // NOTE: First-run Onboarding Banner
    //   The script shows a one-time onboarding banner (dismissible) explaining:
    //     - Left-click export
    //     - Right-click settings
    //     - Right-drag move
    //     - Supported providers
    //
    //   Implementation is centralized in `OnboardingBanner`:
    //     - Declarative content in OnboardingBanner.CONTENT (single source of truth)
    //     - Styles injected once (idempotent) with OnboardingBanner.STYLES_ID
    //     - Provider icons loaded via FaviconLoader (CSP-safe) from CONTENT.providers
    //     - Dismissal uses CONFIG.HINT_DISMISSED_KEY (stored in localStorage)
    //
    //   When adding a new provider, update OnboardingBanner.CONTENT.providers array.
    //
    // STEP 1: GATHER INFORMATION
    //   □ Sample API/JSON response from the chat endpoint (DevTools → Network)
    //   □ Sample HTML of the chat page (if DOM scraping is needed)
    //   □ URL patterns for chat pages (e.g., /chat/{id}, /c/{id})
    //
    // STEP 2: IDENTIFY APPLICABLE FLAGS
    //   Review each CONFIG flag category and determine support:
    //   □ User/Assistant messages (almost always yes)
    //   □ Thinking/reasoning blocks (check for "reasoning", "thinking" fields)
    //   □ File attachments (uploaded files with content)
    //   □ Artifacts (code/content generation blocks)
    //   □ Web search (tool calls, search results, citations)
    //   □ Inline citations (requires position data like end_index, charLocation)
    //   □ Model info (in API response, or needs DOM scraping?)
    //   □ Timestamps, message IDs
    //   □ Branching/regenerations (conversation tree structure)
    //
    // STEP 3: UPDATE THIS FILE — CONFIG SECTION
    //   □ Update the PROVIDER SUPPORT MATRIX above (add column for new provider)
    //   □ Add any provider-specific flags (e.g., NEWPROVIDER_EXPORT_ALL_REGENS)
    //   □ Add behavior notes if the provider has unique handling
    //
    // STEP 4: UPDATE PROVIDER_FLAGS ARRAY
    //   □ Add new provider entry listing all applicable flag names
    //   □ Order flags logically (matches settings panel display order)
    //
    // STEP 5: UPDATE FLAG_METADATA
    //   □ Add provider-specific labels if wording differs (e.g., "Citations" vs "Sources")
    //   □ Add provider-specific tooltips explaining unique behavior
    //
    // STEP 6: IMPLEMENT PROVIDER MODULE
    //   Create provider object with required interface:
    //   □ name: string (display name, e.g., "NewProvider")
    //   □ hostPattern: RegExp (matches the site's domain)
    //   □ matches(url): boolean
    //   □ extractChatId(url): string | null
    //   □ fetchChat(chatId): Promise<object>
    //   □ generateMarkdown(data, settings): { content, filename, stats }
    //
    // STEP 7: REGISTER PROVIDER
    //   □ Add to Providers.list array
    //   □ Add @match directive to userscript header
    //
    // STEP 8: TEST
    //   □ Verify all applicable flags work correctly
    //   □ Test settings panel shows correct options
    //   □ Test export produces valid Markdown
    //
    // ═══════════════════════════════════════════════════════════════════════════

    const CONFIG = {
        // ─── Message Content ─────────────────────────────────────────────────
        INCLUDE_USER_MESSAGES: true,
        INCLUDE_ASSISTANT_MESSAGES: true,
        INCLUDE_THINKING: true,
        COLLAPSIBLE_THINKING: true,
        INCLUDE_ATTACHMENTS: true,
        COLLAPSIBLE_ATTACHMENTS: true,
        INCLUDE_ARTIFACTS: true,
        COLLAPSIBLE_ARTIFACTS: true,
        INCLUDE_CODE_BLOCKS: true,
        COLLAPSIBLE_CODE_BLOCKS: true,

        // ─── Web Search ──────────────────────────────────────────────────────
        INCLUDE_SEARCH_QUERIES: true,
        INCLUDE_SEARCH_RESULTS: false,        // Off by default (noisy raw data)
        COLLAPSIBLE_SEARCH_RESULTS: true,
        INCLUDE_SOURCES: true,                // Inline citation markers
        INCLUDE_SOURCES_LIST: true,           // Bibliography at end of message
        COLLAPSIBLE_SOURCES_LIST: true,

        // ─── Metadata ────────────────────────────────────────────────────────
        INCLUDE_MODEL_INFO: true,
        INCLUDE_THINKING_DURATION: false,
        INCLUDE_TIMESTAMPS: false,
        INCLUDE_MESSAGE_IDS: false,

        // ─── Header & Formatting ─────────────────────────────────────────────
        INCLUDE_HEADER: true,
        INCLUDE_ACTIVE_LEAF_INFO: true,
        INCLUDE_TURN_NUMBERS: true,
        COLLAPSIBLE_MESSAGES: false,

        // ─── Branching ───────────────────────────────────────────────────────
        CLAUDE_EXPORT_ALL_REGENERATIONS: false,
        GROK_EXPORT_ALL_REGENERATIONS: false,

        // ─── File Naming ─────────────────────────────────────────────────────
        FILENAME_MAX_LEN: 80,

        // ─── UI (Button) ─────────────────────────────────────────────────────
        BUTTON_SIZE: 50,
        BUTTON_COLOR_READY: '#22c55e',
        BUTTON_COLOR_LOADING: '#f59e0b',
        BUTTON_COLOR_ERROR: '#ef4444',
        Z_INDEX: 2147483647,
        STORAGE_KEY: 'chat_export_pos',
        SETTINGS_STORAGE_PREFIX: 'chat_export_config_',
        HINT_DISMISSED_KEY: 'chat_export_hint_dismissed'
    };

    // ───────────────────────────────────────────────────────────────────────────
    // PROVIDER FLAG APPLICABILITY
    // ───────────────────────────────────────────────────────────────────────────
    // Maps CONFIG flags to providers for the settings panel UI.
    // Order determines display order in settings menu.
    // See PROVIDER SUPPORT MATRIX in CONFIG section for capability details.
    //
    const PROVIDER_FLAGS = {
        Claude: [
            'INCLUDE_USER_MESSAGES',
            'INCLUDE_ASSISTANT_MESSAGES',
            'INCLUDE_TURN_NUMBERS',
            'COLLAPSIBLE_MESSAGES',
            'INCLUDE_CODE_BLOCKS',
            'COLLAPSIBLE_CODE_BLOCKS',
            'INCLUDE_THINKING',
            'COLLAPSIBLE_THINKING',
            'INCLUDE_ATTACHMENTS',
            'COLLAPSIBLE_ATTACHMENTS',
            'INCLUDE_ARTIFACTS',
            'COLLAPSIBLE_ARTIFACTS',
            'INCLUDE_SEARCH_QUERIES',
            'INCLUDE_SEARCH_RESULTS',
            'COLLAPSIBLE_SEARCH_RESULTS',
            'INCLUDE_SOURCES',
            'INCLUDE_SOURCES_LIST',
            'COLLAPSIBLE_SOURCES_LIST',
            'INCLUDE_TIMESTAMPS',
            'INCLUDE_MESSAGE_IDS',
            'INCLUDE_HEADER',
            'INCLUDE_ACTIVE_LEAF_INFO',
            'CLAUDE_EXPORT_ALL_REGENERATIONS'
        ],
        Grok: [
            'INCLUDE_USER_MESSAGES',
            'INCLUDE_ASSISTANT_MESSAGES',
            'INCLUDE_TURN_NUMBERS',
            'COLLAPSIBLE_MESSAGES',
            'INCLUDE_CODE_BLOCKS',
            'COLLAPSIBLE_CODE_BLOCKS',
            'INCLUDE_ATTACHMENTS',
            'COLLAPSIBLE_ATTACHMENTS',
            'INCLUDE_SOURCES_LIST',
            'COLLAPSIBLE_SOURCES_LIST',
            'INCLUDE_MODEL_INFO',
            'INCLUDE_THINKING_DURATION',
            'INCLUDE_TIMESTAMPS',
            'INCLUDE_MESSAGE_IDS',
            'INCLUDE_HEADER',
            'INCLUDE_ACTIVE_LEAF_INFO',
            'GROK_EXPORT_ALL_REGENERATIONS'
        ],
        LMArena: [
            'INCLUDE_USER_MESSAGES',
            'INCLUDE_ASSISTANT_MESSAGES',
            'INCLUDE_TURN_NUMBERS',
            'COLLAPSIBLE_MESSAGES',
            'INCLUDE_CODE_BLOCKS',
            'COLLAPSIBLE_CODE_BLOCKS',
            'INCLUDE_THINKING',
            'COLLAPSIBLE_THINKING',
            'INCLUDE_SOURCES',
            'INCLUDE_SOURCES_LIST',
            'COLLAPSIBLE_SOURCES_LIST',
            'INCLUDE_MODEL_INFO',
            'INCLUDE_TIMESTAMPS',
            'INCLUDE_MESSAGE_IDS',
            'INCLUDE_HEADER'
        ]
    };

    // Human-readable labels and grouping for settings UI
    const FLAG_METADATA = {
        // Group: Messages
        INCLUDE_USER_MESSAGES: { label: 'Include User Messages', group: 'Messages' },
        INCLUDE_ASSISTANT_MESSAGES: { label: 'Include Assistant Messages', group: 'Messages' },
        INCLUDE_TURN_NUMBERS: { label: 'Include Turn Numbers', group: 'Messages', tooltip: 'Add sequential numbers to message headers: [1] USER, [2] ASSISTANT, etc.' },
        COLLAPSIBLE_MESSAGES: { label: 'Collapsible Messages', group: 'Messages', tooltip: 'Wrap each message turn in a collapsible <details> section.' },

        // Group: Thinking
        INCLUDE_THINKING: { label: 'Include Thinking/Reasoning', group: 'Thinking' },
        COLLAPSIBLE_THINKING: { label: 'Collapsible Thinking', group: 'Thinking', indent: true },
        INCLUDE_THINKING_DURATION: { label: 'Show Thinking Duration', group: 'Thinking' },

        // Group: Attachments
        INCLUDE_ATTACHMENTS: { label: 'Include Attachments', group: 'Attachments' },
        COLLAPSIBLE_ATTACHMENTS: { label: 'Collapsible Attachments', group: 'Attachments', indent: true },

        // Group: Artifacts
        INCLUDE_ARTIFACTS: { label: 'Include Artifacts', group: 'Artifacts' },
        COLLAPSIBLE_ARTIFACTS: { label: 'Collapsible Artifacts', group: 'Artifacts', indent: true },

        // Group: Code
        INCLUDE_CODE_BLOCKS: { label: 'Include Code Blocks', group: 'Code' },
        COLLAPSIBLE_CODE_BLOCKS: { label: 'Collapsible Code Blocks', group: 'Code', indent: true },

        // Group: Web Search
        // Ordered by Execution Flow: Queries → Results → Inline → List
        INCLUDE_SEARCH_QUERIES: {
            label: 'Include Search Queries',
            group: 'Web Search',
            tooltip: 'Show the search terms Claude sent to the search engine.\nAppears as: 🔍 Searching: "query"'
        },
        INCLUDE_SEARCH_RESULTS: {
            label: 'Include Search Results',
            group: 'Web Search',
            tooltip: 'Show the raw list of URLs returned by the search engine.\nThis is the data Claude received before writing its answer.\n(Often noisy; disabled by default)'
        },
        COLLAPSIBLE_SEARCH_RESULTS: {
            label: 'Collapsible Search Results',
            group: 'Web Search',
            indent: true,
            tooltip: 'Wrap the raw search results in a collapsible <details> section.'
        },
        INCLUDE_SOURCES: {
            label: {
                Claude: 'Include Inline Citations',
                LMArena: 'Include Inline Citations'
            },
            group: 'Web Search',
            tooltip: {
                Claude: 'Insert superscript citation markers into the message text.\nFormat: [[Title]](url) at each citation position.',
                LMArena: 'Insert numbered superscript markers into the message text.\nFormat: [1], [2], etc. linking to the source URL.'
            }
        },
        INCLUDE_SOURCES_LIST: {
            label: 'Include Sources List',
            group: 'Web Search',
            tooltip: {
                Claude: 'Append a numbered bibliography at the end of the message.\nLists all cited sources for easy reference.',
                LMArena: 'Append a numbered bibliography at the end of the message.\nLists all cited sources for easy reference.',
                Grok: 'Append the list of web sources at the end of the message.\n(Grok does not support inline citations.)'
            }
        },
        COLLAPSIBLE_SOURCES_LIST: {
            label: 'Collapsible Sources List',
            group: 'Web Search',
            indent: true,
            tooltip: 'Wrap the sources list in a collapsible <details> section.\nKeeps the export cleaner when there are many sources.'
        },

        // Group: Metadata
        INCLUDE_MODEL_INFO: { label: 'Show Model Names', group: 'Metadata' },
        INCLUDE_TIMESTAMPS: { label: 'Show Timestamps', group: 'Metadata' },
        INCLUDE_MESSAGE_IDS: { label: 'Show Message IDs', group: 'Metadata' },

        // Group: Header
        INCLUDE_HEADER: { label: 'Include Header', group: 'Header' },
        INCLUDE_ACTIVE_LEAF_INFO: { label: 'Show Active Leaf/Response ID', group: 'Header', indent: true },

        // Group: Branching
        CLAUDE_EXPORT_ALL_REGENERATIONS: {
            label: 'Export All Regenerations',
            group: 'Branching',
            tooltip: '• Enabled: export ALL messages chronologically, including regenerated responses\n• Disabled: export only the currently selected conversation path'
        },
        GROK_EXPORT_ALL_REGENERATIONS: {
            label: 'Export All Regenerations',
            group: 'Branching',
            tooltip: '• Enabled: export ALL responses chronologically, including regenerations\n• Disabled: export only the currently viewed response chain'
        }
    };

    // Group display order
    const FLAG_GROUP_ORDER = [
        'Messages', 'Code', 'Thinking', 'Attachments', 'Artifacts',
        'Web Search', 'Metadata', 'Header', 'Branching'
    ];

    // ═══════════════════════════════════════════════════════════════════════════
    // SETTINGS STORAGE (Per-Provider)
    // ═══════════════════════════════════════════════════════════════════════════
    const Settings = {
        /**
         * Load provider-specific settings and merge with base CONFIG.
         * @param {string} providerName - e.g., 'Claude', 'Grok', 'LMArena'
         * @returns {object} - Merged settings object
         */
        load(providerName) {
            if (!providerName) {
                console.log('[Chat Exporter] Settings.load() called without provider, returning CONFIG');
                return { ...CONFIG };
            }

            try {
                const key = CONFIG.SETTINGS_STORAGE_PREFIX + providerName;
                const saved = localStorage.getItem(key);
                console.log('[Chat Exporter] Settings.load() for', providerName, '- raw storage:', saved);

                if (saved) {
                    const overrides = JSON.parse(saved);
                    const merged = { ...CONFIG, ...overrides };
                    console.log('[Chat Exporter] Settings.load() merged result - INCLUDE_USER_MESSAGES:', merged.INCLUDE_USER_MESSAGES, 'INCLUDE_ASSISTANT_MESSAGES:', merged.INCLUDE_ASSISTANT_MESSAGES);
                    return merged;
                }
            } catch (e) {
                console.warn('[Chat Exporter] Failed to load settings:', e);
            }

            console.log('[Chat Exporter] Settings.load() no overrides found, returning CONFIG defaults');
            return { ...CONFIG };
        },

        /**
         * Save provider-specific setting override.
         * @param {string} providerName
         * @param {string} flagName
         * @param {boolean} value
         */
        save(providerName, flagName, value) {
            if (!providerName) return;

            try {
                const key = CONFIG.SETTINGS_STORAGE_PREFIX + providerName;
                let overrides = {};

                const saved = localStorage.getItem(key);
                if (saved) {
                    overrides = JSON.parse(saved);
                }

                overrides[flagName] = value;
                localStorage.setItem(key, JSON.stringify(overrides));
                console.log('[Chat Exporter] Settings.save() -', flagName, '=', value, 'for', providerName);
                console.log('[Chat Exporter] Settings.save() - full overrides now:', overrides);
            } catch (e) {
                console.warn('[Chat Exporter] Failed to save settings:', e);
            }
        },

        /**
         * Get current effective value of a flag for a provider.
         * @param {string} providerName
         * @param {string} flagName
         * @returns {boolean}
         */
        get(providerName, flagName) {
            const settings = this.load(providerName);
            return settings[flagName];
        },

        /**
         * Reset all settings for a provider to defaults.
         * @param {string} providerName
         */
        reset(providerName) {
            if (!providerName) return;
            const key = CONFIG.SETTINGS_STORAGE_PREFIX + providerName;
            localStorage.removeItem(key);
            console.log('[Chat Exporter] Settings.reset() - cleared all overrides for', providerName);
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // UTILITY FUNCTIONS
    // ═══════════════════════════════════════════════════════════════════════════
    const Utils = {
        /**
         * Sanitize text for use in markdown (strips markdown special chars).
         * @param {string} s - Raw text
         * @param {string} fallback - Fallback if result is empty
         * @returns {string} - Sanitized text safe for markdown display
         */
        sanitize(s, fallback = 'Untitled') {
            if (!s) return fallback;
            return s.replace(/[\r\n]+/g, ' ')
                    .replace(/[#`*\[\]:\/\\?*|"<>;]/g, '')
                    .trim() || fallback;
        },

        /**
         * Sanitize for filename (applies sanitize + length limit + underscore spaces).
         * @param {string} s - Raw text
         * @param {string} fallback - Fallback if result is empty
         * @returns {string} - Safe filename (without extension)
         */
        sanitizeFilename(s, fallback = 'Export') {
            const clean = this.sanitize(s, fallback);
            return clean.substring(0, CONFIG.FILENAME_MAX_LEN).replace(/\s+/g, '_');
        },

        formatDate(dateStr) {
            return dateStr ? new Date(dateStr).toLocaleString() : new Date().toLocaleString();
        },

        escapeMarkdown(text) {
            return text.replace(/([\\`*_{}[\]()#+\-.!])/g, '\\$1');
        },

        /**
         * Wrap code blocks (```...```) in collapsible <details> sections
         * @param {string} text - Markdown text with code blocks
         * @returns {string} - Text with code blocks wrapped in <details>
         */
        escapeHtml(s) {
            return String(s ?? '')
                .replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#39;');
        },

        /**
         * Create a "safe" fenced code block that cannot be prematurely closed by
         * backticks inside the content (e.g. when a markdown file contains ```).
         * We choose a fence length of (max backtick run in content + 1).
         */
        makeCodeFence(content, lang = '') {
            const text = String(content ?? '').replace(/\r\n/g, '\n');
            const runs = text.match(/`+/g) || [];
            let maxRun = 0;
            for (const r of runs) {
                if (r.length > maxRun) maxRun = r.length;
            }

            const fenceLen = Math.max(3, maxRun + 1);
            const fence = '`'.repeat(fenceLen);
            const info = lang ? String(lang) : '';

            const body = text.endsWith('\n') ? text : (text + '\n');
            return `${fence}${info}\n${body}${fence}`;
        },

        /**
         * Format a thinking/reasoning block as markdown.
         * Used by Claude (thinking blocks) and LMArena (reasoning field).
         * @param {string} thinking - Raw thinking/reasoning text
         * @returns {string} - Formatted markdown string
         */
        formatThinkingBlock(thinking, cfg = null) {
            if (!thinking) return '';
            const settings = cfg || CONFIG;

            const quoted = thinking.replace(/\n/g, '\n> ');

            if (settings.COLLAPSIBLE_THINKING) {
                return `<details>\n<summary><strong>💭 Thinking Process</strong></summary>\n\n> ${quoted}\n\n</details>\n\n`;
            } else {
                return `> **💭 Thinking:**\n> \n> ${quoted}\n\n`;
            }
        },

        /**
         * Process code blocks in text based on settings.
         * If INCLUDE_CODE_BLOCKS is false, removes all code blocks.
         * If COLLAPSIBLE_CODE_BLOCKS is true, wraps them in <details>.
         * Otherwise, leaves them as-is.
         * @param {string} text - Markdown text with code blocks
         * @param {object} cfg - Settings object
         * @returns {string} - Processed text
         */
        processCodeBlocks(text, cfg = null) {
            if (!text) return text;
            const settings = cfg || CONFIG;

            // If code blocks disabled, strip them entirely
            if (!settings.INCLUDE_CODE_BLOCKS) {
                return this.stripCodeBlocks(text);
            }

            // If not collapsible, return as-is
            if (!settings.COLLAPSIBLE_CODE_BLOCKS) {
                return text;
            }

            // Wrap in collapsible
            return this.wrapCodeBlocksCollapsible(text);
        },

        /**
         * Strip all fenced code blocks from text
         * @param {string} text - Markdown text with code blocks
         * @returns {string} - Text with code blocks removed
         */
        stripCodeBlocks(text) {
            if (!text) return text;

            const normalized = String(text).replace(/\r\n/g, '\n');
            const lines = normalized.split('\n');

            let out = [];
            let inFence = false;
            let fenceLen = 0;

            for (const line of lines) {
                const m = line.match(/^(`{3,})(.*)$/);
                if (m) {
                    const ticks = m[1];
                    if (!inFence) {
                        inFence = true;
                        fenceLen = ticks.length;
                    } else if (ticks.length >= fenceLen) {
                        inFence = false;
                        fenceLen = 0;
                    }
                    continue;
                }

                if (!inFence) {
                    out.push(line);
                }
            }

            return out.join('\n');
        },

        /**
         * Wrap code blocks (```...```) in collapsible <details> sections
         * @param {string} text - Markdown text with code blocks
         * @returns {string} - Text with code blocks wrapped in <details>
         */
        wrapCodeBlocksCollapsible(text) {
            if (!text) return text;

            const normalized = String(text).replace(/\r\n/g, '\n');

            // State machine to support BOTH:
            // - complete fenced blocks
            // - incomplete blocks (e.g., streaming/partial responses without a closing fence)
            // Also supports fences longer than 3 backticks (e.g. ````) and won't close early on ```
            const lines = normalized.split('\n');

            let out = [];
            let inFence = false;
            let fenceLen = 0;
            let fenceLang = '';
            let buf = [];
            let blockIndex = 0;

            const flushFence = (isTruncated) => {
                blockIndex++;

                // Use first token only as fence info string (best practice for Markdown code fences)
                const safeLang = (fenceLang || '').trim().split(/\s+/)[0];
                const langLabel = safeLang ? ` (${safeLang})` : '';
                const lineCount = buf.length;

                const summary = `💻 Code Block${langLabel}${isTruncated ? ' — truncated' : ''} — ${lineCount} lines`;
                const fenced = this.makeCodeFence(buf.join('\n'), safeLang);

                out.push(
                    `<details>\n` +
                    `<summary><strong>${this.escapeHtml(summary)}</strong></summary>\n\n` +
                    `${fenced}\n\n` +
                    `</details>`
                );

                buf = [];
                fenceLen = 0;
                fenceLang = '';
            };

            for (const line of lines) {
                // Any fence of 3+ backticks at start of line
                const m = line.match(/^(`{3,})(.*)$/);
                if (m) {
                    const ticks = m[1];
                    const rest = (m[2] || '');

                    if (!inFence) {
                        // Opening fence
                        inFence = true;
                        fenceLen = ticks.length;
                        fenceLang = rest.trim();
                        buf = [];
                    } else {
                        // Closing fence: must be at least as long as opening fence
                        if (ticks.length >= fenceLen) {
                            inFence = false;
                            flushFence(false);
                        } else {
                            // Treat as content if it's a shorter fence inside a longer-fenced block
                            buf.push(line);
                        }
                    }
                    continue;
                }

                if (inFence) {
                    buf.push(line);
                } else {
                    out.push(line);
                }
            }

            // If message ended mid-fence (common with Grok partial responses), close it for export
            if (inFence) {
                flushFence(true);
            }

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

    // ═══════════════════════════════════════════════════════════════════════════
    // PROVIDER: CLAUDE
    // ═══════════════════════════════════════════════════════════════════════════
    const ClaudeProvider = {
        name: 'Claude',
        hostPattern: /claude\.ai/,
        orgId: null,

        matches(url) {
            return this.hostPattern.test(url);
        },

        extractChatId(url) {
            const match = url.match(/\/chat\/([a-z0-9-]+)/);
            return match ? match[1] : null;
        },

        async getOrgId() {
            if (this.orgId) return this.orgId;
            const resp = await fetch('/api/organizations');
            if (!resp.ok) throw new Error('Failed to fetch Org ID');
            const orgs = await resp.json();
            if (orgs && orgs.length > 0) {
                this.orgId = orgs[0].uuid;
                return this.orgId;
            }
            throw new Error('No Organization found');
        },

        async fetchChat(chatId) {
            const orgId = await this.getOrgId();
            const url = `/api/organizations/${orgId}/chat_conversations/${chatId}?tree=True&rendering_mode=messages&render_all_tools=true&consistency=strong`;
            const resp = await fetch(url, {
                headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }
            });
            if (!resp.ok) throw new Error(`API error: ${resp.status}`);
            return await resp.json();
        },

        generateMarkdown(data, settings = null) {
            // Use provider-specific settings if provided, otherwise fall back to CONFIG
            const cfg = settings || CONFIG;
            console.log('[Chat Exporter] ClaudeProvider.generateMarkdown() using cfg.INCLUDE_USER_MESSAGES:', cfg.INCLUDE_USER_MESSAGES, 'cfg.INCLUDE_ASSISTANT_MESSAGES:', cfg.INCLUDE_ASSISTANT_MESSAGES);

            const title = Utils.sanitize(data.name, 'Claude_Export');
            const activeLeaf = data?.current_leaf_message_uuid || null;

            let md = `# ${title}\n\n`;

            if (cfg.INCLUDE_HEADER) {
                md += `> **Provider:** Claude  \n`;
                md += `> **Date:** ${new Date().toLocaleString()}  \n`;
                md += `> **Source:** [Claude.ai](${location.href})  \n`;
                if (cfg.INCLUDE_ACTIVE_LEAF_INFO && activeLeaf) {
                    md += `> **Active leaf:** \`${activeLeaf}\`  \n`;
                }
                md += `\n---\n\n`;
            }

            if (!data.chat_messages) return { content: md, filename: 'empty.md', stats: { total: 0, exported: 0 } };

            // Claude conversations are a tree. Regenerations create siblings (same parent_message_uuid).
            // To export ONLY the currently selected path, walk parents from current_leaf_message_uuid.
            const ROOT_PARENT_UUID = '00000000-0000-4000-8000-000000000000';

            const byId = new Map();
            data.chat_messages.forEach(m => {
                if (m?.uuid) byId.set(m.uuid, m);
            });

            let messagesToExport = data.chat_messages;

            if (cfg.CLAUDE_EXPORT_ALL_REGENERATIONS) {
                // Export the full conversation history in chronological order.
                // This includes regenerated assistant replies (siblings) under the same parent.
                messagesToExport = (data.chat_messages || []).slice().sort((a, b) => {
                    const timeOrIndex = (m) => {
                        const t = m?.created_at ? new Date(m.created_at).getTime() : NaN;
                        if (Number.isFinite(t)) return t;
                        return (typeof m?.index === 'number') ? m.index : 0;
                    };

                    const ta = timeOrIndex(a);
                    const tb = timeOrIndex(b);
                    if (ta !== tb) return ta - tb;

                    const ia = (typeof a?.index === 'number') ? a.index : 0;
                    const ib = (typeof b?.index === 'number') ? b.index : 0;
                    if (ia !== ib) return ia - ib;

                    const ua = String(a?.uuid || '');
                    const ub = String(b?.uuid || '');
                    return ua.localeCompare(ub);
                });
            } else if (activeLeaf && byId.has(activeLeaf)) {
                const chain = [];
                const seen = new Set();
                let cur = byId.get(activeLeaf);

                while (cur && cur.uuid && !seen.has(cur.uuid)) {
                    seen.add(cur.uuid);
                    chain.push(cur);

                    const parentId = cur.parent_message_uuid;
                    if (!parentId || parentId === ROOT_PARENT_UUID) break;

                    cur = byId.get(parentId);
                    if (!cur) break;
                }

                chain.reverse();

                // Only use the chain if it looks sane (at least 1 message).
                if (chain.length > 0) {
                    messagesToExport = chain;
                }
            }

            // Track artifact state across the exported path so we can reconstruct "update" versions.
            // Key: artifact input.id, Value: { content, language, title, type }
            const artifactStateById = new Map();

            let turnNumber = 0;
            let exportedCount = 0;

            messagesToExport.forEach((msg) => {
                const isHuman = msg.sender === 'human';
                const role = isHuman ? 'USER' : 'CLAUDE';

                // Skip based on config
                if (isHuman && !cfg.INCLUDE_USER_MESSAGES) return;
                if (!isHuman && !cfg.INCLUDE_ASSISTANT_MESSAGES) return;

                turnNumber++;
                exportedCount++;

                // Build header with optional turn number
                let header = role;
                if (cfg.INCLUDE_TURN_NUMBERS) {
                    header = `[${turnNumber}] ${header}`;
                }

                if (cfg.COLLAPSIBLE_MESSAGES) {
                    md += `<details>\n<summary><strong>${header}</strong></summary>\n\n`;
                } else {
                    md += `## ${header}\n\n`;
                }

                // Optional: show timestamp
                if (cfg.INCLUDE_TIMESTAMPS && msg.created_at) {
                    md += `*${new Date(msg.created_at).toLocaleString()}*\n\n`;
                }

                // Optional: show message ID
                if (cfg.INCLUDE_MESSAGE_IDS && msg.uuid) {
                    md += `\`ID: ${msg.uuid}\`\n\n`;
                }

                // 1) Attachments (Files uploaded by user)
                // IMPORTANT: attachments can contain markdown with ``` fences and <details> tags.
                // To prevent breaking the outer export structure, we always wrap attachment content
                // in a "safe" fenced code block with a longer backtick fence.
                if (cfg.INCLUDE_ATTACHMENTS && msg.attachments?.length > 0) {
                    msg.attachments.forEach(att => {
                        if (att.extracted_content) {
                            const name = att.file_name || 'unnamed';
                            const raw = String(att.extracted_content ?? '').replace(/\r\n/g, '\n');

                            const lower = String(name).toLowerCase();
                            let lang = '';
                            if (lower.endsWith('.md') || lower.endsWith('.markdown')) lang = 'markdown';
                            else if (lower.endsWith('.txt')) lang = '';
                            else if (lower.endsWith('.ps1')) lang = 'powershell';
                            else if (lower.endsWith('.json')) lang = 'json';
                            else if (lower.endsWith('.yml') || lower.endsWith('.yaml')) lang = 'yaml';
                            else if (lower.endsWith('.js')) lang = 'javascript';
                            else if (lower.endsWith('.ts')) lang = 'typescript';
                            else if (lower.endsWith('.py')) lang = 'python';
                            else if (lower.endsWith('.bat') || lower.endsWith('.cmd')) lang = 'batch';

                            const fenced = Utils.makeCodeFence(raw, lang);

                            if (cfg.COLLAPSIBLE_ATTACHMENTS) {
                                md += `<details>\n<summary><strong>📎 Attached File: ${Utils.escapeHtml(name)}</strong></summary>\n\n${fenced}\n\n</details>\n\n`;
                            } else {
                                md += `**📎 Attached File: ${name}**\n\n${fenced}\n\n`;
                            }
                        }
                    });
                }

                // 2) Content array (Thinking, Text, Artifacts, Web search tool results)
                if (msg.content && Array.isArray(msg.content)) {
                    msg.content.forEach(block => {
                        if (block.type === 'text') {
                            let text = block.text || '';
                            let citations = [];

                            // Collect citations from this text block
                            if (block.citations?.length) {
                                const sortedCitations = [...block.citations]
                                    .filter(c => typeof c.end_index === 'number' && c.url)
                                    .sort((a, b) => b.end_index - a.end_index);

                                sortedCitations.forEach(c => {
                                    citations.unshift({
                                        title: c.title || c.url,
                                        url: c.url,
                                        source: c.metadata?.site_name || c.metadata?.site_domain || '',
                                        end_index: c.end_index
                                    });
                                });

                                // Insert inline citation markers (if enabled)
                                if (cfg.INCLUDE_SOURCES) {
                                    // Insert from end to start to preserve positions
                                    sortedCitations.forEach(c => {
                                        const pos = Math.min(c.end_index, text.length);
                                        const linkTitle = (c.title || 'source').replace(/\n/g, ' ').substring(0, 30);
                                        text = text.slice(0, pos) + `<sup>[[${linkTitle}](${c.url})]</sup>` + text.slice(pos);
                                    });
                                }
                            }

                            // Process code blocks based on settings
                            text = Utils.processCodeBlocks(text, cfg);

                            md += `${text}\n\n`;

                            // Add sources list section after this text block (if enabled)
                            if (cfg.INCLUDE_SOURCES_LIST && citations.length) {
                                if (cfg.COLLAPSIBLE_SOURCES_LIST) {
                                    md += `<details>\n<summary><strong>📚 Sources (${citations.length})</strong></summary>\n\n`;
                                    citations.forEach((c, idx) => {
                                        const t = (c.title || c.url).replace(/\n/g, ' ').trim();
                                        const s = c.source ? ` — ${c.source}` : '';
                                        md += `${idx + 1}. [${t}](${c.url})${s}\n`;
                                    });
                                    md += `\n</details>\n\n`;
                                } else {
                                    md += `**📚 Sources (${citations.length}):**\n\n`;
                                    citations.forEach((c, idx) => {
                                        const t = (c.title || c.url).replace(/\n/g, ' ').trim();
                                        const s = c.source ? ` — ${c.source}` : '';
                                        md += `${idx + 1}. [${t}](${c.url})${s}\n`;
                                    });
                                    md += `\n`;
                                }
                            }
                        }
                        else if (block.type === 'thinking' && cfg.INCLUDE_THINKING) {
                            md += Utils.formatThinkingBlock(block.thinking, cfg);
                        }
                        else if (block.type === 'tool_result' && block.name === 'web_search' && cfg.INCLUDE_SEARCH_RESULTS) {
                            // Web search results in block.content[] with { type: "knowledge", title, url, metadata }
                            const items = Array.isArray(block.content) ? block.content : [];
                            const searchResults = items.filter(it => it && it.url);

                            if (searchResults.length) {
                                if (cfg.COLLAPSIBLE_SEARCH_RESULTS) {
                                    md += `<details>\n<summary><strong>🔍 Search Results (${searchResults.length})</strong></summary>\n\n`;
                                    searchResults.forEach((it, idx) => {
                                        const title = (it.title || it.url || '').replace(/\n/g, ' ').trim();
                                        const source = it.metadata?.site_name || it.metadata?.site_domain || '';
                                        md += `${idx + 1}. [${title}](${it.url})${source ? ` — ${source}` : ''}\n`;
                                    });
                                    md += `\n</details>\n\n`;
                                } else {
                                    md += `**🔍 Search Results (${searchResults.length}):**\n\n`;
                                    searchResults.forEach((it, idx) => {
                                        const title = (it.title || it.url || '').replace(/\n/g, ' ').trim();
                                        const source = it.metadata?.site_name || it.metadata?.site_domain || '';
                                        md += `${idx + 1}. [${title}](${it.url})${source ? ` — ${source}` : ''}\n`;
                                    });
                                    md += `\n`;
                                }
                            }
                        }
                        else if (block.type === 'tool_use' && block.name === 'web_search' && cfg.INCLUDE_SEARCH_QUERIES) {
                            // Show the query
                            const q = block.input?.query;
                            if (q) {
                                md += `🔍 *Searching:* \`${q}\`\n\n`;
                            }
                        }
                        else if (block.type === 'tool_use' && block.name === 'artifacts' && cfg.INCLUDE_ARTIFACTS) {
                            const input = block.input || {};
                            const artId = input.id || 'artifact';
                            const command = input.command || '';
                            const version = input.version_uuid ? ` (${input.version_uuid.slice(0, 8)}...)` : '';

                            const normalizeNL = (s) => (typeof s === 'string' ? s.replace(/\r\n/g, '\n') : '');

                            // Get previous state for this artifact (if any)
                            const prevState = artifactStateById.get(artId) || { content: null, language: '', title: artId, type: '' };

                            // Current block may override title/language/type, or we inherit from previous
                            const artTitle = input.title || prevState.title || artId;
                            const artLang = input.language || prevState.language || '';
                            const artType = input.type || prevState.type || '';

                            let resolvedContent = '';
                            let resolvedLabel = '';

                            if (command === 'create' || command === 'rewrite') {
                                resolvedContent = normalizeNL(input.content || '');
                                resolvedLabel = command;
                            }
                            else if (command === 'update') {
                                const oldStr = normalizeNL(input.old_str || '');
                                const newStr = normalizeNL(input.new_str || '');
                                const currentContent = prevState.content ? normalizeNL(prevState.content) : null;

                                if (currentContent && oldStr && currentContent.includes(oldStr)) {
                                    resolvedContent = currentContent.replace(oldStr, newStr);
                                    resolvedLabel = 'update (reconstructed)';
                                } else {
                                    // Could not reconstruct; export the patch so it's not lost
                                    resolvedContent = `<<<<<<< OLD\n${oldStr}\n=======\n${newStr}\n>>>>>>> NEW`;
                                    resolvedLabel = 'update (patch only)';
                                }
                            }
                            else {
                                // Unknown artifact command; best-effort export
                                resolvedContent = normalizeNL(input.content || '');
                                resolvedLabel = command || 'artifact';
                            }

                            // Update stored state for this artifact ID
                            artifactStateById.set(artId, {
                                content: resolvedContent,
                                language: artLang,
                                title: artTitle,
                                type: artType
                            });

                            // Determine fence language: use stored/inherited language, fallback to 'diff' for patch-only
                            const fenceLang = (resolvedLabel.includes('patch')) ? 'diff' : artLang;
                            const headerSuffix = resolvedLabel ? ` — ${resolvedLabel}${version}` : version;
                            const codeBlock = Utils.makeCodeFence(resolvedContent, fenceLang);

                            if (cfg.COLLAPSIBLE_ARTIFACTS) {
                                // No ### for collapsible - it's inside <summary>
                                const artifactSummary = `📄 Artifact: ${artTitle}${headerSuffix}`;
                                md += `<details>\n<summary><strong>${artifactSummary}</strong></summary>\n\n${codeBlock}\n\n</details>\n\n`;
                            } else {
                                // Use ### header for non-collapsible
                                const artifactHeader = `### 📄 Artifact: ${artTitle}${headerSuffix}`;
                                md += `${artifactHeader}\n${codeBlock}\n\n`;
                            }
                        }
                    });
                }

                if (cfg.COLLAPSIBLE_MESSAGES) {
                    md += `</details>\n\n---\n\n`;
                } else {
                    md += `---\n\n`;
                }
            });

            return {
                content: md,
                filename: `${Utils.sanitizeFilename(title, 'Claude_Export')}.md`,
                stats: {
                    total: messagesToExport.length,
                    exported: exportedCount
                }
            };
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // PROVIDER: GROK
    // ═══════════════════════════════════════════════════════════════════════════
    const GrokProvider = {
        name: 'Grok',
        hostPattern: /grok\.com/,

        matches(url) {
            return this.hostPattern.test(url);
        },

        extractChatId(url) {
            const match = url.match(/\/c\/([a-z0-9-]+)/);
            return match ? match[1] : null;
        },

        async fetchChat(chatId) {
            // Goal: export what you're CURRENTLY VIEWING.
            // Grok encodes the selected variant in the URL (?rid=...).
            const activeRid = new URLSearchParams(location.search).get('rid');

            // 1) Snapshot list from server
            const snapshotUrl = `/rest/app-chat/conversations/${chatId}/responses`;
            const snapshotResp = await fetch(snapshotUrl, {
                method: 'GET',
                headers: { 'Accept': 'application/json' },
                credentials: 'include',
                cache: 'no-store'
            });

            if (!snapshotResp.ok) throw new Error(`API error: ${snapshotResp.status}`);
            const snapshot = await snapshotResp.json();

            // 2) Build a set of IDs to hydrate (this is what load-responses expects)
            const ids = new Set();
            (snapshot.responses || []).forEach(r => { if (r?.responseId) ids.add(r.responseId); });
            (snapshot.inflightResponses || []).forEach(r => { if (r?.responseId) ids.add(r.responseId); });
            if (activeRid) ids.add(activeRid);

            // Useful on Grok: stores IDs you have viewed/selected in this browser profile
            try {
                const viewed = JSON.parse(localStorage.getItem('responseViewedMap') || '{}');
                Object.keys(viewed || {}).forEach(id => ids.add(id));
            } catch (e) { /* ignore */ }

            const loadUrl = `/rest/app-chat/conversations/${chatId}/load-responses`;

            const loadOnce = async () => {
                if (!ids.size) return null;
                try {
                    const resp = await fetch(loadUrl, {
                        method: 'POST',
                        headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
                        credentials: 'include',
                        cache: 'no-store',
                        body: JSON.stringify({ responseIds: Array.from(ids) })
                    });
                    if (!resp.ok) return null;
                    return await resp.json();
                } catch (e) {
                    return null;
                }
            };

            // Fetch file attachment content from assets.grok.com
            const fetchAttachmentContent = async (fileUri) => {
                if (!fileUri) return null;
                try {
                    const url = `https://assets.grok.com/${fileUri}`;
                    const resp = await fetch(url, {
                        method: 'GET',
                        credentials: 'include',
                        cache: 'no-store'
                    });
                    if (!resp.ok) return null;
                    return await resp.text();
                } catch (e) {
                    console.warn('[Grok Exporter] Failed to fetch attachment:', fileUri, e);
                    return null;
                }
            };

            let loaded = await loadOnce();

            // 3) Merge snapshot + hydrated responses by responseId
            const byId = new Map();
            const ingest = (arr) => {
                (arr || []).forEach(r => {
                    if (r && r.responseId) byId.set(r.responseId, r);
                });
            };

            ingest(snapshot.responses);
            ingest(snapshot.inflightResponses);
            ingest(loaded?.responses);

            // 4) If we have an active rid, try to ensure its ancestor chain is present
            // by adding missing parentResponseId values and re-hydrating a few times.
            if (activeRid && byId.has(activeRid)) {
                for (let i = 0; i < 6; i++) {
                    const missing = [];
                    let cur = byId.get(activeRid);
                    const seen = new Set();

                    while (cur && cur.responseId && !seen.has(cur.responseId)) {
                        seen.add(cur.responseId);
                        const pid = cur.parentResponseId;
                        if (pid && !byId.has(pid) && !ids.has(pid)) missing.push(pid);
                        if (!pid) break;
                        cur = byId.get(pid);
                    }

                    if (!missing.length) break;
                    missing.forEach(id => ids.add(id));

                    const more = await loadOnce();
                    ingest(more?.responses);
                }
            }

            // Fetch attachment contents for all responses
            const allResponses = Array.from(byId.values());
            for (const response of allResponses) {
                if (response.fileAttachmentsMetadata?.length > 0) {
                    for (const att of response.fileAttachmentsMetadata) {
                        if (att.fileUri) {
                            const content = await fetchAttachmentContent(att.fileUri);
                            if (content !== null) {
                                att.__fetchedContent = content;
                            }
                        }
                    }
                }
            }

            return {
                ...snapshot,
                responses: allResponses,
                __activeRid: activeRid
            };
        },

        cleanGrokMessage(text) {
            if (!text) return '';
            // Remove <grok:render> citation tags
            return text.replace(/<grok:render[^>]*>[\s\S]*?<\/grok:render>/g, '');
        },

        parseCitations(cardAttachmentsJson) {
            if (!cardAttachmentsJson?.length) return {};
            const citations = {};
            cardAttachmentsJson.forEach(jsonStr => {
                try {
                    const card = JSON.parse(jsonStr);
                    if (card.id && card.url) {
                        citations[card.id] = card.url;
                    }
                } catch (e) { /* ignore parse errors */ }
            });
            return citations;
        },

        generateMarkdown(data, settings = null) {
            // Use provider-specific settings if provided, otherwise fall back to CONFIG
            const cfg = settings || CONFIG;
            console.log('[Chat Exporter] GrokProvider.generateMarkdown() using cfg.INCLUDE_USER_MESSAGES:', cfg.INCLUDE_USER_MESSAGES, 'cfg.INCLUDE_ASSISTANT_MESSAGES:', cfg.INCLUDE_ASSISTANT_MESSAGES);

            const activeRid = data?.__activeRid || new URLSearchParams(location.search).get('rid') || null;

            // Combine + de-dupe by responseId
            const combined = [
                ...(data.responses || []),
                ...(data.inflightResponses || [])
            ].filter(r => r && typeof r === 'object');

            const byId = new Map();
            combined.forEach(r => {
                if (r?.responseId) byId.set(r.responseId, r);
            });

            const buildChainFromRid = (rid) => {
                const chain = [];
                const seen = new Set();
                let cur = byId.get(rid);

                while (cur && cur.responseId && !seen.has(cur.responseId)) {
                    seen.add(cur.responseId);
                    chain.push(cur);
                    const pid = cur.parentResponseId;
                    if (!pid) break;
                    cur = byId.get(pid);
                }

                return chain.reverse();
            };

            let responses;
            if (cfg.GROK_EXPORT_ALL_REGENERATIONS) {
                // Export ALL responses chronologically, including regenerations
                responses = combined.slice().sort((a, b) => {
                    const ta = a?.createTime ? new Date(a.createTime).getTime() : 0;
                    const tb = b?.createTime ? new Date(b.createTime).getTime() : 0;
                    return ta - tb;
                });

                const seen = new Set();
                responses = responses.filter(r => {
                    const id = r?.responseId || `${r?.sender || 'unknown'}:${r?.createTime || ''}:${(r?.message || '').slice(0, 32)}`;
                    if (seen.has(id)) return false;
                    seen.add(id);
                    return true;
                });
            } else if (activeRid && byId.has(activeRid)) {
                // Export only the currently selected response chain
                responses = buildChainFromRid(activeRid);
            } else {
                // Fallback: no active rid, export all chronologically
                responses = combined.slice().sort((a, b) => {
                    const ta = a?.createTime ? new Date(a.createTime).getTime() : 0;
                    const tb = b?.createTime ? new Date(b.createTime).getTime() : 0;
                    return ta - tb;
                });

                const seen = new Set();
                responses = responses.filter(r => {
                    const id = r?.responseId || `${r?.sender || 'unknown'}:${r?.createTime || ''}:${(r?.message || '').slice(0, 32)}`;
                    if (seen.has(id)) return false;
                    seen.add(id);
                    return true;
                });
            }

            const firstHuman = responses.find(r => r.sender === 'human');
            const titleSource = firstHuman?.message?.substring(0, 60) || data?.title || 'Grok_Export';
            const title = Utils.sanitize(titleSource, 'Grok_Export');

            let md = `# ${title}\n\n`;

            if (cfg.INCLUDE_HEADER) {
                md += `> **Provider:** Grok  \n`;
                md += `> **Date:** ${new Date().toLocaleString()}  \n`;
                md += `> **Source:** [Grok.com](${location.href})  \n`;
                if (cfg.INCLUDE_ACTIVE_LEAF_INFO && activeRid) {
                    md += `> **Active Response (rid):** \`${activeRid}\`  \n`;
                }
                md += `\n---\n\n`;
            }

            if (!responses.length) return { content: md, filename: 'empty.md', stats: { total: 0, exported: 0, modelsDetected: 0, assistantCount: 0 } };

            let turnNumber = 0;
            let exportedCount = 0;
            let assistantCount = 0;
            const modelsFound = new Set();

            responses.forEach((msg) => {
                const isHuman = msg.sender === 'human';
                const role = isHuman ? 'USER' : 'GROK';

                // Skip based on config
                if (isHuman && !cfg.INCLUDE_USER_MESSAGES) return;
                if (!isHuman && !cfg.INCLUDE_ASSISTANT_MESSAGES) return;

                turnNumber++;
                exportedCount++;
                if (!isHuman) {
                    assistantCount++;
                    if (msg.model) modelsFound.add(msg.model);
                }

                // Build header with optional turn number
                let header = role;
                if (cfg.INCLUDE_TURN_NUMBERS) {
                    header = `[${turnNumber}] ${header}`;
                }

                if (cfg.COLLAPSIBLE_MESSAGES) {
                    md += `<details>\n<summary><strong>${header}</strong></summary>\n\n`;
                } else {
                    md += `## ${header}\n\n`;
                }

                // Optional: show timestamp
                if (cfg.INCLUDE_TIMESTAMPS && msg.createTime) {
                    md += `*${new Date(msg.createTime).toLocaleString()}*\n\n`;
                }

                // Optional: show message ID
                if (cfg.INCLUDE_MESSAGE_IDS && msg.responseId) {
                    md += `\`ID: ${msg.responseId}\`\n\n`;
                }

                // Model info for assistant
                if (cfg.INCLUDE_MODEL_INFO && !isHuman && msg.model) {
                    md += `*Model: ${msg.model}*\n\n`;
                }

                // Thinking duration
                if (cfg.INCLUDE_THINKING_DURATION && msg.thinkingStartTime && msg.thinkingEndTime) {
                    const start = new Date(msg.thinkingStartTime);
                    const end = new Date(msg.thinkingEndTime);
                    const duration = ((end - start) / 1000).toFixed(1);
                    md += `*💭 Thinking time: ${duration}s*\n\n`;
                }

                // File attachments (with fetched content if available)
                if (cfg.INCLUDE_ATTACHMENTS) {
                    // Handle file attachments with metadata (contains fetched content)
                    if (msg.fileAttachmentsMetadata?.length > 0) {
                        msg.fileAttachmentsMetadata.forEach(att => {
                            const name = att.fileName || 'Unknown file';
                            const mimeType = att.fileMimeType || '';
                            const content = att.__fetchedContent || null;

                            if (content) {
                                // Determine language hint from mime type or filename
                                let lang = '';
                                if (mimeType.includes('javascript')) lang = 'javascript';
                                else if (mimeType.includes('python')) lang = 'python';
                                else if (mimeType.includes('markdown')) lang = 'markdown';
                                else if (mimeType.includes('json')) lang = 'json';
                                else if (mimeType.includes('html')) lang = 'html';
                                else if (mimeType.includes('css')) lang = 'css';
                                else if (mimeType.includes('xml')) lang = 'xml';
                                else if (name.endsWith('.ps1')) lang = 'powershell';
                                else if (name.endsWith('.sh') || name.endsWith('.bash')) lang = 'bash';
                                else if (name.endsWith('.bat') || name.endsWith('.cmd')) lang = 'batch';
                                else if (name.endsWith('.ts')) lang = 'typescript';
                                else lang = mimeType.split('/')[1] || '';

                                const lineCount = (content.match(/\n/g) || []).length + 1;

                                const fenced = Utils.makeCodeFence(content, lang);

                                if (cfg.COLLAPSIBLE_ATTACHMENTS) {
                                    md += `<details>\n<summary><strong>📎 Attached File: ${Utils.escapeHtml(name)}</strong> (${lineCount} lines)</summary>\n\n${fenced}\n\n</details>\n\n`;
                                } else {
                                    md += `**📎 Attached File: ${name}** (${lineCount} lines)\n\n${fenced}\n\n`;
                                }
                            } else {
                                // Content not fetched
                                if (cfg.COLLAPSIBLE_ATTACHMENTS) {
                                    md += `<details>\n<summary><strong>📎 Attached: ${Utils.escapeHtml(name)}</strong></summary>\n\n*(File content could not be fetched)*\n\n</details>\n\n`;
                                } else {
                                    md += `> 📎 **Attached:** ${name}\n\n`;
                                }
                            }
                        });
                    }
                    // Fallback for old-style fileAttachments (just IDs, no metadata)
                    else if (msg.fileAttachments?.length > 0 && !msg.fileAttachmentsMetadata?.length) {
                        msg.fileAttachments.forEach(attId => {
                            if (cfg.COLLAPSIBLE_ATTACHMENTS) {
                                md += `<details>\n<summary><strong>📎 Attached: ${attId}</strong></summary>\n\n*(File content not available)*\n\n</details>\n\n`;
                            } else {
                                md += `> 📎 **Attached:** ${attId}\n\n`;
                            }
                        });
                    }

                    // Image attachments
                    if (msg.imageAttachments?.length > 0) {
                        const count = msg.imageAttachments.length;
                        if (cfg.COLLAPSIBLE_ATTACHMENTS) {
                            md += `<details>\n<summary><strong>🖼️ Images attached: ${count}</strong></summary>\n\n*(Image previews not available via API)*\n\n</details>\n\n`;
                        } else {
                            md += `> 🖼️ **Images attached:** ${count}\n\n`;
                        }
                    }
                }

                // Main message content (cleaned of citation tags)
                let cleanedMessage = this.cleanGrokMessage(msg.message);
                if (cleanedMessage) {
                    // Process code blocks based on settings
                    cleanedMessage = Utils.processCodeBlocks(cleanedMessage, cfg);
                    md += `${cleanedMessage}\n\n`;
                }

                // Sources - Grok uses webSearchResults (no inline capability, list only)
                if (cfg.INCLUDE_SOURCES_LIST && msg.webSearchResults?.length > 0) {
                    const results = msg.webSearchResults;
                    if (cfg.COLLAPSIBLE_SOURCES_LIST) {
                        md += `<details>\n<summary><strong>📚 Sources (${results.length})</strong></summary>\n\n`;
                        results.forEach((result, idx) => {
                            if (result.url) {
                                const title = (result.title || result.url).replace(/\n/g, ' ').trim();
                                const siteName = result.siteName ? ` — ${result.siteName}` : '';
                                md += `${idx + 1}. [${title}](${result.url})${siteName}\n`;
                                if (result.preview) {
                                    const preview = result.preview.substring(0, 150).replace(/\n/g, ' ').trim();
                                    md += `   > ${preview}...\n\n`;
                                }
                            }
                        });
                        md += `</details>\n\n`;
                    } else {
                        md += `**📚 Sources (${results.length}):**\n\n`;
                        results.forEach((result, idx) => {
                            if (result.url) {
                                const title = (result.title || result.url).replace(/\n/g, ' ').trim();
                                const siteName = result.siteName ? ` — ${result.siteName}` : '';
                                md += `${idx + 1}. [${title}](${result.url})${siteName}\n`;
                                if (result.preview) {
                                    const preview = result.preview.substring(0, 150).replace(/\n/g, ' ').trim();
                                    md += `   > ${preview}...\n\n`;
                                }
                            }
                        });
                        md += `\n`;
                    }
                }

                if (cfg.COLLAPSIBLE_MESSAGES) {
                    md += `</details>\n\n---\n\n`;
                } else {
                    md += `---\n\n`;
                }
            });

            return {
                content: md,
                filename: `${Utils.sanitizeFilename(title, 'Grok_Export')}.md`,
                stats: {
                    total: responses.length,
                    exported: exportedCount,
                    modelsDetected: modelsFound.size,
                    assistantCount: assistantCount
                }
            };
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // PROVIDER: LMARENA
    // ═══════════════════════════════════════════════════════════════════════════
    const LMArenaProvider = {
        name: 'LMArena',
        hostPattern: /lmarena\.ai|chat\.lmsys\.org/,

        matches(url) {
            return this.hostPattern.test(url);
        },

        extractChatId(url) {
            // Handles: /c/{id}, /chat/{id}, /{locale}/c/{id}, /{locale}/chat/{id}
            const match = url.match(/\/(?:c|chat)\/([a-zA-Z0-9-]+)/);
            if (match && match[1] !== 'new') {
                return match[1];
            }
            return null;
        },

        async fetchChat(chatId) {
            const response = await fetch(`/api/evaluation/${chatId}`, {
                credentials: 'include',
                headers: { 'Accept': 'application/json' }
            });

            if (!response.ok) {
                throw new Error(`API error: ${response.status}`);
            }

            const data = await response.json();

            if (!data.messages || !Array.isArray(data.messages) || data.messages.length === 0) {
                throw new Error('No messages found');
            }

            return data;
        },

        /**
         * Extract model names from DOM in display order.
         * The API only provides model UUIDs, so we scrape the visible names.
         * These correspond 1:1 with assistant messages from the API.
         */
        getModelNamesFromDOM() {
            const selectors = [
                // Primary: sticky headers in message list showing model name
                'ol.mt-8 div.sticky span.truncate',
                'ol[class*="mt-8"] div[class*="sticky"] span[class*="truncate"]',
                // Fallback: any truncate span near model icons
                '[class*="bg-surface-primary"] [class*="sticky"] span.truncate'
            ];

            for (const selector of selectors) {
                const elements = document.querySelectorAll(selector);
                if (elements.length > 0) {
                    return [...elements]
                        .map(el => el.textContent?.trim())
                        .filter(Boolean);
                }
            }

            return [];
        },

        generateMarkdown(data, settings = null) {
            // Use provider-specific settings if provided, otherwise fall back to CONFIG
            const cfg = settings || CONFIG;
            console.log('[Chat Exporter] LMArenaProvider.generateMarkdown() using cfg.INCLUDE_USER_MESSAGES:', cfg.INCLUDE_USER_MESSAGES, 'cfg.INCLUDE_ASSISTANT_MESSAGES:', cfg.INCLUDE_ASSISTANT_MESSAGES);

            // Use first user message as fallback title (like old LMArena script)
            const firstUserMsg = data.messages?.find(m => m.role === 'user');
            const titleSource = data.title || firstUserMsg?.content?.substring(0, 60) || 'LMArena_Export';
            const title = Utils.sanitize(titleSource, 'LMArena_Export');
            const chatId = data.id || 'unknown';
            const createdAt = data.createdAt
                ? new Date(data.createdAt).toLocaleString()
                : new Date().toLocaleString();

            let md = `# ${title}\n\n`;

            if (cfg.INCLUDE_HEADER) {
                md += `> **Provider:** LMArena  \n`;
                md += `> **Date:** ${createdAt}  \n`;
                md += `> **Chat ID:** \`${chatId}\`  \n`;
                md += `> **Source:** [LMArena](${location.href})  \n`;
                md += `\n---\n\n`;
            }

            if (!data.messages || data.messages.length === 0) {
                return { content: md, filename: 'empty.md', stats: { total: 0, exported: 0, modelsDetected: 0, assistantCount: 0 } };
            }

            // Pre-process: assign turn numbers and detect battle mode (parallel responses)
            // Group messages by parent to detect parallel assistant responses
            const messagesByParent = new Map();
            data.messages.forEach(msg => {
                const parentKey = msg.parentMessageIds?.[0] || 'root';
                if (!messagesByParent.has(parentKey)) {
                    messagesByParent.set(parentKey, []);
                }
                messagesByParent.get(parentKey).push(msg);
            });

            // Assign turn numbers and battle position labels
            const processedMessages = [];
            let currentTurn = 0;

            data.messages.forEach(msg => {
                const parentKey = msg.parentMessageIds?.[0] || 'root';
                const siblings = messagesByParent.get(parentKey) || [];
                
                // Check if this is the first message in a group of siblings
                const isFirstInGroup = siblings[0]?.id === msg.id;
                
                if (isFirstInGroup) {
                    currentTurn++;
                }

                // Detect battle mode: multiple assistant messages with same parent
                const isBattle = siblings.length > 1 && 
                                msg.role === 'assistant' && 
                                siblings.filter(s => s.role === 'assistant').length > 1;

                const position = msg.participantPosition || '';
                const turnLabel = isBattle && position
                    ? `${currentTurn}${position.toUpperCase()}`
                    : `${currentTurn}`;

                processedMessages.push({
                    ...msg,
                    _turnNumber: currentTurn,
                    _turnLabel: turnLabel,
                    _isBattle: isBattle,
                    _position: position
                });
            });

            // Sort to ensure consistent ordering: within same turn, sort by participantPosition (a before b)
            processedMessages.sort((a, b) => {
                if (a._turnNumber !== b._turnNumber) {
                    return a._turnNumber - b._turnNumber;
                }
                // Within same turn, sort by position
                return (a._position || '').localeCompare(b._position || '');
            });

            // Detect mode from API response
            const isBattleMode = data.mode === 'battle';

            // Get model names from DOM
            // In direct mode: DOM uses flex-col-reverse (newest first), so reverse to get chronological order
            // In battle mode: DOM shows models side-by-side (A left, B right), no reversal needed
            const modelNamesFromDOM = cfg.INCLUDE_MODEL_INFO
                ? this.getModelNamesFromDOM()
                : [];
            
            // Only reverse for direct mode (chronological order needed)
            if (!isBattleMode && modelNamesFromDOM.length > 0) {
                modelNamesFromDOM.reverse();
            }

            // Build model name lookup function
            const getModelName = (msg, assistantIndex) => {
                if (!cfg.INCLUDE_MODEL_INFO || !modelNamesFromDOM.length) return null;

                if (isBattleMode) {
                    // Battle mode: use participantPosition to select model
                    // Position 'a' → first model, 'b' → second model
                    const pos = msg._position || '';
                    if (pos === 'a') return modelNamesFromDOM[0];
                    if (pos === 'b') return modelNamesFromDOM[1];
                    return null;
                } else {
                    // Direct mode: use sequential assistant message index
                    return modelNamesFromDOM[assistantIndex] || null;
                }
            };

            let exportedCount = 0;
            let assistantCount = 0;
            let assistantIndex = 0;

            processedMessages.forEach((msg) => {
                const isUser = msg.role === 'user';
                const role = isUser ? 'USER' : 'ASSISTANT';

                // Skip based on config
                if (isUser && !cfg.INCLUDE_USER_MESSAGES) return;
                if (!isUser && !cfg.INCLUDE_ASSISTANT_MESSAGES) return;

                exportedCount++;
                if (!isUser) assistantCount++;

                // Build role header with optional turn number and model name
                let roleHeader = role;
                if (cfg.INCLUDE_TURN_NUMBERS) {
                    roleHeader = `[${msg._turnLabel}] ${roleHeader}`;
                }
                if (!isUser && cfg.INCLUDE_MODEL_INFO) {
                    const modelName = getModelName(msg, assistantIndex);
                    if (modelName) {
                        roleHeader = `${roleHeader} (${modelName})`;
                    }
                    assistantIndex++;
                }

                if (cfg.COLLAPSIBLE_MESSAGES) {
                    md += `<details>\n<summary><strong>${roleHeader}</strong></summary>\n\n`;
                } else {
                    md += `## ${roleHeader}\n\n`;
                }

                // Optional: timestamp
                if (cfg.INCLUDE_TIMESTAMPS && msg.createdAt) {
                    md += `*${new Date(msg.createdAt).toLocaleString()}*\n\n`;
                }

                // Optional: message ID
                if (cfg.INCLUDE_MESSAGE_IDS && msg.id) {
                    md += `\`ID: ${msg.id}\`\n\n`;
                }

                // Reasoning/thinking block (LMArena uses "reasoning" field)
                if (cfg.INCLUDE_THINKING && msg.reasoning) {
                    md += Utils.formatThinkingBlock(msg.reasoning, cfg);
                }

                // Main content with optional inline citations
                let content = msg.content || '';

                // Collect sources for this message
                let sources = [];
                if (msg.sources?.length > 0) {
                    sources = msg.sources.filter(s => s && s.url);

                    // Insert inline numeric superscript citations (if enabled)
                    if (cfg.INCLUDE_SOURCES && sources.length > 0) {
                        // Build insertions with source index (1-based)
                        const insertions = [];
                        sources.forEach((source, sourceIdx) => {
                            if (source.charLocation?.length > 0) {
                                source.charLocation.forEach(pos => {
                                    insertions.push({
                                        pos: pos,
                                        num: sourceIdx + 1,
                                        url: source.url
                                    });
                                });
                            }
                        });

                        // Sort: descending by position, then descending by num for same position
                        // This ensures [1][2][3] order when multiple sources cite same position
                        insertions.sort((a, b) => {
                            if (a.pos !== b.pos) return b.pos - a.pos;
                            return b.num - a.num;
                        });

                        // Insert numeric superscript links
                        insertions.forEach(ins => {
                            const insertPos = Math.min(ins.pos, content.length);
                            content = content.slice(0, insertPos) +
                                `<sup>[[${ins.num}](${ins.url})]</sup>` +
                                content.slice(insertPos);
                        });
                    }
                }

                content = Utils.processCodeBlocks(content, cfg);
                md += `${content}\n\n`;

                // Add sources list section after content (if enabled)
                if (cfg.INCLUDE_SOURCES_LIST && sources.length > 0) {
                    if (cfg.COLLAPSIBLE_SOURCES_LIST) {
                        md += `<details>\n<summary><strong>📚 Sources (${sources.length})</strong></summary>\n\n`;
                        sources.forEach((source, idx) => {
                            const title = (source.title || source.url).replace(/\n/g, ' ').trim();
                            md += `${idx + 1}. [${title}](${source.url})\n`;
                        });
                        md += `\n</details>\n\n`;
                    } else {
                        md += `**📚 Sources (${sources.length}):**\n\n`;
                        sources.forEach((source, idx) => {
                            const title = (source.title || source.url).replace(/\n/g, ' ').trim();
                            md += `${idx + 1}. [${title}](${source.url})\n`;
                        });
                        md += `\n`;
                    }
                }

                if (cfg.COLLAPSIBLE_MESSAGES) {
                    md += `</details>\n\n---\n\n`;
                } else {
                    md += `---\n\n`;
                }
            });

            return {
                content: md,
                filename: `${Utils.sanitizeFilename(title, 'LMArena_Export')}.md`,
                stats: {
                    total: data.messages.length,
                    exported: exportedCount,
                    modelsDetected: modelNamesFromDOM.length,
                    assistantCount: assistantCount
                }
            };
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // PROVIDER REGISTRY
    // ═══════════════════════════════════════════════════════════════════════════
    const Providers = {
        list: [ClaudeProvider, GrokProvider, LMArenaProvider],

        detect() {
            const url = location.href;
            return this.list.find(p => p.matches(url)) || null;
        },

        getCurrent() {
            return this.detect();
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // STATE
    // ═══════════════════════════════════════════════════════════════════════════
    const State = {
        status: 'idle',
        error: null
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // SETTINGS PANEL (Shadow DOM Isolated)
    // ═══════════════════════════════════════════════════════════════════════════
    const PANEL_STYLES = `
        :host {
            all: initial;
        }
        * {
            box-sizing: border-box;
        }
        .settings-panel {
            position: fixed;
            background: #1f2937;
            border: 1px solid #374151;
            border-radius: 12px;
            font-family: system-ui, -apple-system, sans-serif;
            font-size: 13px;
            color: #e5e7eb;
            box-shadow: 0 8px 24px rgba(0,0,0,0.4);
            min-width: 260px;
            max-width: 320px;
            user-select: none;
            pointer-events: auto;
            z-index: 2147483647;
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }
        .settings-panel .header {
            flex-shrink: 0;
            padding: 10px 16px;
            background: #111827;
            border-bottom: 1px solid #374151;
            border-radius: 12px 12px 0 0;
            display: flex;
            align-items: center;
            justify-content: space-between;
        }
        .settings-panel .header-title {
            font-weight: 600;
            font-size: 14px;
            color: #f9fafb;
            display: flex;
            align-items: center;
            gap: 6px;
        }
        .settings-panel .header-title .provider-badge {
            background: #22c55e;
            color: #fff;
            font-size: 10px;
            font-weight: 600;
            padding: 2px 6px;
            border-radius: 4px;
            text-transform: uppercase;
        }
        .settings-panel .close-button {
            width: 24px;
            height: 24px;
            border: none;
            background: transparent;
            color: #9ca3af;
            font-size: 18px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 6px;
            padding: 0;
            line-height: 1;
            transition: all 0.15s;
        }
        .settings-panel .close-button:hover {
            background: #374151;
            color: #f9fafb;
        }
        .settings-panel .content {
            flex: 1;
            overflow-y: auto;
            padding: 16px;
        }
        .settings-panel .content::-webkit-scrollbar {
            width: 6px;
        }
        .settings-panel .content::-webkit-scrollbar-track {
            background: #374151;
            border-radius: 3px;
        }
        .settings-panel .content::-webkit-scrollbar-thumb {
            background: #6b7280;
            border-radius: 3px;
        }
        .settings-panel .group {
            margin-bottom: 12px;
        }
        .settings-panel .group:last-child {
            margin-bottom: 0;
        }
        .settings-panel .group-title {
            font-size: 10px;
            font-weight: 600;
            color: #9ca3af;
            text-transform: uppercase;
            letter-spacing: 0.5px;
            margin-bottom: 6px;
            padding-bottom: 4px;
            border-bottom: 1px solid #374151;
        }
        .settings-panel label {
            display: flex;
            align-items: center;
            gap: 10px;
            padding: 5px 0;
            cursor: pointer;
            transition: color 0.15s;
        }
        .settings-panel label:hover {
            color: #60a5fa;
        }
        .settings-panel label.indent {
            padding-left: 20px;
            font-size: 12px;
            color: #9ca3af;
        }
        .settings-panel label.indent:hover {
            color: #60a5fa;
        }
        .settings-panel label.disabled {
            opacity: 0.4;
            pointer-events: none;
        }
        .settings-panel label[data-tooltip] {
            position: relative;
        }
        .settings-panel label[data-tooltip]::after {
            content: attr(data-tooltip);
            position: absolute;
            visibility: hidden;
            opacity: 0;
            background: #111827;
            color: #d1d5db;
            padding: 8px 12px;
            border-radius: 6px;
            font-size: 11px;
            line-height: 1.4;
            max-width: 220px;
            width: max-content;
            white-space: pre-line;
            z-index: 100;
            left: 0;
            bottom: calc(100% + 6px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.4);
            border: 1px solid #374151;
            pointer-events: none;
            transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
            transition-delay: 0s;
        }
        .settings-panel label[data-tooltip]:hover::after {
            visibility: visible;
            opacity: 1;
            transition-delay: 1s;
        }
        .settings-panel input[type="checkbox"] {
            width: 16px;
            height: 16px;
            cursor: pointer;
            accent-color: #22c55e;
            flex-shrink: 0;
        }
        .settings-panel .footer {
            flex-shrink: 0;
            padding: 12px 16px;
            border-top: 1px solid #374151;
            display: flex;
            justify-content: flex-end;
            background: #1f2937;
        }
        .settings-panel .reset-button {
            background: transparent;
            border: 1px solid #374151;
            color: #9ca3af;
            padding: 6px 12px;
            border-radius: 6px;
            font-size: 12px;
            cursor: pointer;
            transition: all 0.15s;
        }
        .settings-panel .reset-button:hover {
            background: #374151;
            color: #f9fafb;
            border-color: #4b5563;
        }
    `;

    const SettingsPanel = {
        shadowHost: null,
        shadowRoot: null,
        panel: null,
        isOpen: false,
        currentProvider: null,
        checkboxRefs: {},
        closeHandler: null,
        escapeHandler: null,

        init() {
            console.log('[Chat Exporter] SettingsPanel.init() called');

            if (this.shadowHost) {
                console.log('[Chat Exporter] Shadow host already exists');
                return;
            }

            this.shadowHost = document.createElement('div');
            this.shadowHost.id = 'chat-export-settings-host';
            Object.assign(this.shadowHost.style, {
                position: 'fixed',
                top: '0',
                left: '0',
                width: '0',
                height: '0',
                overflow: 'visible',
                zIndex: CONFIG.Z_INDEX.toString(),
                pointerEvents: 'none'
            });

            this.shadowRoot = this.shadowHost.attachShadow({ mode: 'closed' });
            console.log('[Chat Exporter] Shadow root created');

            const style = document.createElement('style');
            style.textContent = PANEL_STYLES;
            this.shadowRoot.appendChild(style);

            document.body.appendChild(this.shadowHost);
            console.log('[Chat Exporter] Shadow host appended to body');
        },

        buildPanel(providerName) {
            console.log('[Chat Exporter] buildPanel() for:', providerName);

            // Remove existing panel if any
            if (this.panel) {
                console.log('[Chat Exporter] Removing existing panel');
                this.panel.remove();
                this.panel = null;
            }
            this.checkboxRefs = {};

            const flags = PROVIDER_FLAGS[providerName] || [];
            console.log('[Chat Exporter] Flags for provider:', flags.length, flags);

            if (flags.length === 0) {
                console.warn('[Chat Exporter] No flags defined for provider:', providerName);
                return null;
            }

            this.panel = document.createElement('div');
            this.panel.className = 'settings-panel';

            // Header (fixed at top)
            const header = document.createElement('div');
            header.className = 'header';

            const headerTitle = document.createElement('div');
            headerTitle.className = 'header-title';
            headerTitle.innerHTML = `Export Settings <span class="provider-badge">${providerName}</span>`;

            const closeButton = document.createElement('button');
            closeButton.className = 'close-button';
            closeButton.textContent = '✕';
            closeButton.addEventListener('click', (e) => {
                e.stopPropagation();
                this.hide();
            });

            header.appendChild(headerTitle);
            header.appendChild(closeButton);
            this.panel.appendChild(header);

            // Content container (scrollable)
            const content = document.createElement('div');
            content.className = 'content';

            // Group flags by their group
            const groupedFlags = {};
            flags.forEach(flagName => {
                const meta = FLAG_METADATA[flagName];
                if (!meta) return;
                const group = meta.group || 'Other';
                if (!groupedFlags[group]) groupedFlags[group] = [];
                groupedFlags[group].push({ name: flagName, ...meta });
            });

            // Render groups in order
            FLAG_GROUP_ORDER.forEach(groupName => {
                const groupFlags = groupedFlags[groupName];
                if (!groupFlags || groupFlags.length === 0) return;

                const groupDiv = document.createElement('div');
                groupDiv.className = 'group';

                const groupTitle = document.createElement('div');
                groupTitle.className = 'group-title';
                groupTitle.textContent = groupName;
                groupDiv.appendChild(groupTitle);

                groupFlags.forEach(flag => {
                    const label = document.createElement('label');
                    if (flag.indent) label.classList.add('indent');

                    // Handle provider-specific tooltips
                    if (flag.tooltip) {
                        let tooltipText = flag.tooltip;
                        if (typeof flag.tooltip === 'object') {
                            // Provider-specific tooltip
                            tooltipText = flag.tooltip[providerName] || flag.tooltip.Claude || flag.tooltip.Grok || '';
                        }
                        if (tooltipText) {
                            label.setAttribute('data-tooltip', tooltipText);
                        }
                    }

                    // Resolve provider-specific label
                    let labelText = flag.label;
                    if (typeof flag.label === 'object') {
                        labelText = flag.label[providerName] || flag.label.Claude || flag.label.Grok || flag.name;
                    }

                    const checkbox = document.createElement('input');
                    checkbox.type = 'checkbox';
                    checkbox.id = `setting-${flag.name}`;
                    checkbox.checked = Settings.get(providerName, flag.name);

                    checkbox.addEventListener('change', (e) => {
                        e.stopPropagation();
                        const newValue = checkbox.checked;
                        console.log('[Chat Exporter] Setting changed:', flag.name, '=', newValue);
                        Settings.save(providerName, flag.name, newValue);
                        this.updateDependentStates(providerName);
                    });

                    const text = document.createTextNode(labelText);
                    label.appendChild(checkbox);
                    label.appendChild(text);
                    groupDiv.appendChild(label);

                    this.checkboxRefs[flag.name] = { checkbox, label };
                });

                content.appendChild(groupDiv);
            });

            this.panel.appendChild(content);

            // Footer with reset button (fixed at bottom)
            const footer = document.createElement('div');
            footer.className = 'footer';

            const resetButton = document.createElement('button');
            resetButton.className = 'reset-button';
            resetButton.textContent = 'Reset to Defaults';
            resetButton.addEventListener('click', (e) => {
                e.stopPropagation();
                console.log('[Chat Exporter] Resetting settings for:', providerName);
                Settings.reset(providerName);
                this.refreshCheckboxes(providerName);
            });

            footer.appendChild(resetButton);
            this.panel.appendChild(footer);

            // Block event propagation through panel
            this.panel.addEventListener('mousedown', (e) => e.stopPropagation());
            this.panel.addEventListener('mouseup', (e) => e.stopPropagation());
            this.panel.addEventListener('click', (e) => e.stopPropagation());

            this.shadowRoot.appendChild(this.panel);
            this.updateDependentStates(providerName);

            return this.panel;
        },

        updateDependentStates(providerName) {
            // Disable dependent checkboxes when parent is unchecked
            const settings = Settings.load(providerName);

            // COLLAPSIBLE_THINKING depends on INCLUDE_THINKING
            if (this.checkboxRefs.COLLAPSIBLE_THINKING) {
                const enabled = settings.INCLUDE_THINKING;
                this.checkboxRefs.COLLAPSIBLE_THINKING.label.classList.toggle('disabled', !enabled);
                this.checkboxRefs.COLLAPSIBLE_THINKING.checkbox.disabled = !enabled;
            }

            // COLLAPSIBLE_ATTACHMENTS depends on INCLUDE_ATTACHMENTS
            if (this.checkboxRefs.COLLAPSIBLE_ATTACHMENTS) {
                const enabled = settings.INCLUDE_ATTACHMENTS;
                this.checkboxRefs.COLLAPSIBLE_ATTACHMENTS.label.classList.toggle('disabled', !enabled);
                this.checkboxRefs.COLLAPSIBLE_ATTACHMENTS.checkbox.disabled = !enabled;
            }

            // COLLAPSIBLE_ARTIFACTS depends on INCLUDE_ARTIFACTS
            if (this.checkboxRefs.COLLAPSIBLE_ARTIFACTS) {
                const enabled = settings.INCLUDE_ARTIFACTS;
                this.checkboxRefs.COLLAPSIBLE_ARTIFACTS.label.classList.toggle('disabled', !enabled);
                this.checkboxRefs.COLLAPSIBLE_ARTIFACTS.checkbox.disabled = !enabled;
            }

            // COLLAPSIBLE_SEARCH_RESULTS depends on INCLUDE_SEARCH_RESULTS
            if (this.checkboxRefs.COLLAPSIBLE_SEARCH_RESULTS) {
                const enabled = settings.INCLUDE_SEARCH_RESULTS;
                this.checkboxRefs.COLLAPSIBLE_SEARCH_RESULTS.label.classList.toggle('disabled', !enabled);
                this.checkboxRefs.COLLAPSIBLE_SEARCH_RESULTS.checkbox.disabled = !enabled;
            }

            // COLLAPSIBLE_SOURCES_LIST depends on INCLUDE_SOURCES_LIST
            if (this.checkboxRefs.COLLAPSIBLE_SOURCES_LIST) {
                const enabled = settings.INCLUDE_SOURCES_LIST;
                this.checkboxRefs.COLLAPSIBLE_SOURCES_LIST.label.classList.toggle('disabled', !enabled);
                this.checkboxRefs.COLLAPSIBLE_SOURCES_LIST.checkbox.disabled = !enabled;
            }

            // COLLAPSIBLE_CODE_BLOCKS depends on INCLUDE_CODE_BLOCKS
            if (this.checkboxRefs.COLLAPSIBLE_CODE_BLOCKS) {
                const enabled = settings.INCLUDE_CODE_BLOCKS;
                this.checkboxRefs.COLLAPSIBLE_CODE_BLOCKS.label.classList.toggle('disabled', !enabled);
                this.checkboxRefs.COLLAPSIBLE_CODE_BLOCKS.checkbox.disabled = !enabled;
            }

            // INCLUDE_ACTIVE_LEAF_INFO depends on INCLUDE_HEADER
            if (this.checkboxRefs.INCLUDE_ACTIVE_LEAF_INFO) {
                const enabled = settings.INCLUDE_HEADER;
                this.checkboxRefs.INCLUDE_ACTIVE_LEAF_INFO.label.classList.toggle('disabled', !enabled);
                this.checkboxRefs.INCLUDE_ACTIVE_LEAF_INFO.checkbox.disabled = !enabled;
            }
        },

        refreshCheckboxes(providerName) {
            const settings = Settings.load(providerName);
            Object.keys(this.checkboxRefs).forEach(flagName => {
                const ref = this.checkboxRefs[flagName];
                if (ref && ref.checkbox) {
                    ref.checkbox.checked = settings[flagName];
                }
            });
            this.updateDependentStates(providerName);
        },

        /**
         * Calculate the best position for the panel that keeps it fully visible.
         * Hard rule: never go off-screen (top/bottom/left/right).
         * Behavior: prefer the placement that provides the MOST vertical space.
         */
        calculateBestPosition(anchorRect, panelWidth) {
            const vw = window.innerWidth;
            const vh = window.innerHeight;
            const margin = 8;
            const minHeight = 150;

            // Available height within viewport bounds
            const fullHeight = vh - 2 * margin;

            // Available height above/below button while preserving margins
            const availAbove = anchorRect.top - 2 * margin;
            const availBelow = vh - anchorRect.bottom - 2 * margin;

            // Available width left/right of button while preserving margins
            const availRight = vw - anchorRect.right - 2 * margin;
            const availLeft = anchorRect.left - 2 * margin;

            const clampLeft = (left) => Math.max(margin, Math.min(left, vw - panelWidth - margin));

            // Align right edge of panel with right edge of anchor, clamped
            const alignedLeft = clampLeft(anchorRect.right - panelWidth);

            const candidates = [];

            // Right of button (full height)
            if (availRight >= panelWidth && fullHeight >= minHeight) {
                candidates.push({
                    kind: 'right',
                    left: anchorRect.right + margin,
                    top: margin,
                    height: fullHeight,
                    pref: 2
                });
            }

            // Left of button (full height)
            if (availLeft >= panelWidth && fullHeight >= minHeight) {
                candidates.push({
                    kind: 'left',
                    left: anchorRect.left - margin - panelWidth,
                    top: margin,
                    height: fullHeight,
                    pref: 1
                });
            }

            // Below button (max available below)
            if (availBelow >= minHeight) {
                candidates.push({
                    kind: 'below',
                    left: alignedLeft,
                    top: anchorRect.bottom + margin,
                    height: availBelow,
                    pref: 4
                });
            }

            // Above button (max available above)
            if (availAbove >= minHeight) {
                candidates.push({
                    kind: 'above',
                    left: alignedLeft,
                    top: margin,
                    height: availAbove,
                    pref: 3
                });
            }

            // Fallback: overlay (full height)
            if (candidates.length === 0) {
                candidates.push({
                    kind: 'overlay',
                    left: clampLeft((vw - panelWidth) / 2),
                    top: margin,
                    height: Math.max(minHeight, fullHeight),
                    pref: 0
                });
            }

            // Score: prioritize height, then preference
            candidates.sort((a, b) => {
                if (a.height !== b.height) return b.height - a.height;
                return b.pref - a.pref;
            });

            return candidates[0];
        },

        show(anchorElement, providerName) {
            console.log('[Chat Exporter] SettingsPanel.show() called for:', providerName);

            if (!this.shadowHost) {
                console.log('[Chat Exporter] Initializing shadow host...');
                this.init();
            }

            if (!document.body.contains(this.shadowHost)) {
                console.log('[Chat Exporter] Re-appending shadow host to body');
                document.body.appendChild(this.shadowHost);
            }

            this.currentProvider = providerName;
            console.log('[Chat Exporter] Building panel...');
            this.buildPanel(providerName);

            if (!this.panel) {
                console.warn('[Chat Exporter] No flags available for provider:', providerName);
                return;
            }

            // Get button position and calculate best panel position
            const rect = anchorElement.getBoundingClientRect();
            const vw = window.innerWidth;
            const vh = window.innerHeight;
            const margin = 8;

            // Force a deterministic width that can never overflow the viewport
            const panelWidth = Math.max(220, Math.min(320, vw - 2 * margin));

            const pos = this.calculateBestPosition(rect, panelWidth);
            console.log('[Chat Exporter] Best position calculated:', pos);

            // Apply size - width fixed, height fits content up to max
            this.panel.style.width = `${panelWidth}px`;
            this.panel.style.maxWidth = `${panelWidth}px`;
            this.panel.style.minWidth = '0px';

            // Height: fit content, with max based on available space
            const maxH = Math.max(150, Math.min(pos.height, vh - 2 * margin));
            this.panel.style.height = 'auto';
            this.panel.style.maxHeight = `${maxH}px`;

            // Apply horizontal position
            this.panel.style.left = `${Math.max(margin, Math.min(pos.left, vw - panelWidth - margin))}px`;
            this.panel.style.right = 'auto';

            // Apply vertical position based on placement kind
            // Hard rule: never go off-screen. Also avoid "floating away" from the button.
            const setTop = (t) => {
                this.panel.style.top = `${Math.max(margin, Math.min(t, vh - margin))}px`;
                this.panel.style.bottom = 'auto';
            };
            const setBottom = (b) => {
                this.panel.style.bottom = `${Math.max(margin, Math.min(b, vh - margin))}px`;
                this.panel.style.top = 'auto';
            };

            if (pos.kind === 'below') {
                // Keep it adjacent to the button
                setTop(rect.bottom + margin);
            } else if (pos.kind === 'above') {
                // Keep it adjacent to the button (above)
                setBottom(vh - rect.top + margin);
            } else if (pos.kind === 'left' || pos.kind === 'right') {
                // Side placement: align near the button, but clamp to stay visible.
                setTop(rect.top);

                // Measure after maxHeight is applied so we can clamp precisely
                const h = this.panel.getBoundingClientRect().height;

                // If it would overflow at the bottom, try aligning the panel's bottom to the button's bottom
                if (rect.top + h + margin > vh) {
                    setBottom(vh - rect.bottom + margin);

                    // If that pushed it off-screen at the top, fall back to top margin
                    if (this.panel.getBoundingClientRect().top < margin) {
                        setTop(margin);
                    }
                } else {
                    // Otherwise, clamp top so the whole panel is visible, staying as close as possible to the button
                    const clampedTop = Math.max(margin, Math.min(rect.top, vh - h - margin));
                    setTop(clampedTop);
                }
            } else {
                // overlay fallback
                setTop(margin);
            }

            this.isOpen = true;
            console.log('[Chat Exporter] Panel is now open, isOpen:', this.isOpen);

            // Close handlers
            if (this.closeHandler) {
                document.removeEventListener('mousedown', this.closeHandler, true);
            }

            this.closeHandler = (e) => {
                if (!this.isOpen) return;
                const path = e.composedPath();
                if (path.includes(this.shadowHost)) return;
                if (e.target === anchorElement || anchorElement.contains(e.target)) return;
                this.hide();
            };

            this.escapeHandler = (e) => {
                if (e.key === 'Escape' && this.isOpen) {
                    this.hide();
                }
            };

            setTimeout(() => {
                document.addEventListener('mousedown', this.closeHandler, true);
                document.addEventListener('keydown', this.escapeHandler, true);
            }, 50);
        },

        hide() {
            if (this.panel) {
                this.panel.remove();
                this.panel = null;
            }
            this.isOpen = false;
            this.currentProvider = null;
            this.checkboxRefs = {};

            if (this.closeHandler) {
                document.removeEventListener('mousedown', this.closeHandler, true);
                this.closeHandler = null;
            }
            if (this.escapeHandler) {
                document.removeEventListener('keydown', this.escapeHandler, true);
                this.escapeHandler = null;
            }
        },

        toggle(anchorElement, providerName) {
            console.log('[Chat Exporter] SettingsPanel.toggle() - isOpen:', this.isOpen, 'currentProvider:', this.currentProvider, 'requested:', providerName);

            if (this.isOpen && this.currentProvider === providerName) {
                console.log('[Chat Exporter] Panel already open for this provider, hiding...');
                this.hide();
            } else {
                console.log('[Chat Exporter] Opening panel...');
                this.hide(); // Close any existing panel first
                this.show(anchorElement, providerName);
            }
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // FAVICON LOADER (CSP Bypass via GM_xmlhttpRequest)
    // ═══════════════════════════════════════════════════════════════════════════
    const FaviconLoader = {
        cache: {},

        /**
         * Fetch a favicon URL and convert to base64 data URI.
         * Uses GM_xmlhttpRequest to bypass CSP restrictions.
         */
        fetchAsBase64(url) {
            return new Promise((resolve) => {
                // Check cache first
                if (this.cache[url]) {
                    resolve(this.cache[url]);
                    return;
                }

                try {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: url,
                        responseType: 'blob',
                        timeout: 5000,
                        onload: (response) => {
                            if (response.status !== 200) {
                                resolve(null);
                                return;
                            }
                            const reader = new FileReader();
                            reader.onload = () => {
                                const base64 = reader.result;
                                this.cache[url] = base64;
                                resolve(base64);
                            };
                            reader.onerror = () => resolve(null);
                            reader.readAsDataURL(response.response);
                        },
                        onerror: () => resolve(null),
                        ontimeout: () => resolve(null)
                    });
                } catch (e) {
                    resolve(null);
                }
            });
        },

        /**
         * Load favicon into an img element, bypassing CSP.
         */
        async load(imgElement, url, fallbackText) {
            const base64 = await this.fetchAsBase64(url);
            if (base64) {
                imgElement.src = base64;
            } else {
                // Fallback: show first letter
                imgElement.style.display = 'none';
                const parent = imgElement.parentElement;
                if (parent) {
                    const fallback = document.createElement('span');
                    fallback.textContent = fallbackText || '?';
                    fallback.style.cssText = 'font-size: 18px; font-weight: bold; color: #fff;';
                    parent.appendChild(fallback);
                }
            }
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // ONBOARDING BANNER (First-run hint)
    // ═══════════════════════════════════════════════════════════════════════════
    //
    // DRY design:
    //   - Single style injector (idempotent)
    //   - Declarative content sections
    //   - Provider icons specified as data, loaded via FaviconLoader
    //
    const OnboardingBanner = {
        element: null,
        closeHandler: null,

        // Single source of truth for banner content
        CONTENT: {
            title: { icon: '📥', text: 'Multi-Provider Chat Exporter' },
            controls: [
                { icon: '🖱️', strong: 'Left-click', text: 'Export conversation to Markdown' },
                { icon: '⚙️', strong: 'Right-click', text: 'Open settings panel' },
                { icon: '✋', strong: 'Right-drag', text: 'Move button anywhere' }
            ],
            providers: [
                { id: 'claude', name: 'Claude.ai', favicon: 'https://claude.ai/favicon.ico', fallbackText: 'C' },
                { id: 'grok', name: 'Grok.com', favicon: 'https://grok.com/favicon.ico', fallbackText: 'G' },
                { id: 'lmarena', name: 'LMArena.ai', favicon: 'https://lmarena.ai/favicon.ico', fallbackText: 'L' }
            ],
            features: [
                'User & Assistant Messages',
                'Turn Numbers',
                'Thinking/Reasoning Blocks',
                'Code Blocks',
                'File Attachments',
                'Artifacts',
                'Web Search Queries',
                'Search Results',
                'Inline Citations',
                'Sources Bibliography',
                'Collapsible Sections',
                'Model Names',
                'Timestamps',
                'Message IDs',
                'Regenerations/Branches',
                'Active Leaf/Response ID'
            ],
            footer: {
                checkboxId: 'onboarding-dismiss-forever',
                checkboxText: "Don't show again",
                buttonText: 'Got it!'
            }
        },

        STYLES_ID: 'chat-export-onboarding-styles',
        BANNER_ID: 'chat-export-onboarding',

        isDismissed() {
            // Use GM storage for cross-domain persistence
            return GM_getValue(CONFIG.HINT_DISMISSED_KEY, false) === true;
        },

        dismiss(permanent = false) {
            if (permanent) {
                GM_setValue(CONFIG.HINT_DISMISSED_KEY, true);
            }
            if (this.element) {
                this.element.style.opacity = '0';
                this.element.style.transform = 'translateY(10px)';
                setTimeout(() => {
                    this.element?.remove();
                    this.element = null;
                }, 200);
            }
            if (this.closeHandler) {
                document.removeEventListener('mousedown', this.closeHandler);
                this.closeHandler = null;
            }
        },

        injectStyles() {
            if (document.getElementById(this.STYLES_ID)) return;

            const style = document.createElement('style');
            style.id = this.STYLES_ID;
            style.textContent = `
                #${this.BANNER_ID} {
                    position: fixed;
                    background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
                    color: #fff;
                    padding: 16px 20px;
                    border-radius: 12px;
                    font-family: system-ui, -apple-system, sans-serif;
                    font-size: 13px;
                    z-index: ${CONFIG.Z_INDEX - 1};
                    box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.1) inset;
                    line-height: 1.6;
                    max-width: 340px;
                    opacity: 0;
                    transform: translateY(10px);
                    transition: opacity 0.3s ease-out, transform 0.3s ease-out;
                }
                #${this.BANNER_ID}.visible {
                    opacity: 1;
                    transform: translateY(0);
                }
                #${this.BANNER_ID}.arrow-bottom::after {
                    content: '';
                    position: absolute;
                    bottom: -10px;
                    border-left: 12px solid transparent;
                    border-right: 12px solid transparent;
                    border-top: 12px solid #3b82f6;
                }
                #${this.BANNER_ID}.arrow-left::after { left: 24px; }
                #${this.BANNER_ID}.arrow-right::after { right: 24px; }
                #${this.BANNER_ID}.arrow-top::after {
                    content: '';
                    position: absolute;
                    top: -10px;
                    border-left: 12px solid transparent;
                    border-right: 12px solid transparent;
                    border-bottom: 12px solid #1e40af;
                }
                #${this.BANNER_ID} .hint-title {
                    font-weight: 700;
                    font-size: 15px;
                    margin-bottom: 14px;
                    display: flex;
                    align-items: center;
                    gap: 10px;
                    padding-bottom: 12px;
                    border-bottom: 1px solid rgba(255,255,255,0.15);
                }
                #${this.BANNER_ID} .hint-title-icon {
                    font-size: 20px;
                }
                #${this.BANNER_ID} .hint-section {
                    margin-bottom: 14px;
                }
                #${this.BANNER_ID} .hint-section:last-of-type {
                    margin-bottom: 0;
                }
                #${this.BANNER_ID} .hint-section-title {
                    font-weight: 600;
                    font-size: 10px;
                    text-transform: uppercase;
                    letter-spacing: 0.8px;
                    opacity: 0.75;
                    margin-bottom: 8px;
                }
                #${this.BANNER_ID} .hint-list {
                    margin: 0;
                    padding: 0;
                    list-style: none;
                }
                #${this.BANNER_ID} .hint-list li {
                    padding: 4px 0;
                    display: flex;
                    align-items: flex-start;
                    gap: 10px;
                    font-size: 12.5px;
                }
                #${this.BANNER_ID} .hint-list-icon {
                    opacity: 0.8;
                    flex-shrink: 0;
                    width: 16px;
                    text-align: center;
                }
                #${this.BANNER_ID} .hint-list strong {
                    color: #bfdbfe;
                }
                #${this.BANNER_ID} .hint-providers {
                    display: flex;
                    gap: 16px;
                    align-items: center;
                    justify-content: center;
                    padding: 8px 0;
                }
                #${this.BANNER_ID} .hint-provider-icon {
                    position: relative;
                    width: 32px;
                    height: 32px;
                    background: rgba(255,255,255,0.15);
                    border-radius: 8px;
                    padding:  6px;
                    transition: all 0.2s ease;
                    cursor: help;
                    display: flex;
                    align-items: center;
                    justify-content: center;
                }
                #${this.BANNER_ID} .hint-provider-icon:hover {
                    background: rgba(255,255,255,0.25);
                    transform: translateY(-2px);
                }
                #${this.BANNER_ID} .hint-provider-icon img {
                    width: 100%;
                    height: 100%;
                    display: block;
                    border-radius: 4px;
                }
                #${this.BANNER_ID} .hint-provider-icon::after {
                    content: attr(data-name);
                    position: absolute;
                    bottom: calc(100% + 8px);
                    left: 50%;
                    transform: translateX(-50%);
                    background: #0f172a;
                    color: #f1f5f9;
                    padding: 6px 12px;
                    border-radius: 6px;
                    font-size: 11px;
                    font-weight: 600;
                    white-space: nowrap;
                    opacity: 0;
                    visibility: hidden;
                    transition: opacity 0.2s ease, visibility 0.2s ease;
                    pointer-events: none;
                    box-shadow: 0 4px 12px rgba(0,0,0,0.4);
                }
                #${this.BANNER_ID} .hint-provider-icon:hover::after {
                    opacity: 1;
                    visibility: visible;
                }
                #${this.BANNER_ID} .hint-settings-preview {
                    font-size: 11.5px;
                    opacity: 0.9;
                    line-height: 1.6;
                }
                #${this.BANNER_ID} .hint-settings-grid {
                    display: grid;
                    grid-template-columns: repeat(2, 1fr);
                    gap: 6px 12px;
                    margin-top: 8px;
                }
                #${this.BANNER_ID} .hint-settings-item {
                    display: flex;
                    align-items: center;
                    gap: 6px;
                    font-size: 11px;
                }
                #${this.BANNER_ID} .hint-settings-item::before {
                    content: '✓';
                    color: #93c5fd;
                    font-weight: bold;
                    flex-shrink: 0;
                }
                #${this.BANNER_ID} .hint-footer {
                    display: flex;
                    align-items: center;
                    justify-content: space-between;
                    margin-top: 16px;
                    padding-top: 14px;
                    border-top: 1px solid rgba(255,255,255,0.15);
                }
                #${this.BANNER_ID} .hint-checkbox-label {
                    display: flex;
                    align-items: center;
                    gap: 8px;
                    font-size: 11.5px;
                    opacity: 0.85;
                    cursor: pointer;
                    user-select: none;
                    transition: opacity 0.15s;
                }
                #${this.BANNER_ID} .hint-checkbox-label:hover {
                    opacity: 1;
                }
                #${this.BANNER_ID} .hint-checkbox-label input {
                    cursor: pointer;
                    accent-color: #93c5fd;
                    width: 14px;
                    height: 14px;
                }
                #${this.BANNER_ID} .hint-close {
                    background: rgba(255,255,255,0.2);
                    border: none;
                    color: #fff;
                    padding: 8px 20px;
                    border-radius: 6px;
                    font-size: 13px;
                    font-weight: 600;
                    cursor: pointer;
                    transition: background 0.15s, transform 0.1s;
                }
                #${this.BANNER_ID} .hint-close:hover {
                    background: rgba(255,255,255,0.3);
                }
                #${this.BANNER_ID} .hint-close:active {
                    transform: scale(0.97);
                }
            `;
            document.head.appendChild(style);
        },

        buildHtml() {
            const c = this.CONTENT;

            const controlsHtml = c.controls.map(item =>
                `<li><span class="hint-list-icon">${item.icon}</span><span><strong>${item.strong}</strong> — ${item.text}</span></li>`
            ).join('');

            const providersHtml = c.providers.map(p =>
                `<div class="hint-provider-icon" data-name="${Utils.escapeHtml(p.name)}">` +
                `<img id="onboarding-icon-${p.id}" src="" alt="${Utils.escapeHtml(p.name)}">` +
                `</div>`
            ).join('');

            const featuresHtml = c.features.map(f =>
                `<div class="hint-settings-item">${Utils.escapeHtml(f)}</div>`
            ).join('');

            return `
                <div class="hint-title">
                    <span class="hint-title-icon">${c.title.icon}</span>
                    <span>${Utils.escapeHtml(c.title.text)}</span>
                </div>

                <div class="hint-section">
                    <div class="hint-section-title">Button Controls</div>
                    <ul class="hint-list">
                        ${controlsHtml}
                    </ul>
                </div>

                <div class="hint-section">
                    <div class="hint-section-title">Supported Providers</div>
                    <div class="hint-providers">
                        ${providersHtml}
                    </div>
                </div>

                <div class="hint-section">
                    <div class="hint-section-title">Configurable Per Provider — Right-Click to Customize</div>
                    <div class="hint-settings-preview">
                        <div class="hint-settings-grid">
                            ${featuresHtml}
                        </div>
                    </div>
                </div>

                <div class="hint-footer">
                    <label class="hint-checkbox-label">
                        <input type="checkbox" id="${c.footer.checkboxId}">
                        ${Utils.escapeHtml(c.footer.checkboxText)}
                    </label>
                    <button class="hint-close">${Utils.escapeHtml(c.footer.buttonText)}</button>
                </div>
            `;
        },

        show(anchorEl) {
            if (this.isDismissed()) return;
            if (this.element) return;
            if (!anchorEl) return;

            this.injectStyles();

            const hint = document.createElement('div');
            hint.id = this.BANNER_ID;

            hint.innerHTML = this.buildHtml();

            document.body.appendChild(hint);
            this.element = hint;

            // Position hint relative to button
            const btnRect = anchorEl.getBoundingClientRect();
            const hintWidth = 340;
            const hintHeight = hint.offsetHeight;
            const margin = 16;
            const vw = window.innerWidth;
            const vh = window.innerHeight;

            // Determine if button is in top or bottom half
            const buttonInTopHalf = btnRect.top < vh / 2;
            // Determine if button is in left or right half
            const buttonInLeftHalf = btnRect.left < vw / 2;

            let top, left;
            let arrowVertical = 'bottom';
            let arrowHorizontal = buttonInLeftHalf ? 'left' : 'right';

            if (buttonInTopHalf) {
                // Position below button
                top = btnRect.bottom + margin;
                arrowVertical = 'top';
            } else {
                // Position above button
                top = btnRect.top - hintHeight - margin;
                arrowVertical = 'bottom';
            }

            if (buttonInLeftHalf) {
                // Align left edges
                left = Math.max(margin, btnRect.left - 10);
            } else {
                // Align right edges
                left = Math.min(vw - hintWidth - margin, btnRect.right - hintWidth + 10);
            }

            // Clamp to viewport
            top = Math.max(margin, Math.min(top, vh - hintHeight - margin));
            left = Math.max(margin, Math.min(left, vw - hintWidth - margin));

            hint.style.top = `${top}px`;
            hint.style.left = `${left}px`;
            hint.classList.add(`arrow-${arrowVertical}`, `arrow-${arrowHorizontal}`);

            // Animate in
            requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                    hint.classList.add('visible');
                });
            });

            // Load favicons via CSP bypass (data-driven)
            this.CONTENT.providers.forEach(p => {
                const img = hint.querySelector(`#onboarding-icon-${p.id}`);
                if (img) FaviconLoader.load(img, p.favicon, p.fallbackText);
            });

            // Event handlers
            const checkbox = hint.querySelector(`#${this.CONTENT.footer.checkboxId}`);
            const closeBtn = hint.querySelector('.hint-close');

            const close = () => this.dismiss(checkbox?.checked || false);

            closeBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                close();
            });

            // Close on outside click
            this.closeHandler = (e) => {
                if (!hint.contains(e.target) && e.target !== anchorEl && !anchorEl.contains(e.target)) {
                    close();
                }
            };

            setTimeout(() => {
                document.addEventListener('mousedown', this.closeHandler);
            }, 300);
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // UI COMPONENTS
    // ═══════════════════════════════════════════════════════════════════════════
    const UI = {
        btn: null,
        isDragging: false,
        isExporting: false,
        dragStartTime: 0,
        dragMoved: false,

        init() {
            const btn = document.createElement('div');
            btn.id = 'chat-export-btn';
            btn.innerHTML = `<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;

            Object.assign(btn.style, {
                position: 'fixed',
                width: `${CONFIG.BUTTON_SIZE}px`,
                height: `${CONFIG.BUTTON_SIZE}px`,
                borderRadius: '50%',
                boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
                display: 'none',
                alignItems: 'center',
                justifyContent: 'center',
                cursor: 'pointer',
                zIndex: CONFIG.Z_INDEX,
                userSelect: 'none',
                transition: 'background-color 0.2s, transform 0.1s, opacity 0.2s',
                color: '#fff'
            });

            this.loadPosition(btn);

            // Left-click: Export
            btn.addEventListener('click', (e) => {
                console.log('[Chat Exporter] Left-click detected, isDragging:', this.isDragging);
                if (e.button === 0 && !this.isDragging) this.export();
            });

            // Right-click: Settings (if no drag) or Drag
            btn.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                console.log('[Chat Exporter] Context menu event, dragMoved:', this.dragMoved);

                // If we didn't drag, show settings
                if (!this.dragMoved) {
                    const provider = Providers.getCurrent();
                    console.log('[Chat Exporter] Provider for settings:', provider?.name);
                    if (provider) {
                        console.log('[Chat Exporter] Toggling settings panel for:', provider.name);
                        SettingsPanel.toggle(btn, provider.name);
                    } else {
                        console.warn('[Chat Exporter] No provider detected!');
                    }
                } else {
                    console.log('[Chat Exporter] Drag detected, skipping settings panel');
                }
                // Reset dragMoved for next interaction
                this.dragMoved = false;
            });

            btn.addEventListener('mousedown', (e) => {
                console.log('[Chat Exporter] Mousedown, button:', e.button);
                if (e.button === 2) {
                    e.preventDefault();
                    this.dragStartTime = Date.now();
                    this.dragMoved = false;
                    this.startDrag(e);
                }
            });

            document.body.appendChild(btn);
            this.btn = btn;
            window.addEventListener('resize', () => this.applyPosition());
            this.update();

            // Show first-run onboarding banner after a short delay
            setTimeout(() => {
                if (this.btn && this.btn.style.display !== 'none') {
                    OnboardingBanner.show(this.btn);
                }
            }, 1200);
        },

        loadPosition(btn) {
            const saved = localStorage.getItem(CONFIG.STORAGE_KEY);
            if (saved) {
                try {
                    const { ratioX, ratioY } = JSON.parse(saved);
                    this.setPosition(btn, ratioX, ratioY);
                } catch (e) {
                    // Default: bottom-left corner (as far left/bottom as allowed)
                    this.setPosition(btn, 0, 1);
                }
            } else {
                // Default: bottom-left corner (as far left/bottom as allowed)
                this.setPosition(btn, 0, 1);
            }
        },

        setPosition(btn, rx, ry) {
            const maxX = window.innerWidth - CONFIG.BUTTON_SIZE;
            const maxY = window.innerHeight - CONFIG.BUTTON_SIZE;
            btn.style.left = `${Math.max(0, rx * maxX)}px`;
            btn.style.top = `${Math.max(0, ry * maxY)}px`;
        },

        applyPosition() {
            const saved = localStorage.getItem(CONFIG.STORAGE_KEY);
            if (saved && this.btn) {
                try {
                    const { ratioX, ratioY } = JSON.parse(saved);
                    this.setPosition(this.btn, ratioX, ratioY);
                } catch (e) { /* ignore */ }
            }
        },

        startDrag(e) {
            this.isDragging = true;
            const rect = this.btn.getBoundingClientRect();
            const startX = e.clientX;
            const startY = e.clientY;
            const offX = e.clientX - rect.left;
            const offY = e.clientY - rect.top;

            const onMove = (ev) => {
                const dx = Math.abs(ev.clientX - startX);
                const dy = Math.abs(ev.clientY - startY);

                // Only start visual drag if moved more than 5px
                if (dx > 5 || dy > 5) {
                    this.dragMoved = true;
                    this.btn.style.cursor = 'grabbing';
                }

                if (this.dragMoved) {
                    const x = Math.max(0, Math.min(window.innerWidth - CONFIG.BUTTON_SIZE, ev.clientX - offX));
                    const y = Math.max(0, Math.min(window.innerHeight - CONFIG.BUTTON_SIZE, ev.clientY - offY));
                    this.btn.style.left = `${x}px`;
                    this.btn.style.top = `${y}px`;
                }
            };

            const onUp = () => {
                this.btn.style.cursor = 'pointer';
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup', onUp);

                if (this.dragMoved) {
                    const finalRect = this.btn.getBoundingClientRect();
                    const maxX = window.innerWidth - CONFIG.BUTTON_SIZE;
                    const maxY = window.innerHeight - CONFIG.BUTTON_SIZE;
                    const rx = maxX > 0 ? finalRect.left / maxX : 0.95;
                    const ry = maxY > 0 ? finalRect.top / maxY : 0.85;
                    localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({ ratioX: rx, ratioY: ry }));
                }

                setTimeout(() => {
                    this.isDragging = false;
                    // dragMoved is reset on next mousedown
                }, 50);
            };

            document.addEventListener('mousemove', onMove);
            document.addEventListener('mouseup', onUp);
        },

        update() {
            if (!this.btn) return;
            const provider = Providers.getCurrent();

            if (this.isExporting) {
                this.btn.style.backgroundColor = CONFIG.BUTTON_COLOR_LOADING;
                this.btn.title = 'Exporting...';
            } else if (State.status === 'error') {
                this.btn.style.backgroundColor = CONFIG.BUTTON_COLOR_ERROR;
                this.btn.title = `Error: ${State.error || 'Unknown'}. Click to retry.`;
            } else {
                this.btn.style.backgroundColor = CONFIG.BUTTON_COLOR_READY;
                this.btn.title = `Export to Markdown (${provider?.name || 'Unknown'})\nLeft-click: Export\nRight-click: Settings\nRight-drag: Move`;
            }
        },

        async export() {
            const provider = Providers.getCurrent();
            if (!provider) return this.toast('Unknown provider');

            const chatId = provider.extractChatId(location.href);
            if (!chatId) return this.toast('Open a chat first');

            // Close settings panel if open
            SettingsPanel.hide();

            this.isExporting = true;
            State.status = 'loading';
            this.update();
            this.toast(`Fetching ${provider.name} data...`);

            try {
                const data = await provider.fetchChat(chatId);

                // Load provider-specific settings before generating markdown
                console.log('[Chat Exporter] Export starting for provider:', provider.name);
                const providerSettings = Settings.load(provider.name);
                console.log('[Chat Exporter] Exporting with settings:', {
                    INCLUDE_USER_MESSAGES: providerSettings.INCLUDE_USER_MESSAGES,
                    INCLUDE_ASSISTANT_MESSAGES: providerSettings.INCLUDE_ASSISTANT_MESSAGES,
                    INCLUDE_THINKING: providerSettings.INCLUDE_THINKING,
                    INCLUDE_HEADER: providerSettings.INCLUDE_HEADER
                });

                const { content, filename, stats } = provider.generateMarkdown(data, providerSettings);

                const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = filename;
                a.click();
                URL.revokeObjectURL(url);

                // Build detailed success message
                let successMsg = `Exported ${stats?.exported || 0} messages`;
                if (stats?.modelsDetected !== undefined && stats?.assistantCount) {
                    successMsg += ` (${stats.modelsDetected}/${stats.assistantCount} models detected)`;
                }
                this.toast(successMsg);
                State.status = 'ready';
                State.error = null;
            } catch (err) {
                State.status = 'error';
                State.error = err.message;
                this.toast(`Failed: ${err.message}`);
                console.error('[Chat Exporter]', err);
            } finally {
                this.isExporting = false;
                this.update();
            }
        },

        toast(msg) {
            // Remove existing toasts
            document.querySelectorAll('.chat-export-toast').forEach(el => el.remove());

            const el = document.createElement('div');
            el.className = 'chat-export-toast';
            el.textContent = msg;
            Object.assign(el.style, {
                position: 'fixed',
                bottom: '80px',
                left: '50%',
                transform: 'translateX(-50%)',
                background: '#333',
                color: '#fff',
                padding: '10px 20px',
                borderRadius: '8px',
                fontSize: '14px',
                fontFamily: 'system-ui, sans-serif',
                zIndex: CONFIG.Z_INDEX,
                opacity: '0',
                transition: 'opacity 0.3s',
                pointerEvents: 'none'
            });
            document.body.appendChild(el);
            requestAnimationFrame(() => el.style.opacity = '1');
            setTimeout(() => {
                el.style.opacity = '0';
                setTimeout(() => el.remove(), 300);
            }, 2500);
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // ROUTER / OBSERVER
    // ═══════════════════════════════════════════════════════════════════════════
    let lastUrl = location.href;

    const checkRouter = () => {
        const provider = Providers.getCurrent();
        if (!provider) {
            if (UI.btn) UI.btn.style.display = 'none';
            return;
        }

        const chatId = provider.extractChatId(location.href);
        if (UI.btn) {
            UI.btn.style.display = chatId ? 'flex' : 'none';
        }
        UI.update();
    };

    const init = () => {
        UI.init();
        checkRouter();

        // Poll for URL changes (handles SPA navigation)
        setInterval(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                State.status = 'idle';
                State.error = null;
                checkRouter();
            }
        }, 500);
    };

    // Start
    const bootstrap = () => {
        console.log('[Chat Exporter] Bootstrap starting...');
        console.log('[Chat Exporter] PROVIDER_FLAGS defined:', Object.keys(PROVIDER_FLAGS));
        console.log('[Chat Exporter] FLAG_METADATA keys:', Object.keys(FLAG_METADATA).length);

        const provider = Providers.getCurrent();
        console.log('[Chat Exporter] Current provider:', provider?.name || 'none');

        init();
        console.log('[Chat Exporter] Bootstrap complete');
    };

    if (document.body) {
        bootstrap();
    } else {
        window.addEventListener('DOMContentLoaded', bootstrap);
    }
})();