Gemini 效率增强助手

Gemini 网页版增强工具:支持 Markdown 批量导出/复制、对话收藏功能、代码块高亮优化、列表序号修复及沉浸式 Canvas 模式支持。集成对话侧边栏目录与元素审查工具。

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Gemini 效率增强助手
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Gemini 网页版增强工具:支持 Markdown 批量导出/复制、对话收藏功能、代码块高亮优化、列表序号修复及沉浸式 Canvas 模式支持。集成对话侧边栏目录与元素审查工具。
// @author       Youkies
// @match        https://gemini.google.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=google.com
// @run-at       document-idle
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // console.log("Gemini Enhancer: Script Loading...");

    const PREFIX = 'gemini-enhancer-v2';

    // SVG Paths
    const ICONS_PATHS = {
        arrowUp: 'M18 15l-6-6-6 6',
        arrowDown: 'M6 9l6 6 6-6',
        scrollToBottom: 'M5 11l7 7 7-7M5 21h14',
        scrollToTop: 'M19 13l-7-7-7 7M19 3H5',
        menu: 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z',
        close: 'M18 6L6 18M6 6l12 12', // Stroke-based X
        download: 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z',
        copy: 'M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z',
        bug: 'M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z',
        star: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.01 4.38.38-3.32 2.88 1 4.28L12 15.4z',
        starFilled: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z'
    };

    const createSvgDataUri = (path) =>
        `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${path}"/></svg>`)}`;
    const createFilledSvgDataUri = (path) =>
        `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${path}"/></svg>`)}`;

    const ICONS = {
        menu: createFilledSvgDataUri(ICONS_PATHS.menu),
        arrowUp: createSvgDataUri(ICONS_PATHS.arrowUp),
        arrowDown: createSvgDataUri(ICONS_PATHS.arrowDown),
        top: createSvgDataUri(ICONS_PATHS.scrollToTop),
        bottom: createSvgDataUri(ICONS_PATHS.scrollToBottom),
        close: createSvgDataUri(ICONS_PATHS.close),
        download: createFilledSvgDataUri(ICONS_PATHS.download),
        copy: createFilledSvgDataUri(ICONS_PATHS.copy),
        bug: createFilledSvgDataUri(ICONS_PATHS.bug),
        star: createFilledSvgDataUri(ICONS_PATHS.star),
        starFilled: createFilledSvgDataUri(ICONS_PATHS.starFilled)
    };

    /**
     * Advanced HTML to Markdown Converter
     */
    const htmlToMarkdown = (node, listLevel = -1) => {
        if (!node) return '';
        if (node.nodeType === 3) return node.textContent;
        if (node.nodeType === 8) return '';
        if (node.nodeType === 1) {
            const tag = node.tagName.toLowerCase();

            // Formula/Citation Fix
            const dataMath = node.getAttribute('data-math');
            if (dataMath) {
                const content = dataMath.trim();

                // Helper to detect if node is followed by a unit (for distinguishing value "5 min" from citation "[5]")
                const isFollowedByUnit = (n) => {
                    let next = n.nextSibling;
                    while (next && next.nodeType === 3 && !next.textContent.trim()) {
                        next = next.nextSibling; // Skip empty text nodes
                    }
                    if (!next) return false;

                    let text = '';
                    if (next.nodeType === 3) text = next.textContent;
                    else if (next.nodeType === 1) text = next.textContent;

                    text = text.trim().toLowerCase();
                    // Common units whitelist (English & Chinese)
                    const units = [
                        'min', 's', 'sec', 'h', 'hr', 'd', 'day', 'wk', 'mo', 'yr',
                        'g', 'kg', 'mg', 'ug', 'μg', 'ng', 'lb', 'oz',
                        'l', 'ml', 'ul', 'μl', 'gal',
                        'm', 'cm', 'mm', 'nm', 'km', 'ft', 'in',
                        '%', '‰', 'v', 'mv', 'a', 'ma', 'w', 'kw', 'hz', 'khz', 'mhz',
                        'deg', '°', '℃', 'f', 'k', 'pa', 'kpa', 'mpa', 'atm', 'bar',
                        'mol', 'mm/h', 'rpm', 'g/l', 'g/g', 'h-1',
                        '分', '秒', '时', '天', '周', '月', '年',
                        '克', '升', '米', '度', '元', '个', '只', '次'
                    ];
                    // Check if text starts with any unit
                    return units.some(u => text.startsWith(u));
                };

                // Pattern 1: Comma-separated list of integers (e.g., "1, 2") -> Always Citation
                if (/^\d+(,\s*\d+)+$/.test(content)) {
                    return `[${content}]`;
                }

                // Pattern 2: Single integer (e.g., "5") -> citation OR value?
                if (/^\d+$/.test(content)) {
                    if (isFollowedByUnit(node)) {
                        return `$${content}$`; // Value with unit (e.g. 5 min)
                    } else {
                        return `[${content}]`; // Citation (e.g. [5])
                    }
                }

                if (node.classList.contains('math-block')) {
                    return `\n$$${dataMath}$$\n`;
                }
                return `$${dataMath}$`;
            }

            // Universal Attribute Check
            const potentialLatex = node.getAttribute('data-tex') ||
                node.getAttribute('data-latex') ||
                node.getAttribute('alt') ||
                node.getAttribute('aria-label');

            if (potentialLatex) {
                const isMathTag = (tag === 'math' || tag === 'img' || node.classList.contains('math'));
                if (isMathTag || /[\=\^\\_\{]/.test(potentialLatex) || potentialLatex.startsWith('Math formula')) {
                    let clean = potentialLatex.replace(/^Image of /, '').replace(/^Math formula: /, '');
                    if (!clean.startsWith('$')) { clean = `$${clean}$`; }
                    return clean;
                }
            }

            // Standard Logic
            if (tag === 'math') {
                const annotation = node.querySelector('annotation[encoding="application/x-tex"]');
                if (annotation) return `$${annotation.textContent.trim()}$`;
                return node.textContent;
            }
            if (node.classList.contains('katex-mathml')) {
                const annotation = node.querySelector('annotation');
                if (annotation) return `$${annotation.textContent}$`;
            }
            if (node.classList.contains('katex-html')) return '';

            if (tag === 'pre') {
                const code = node.querySelector('code');
                const content = code ? code.textContent : node.textContent;
                let lang = '';

                // Try to get language from code class
                if (code && code.className) {
                    const match = code.className.match(/language-(\w+)/);
                    if (match) lang = match[1];
                }

                // If no language found in code class, try data attributes or parent pre class
                if (!lang) {
                    const preClass = node.className || '';
                    const match = preClass.match(/language-(\w+)/) || preClass.match(/lang-(\w+)/);
                    if (match) lang = match[1];
                }

                // If the user's report is specifically MATLAB, and we see ".m" or "function" keywords and no language is set, heuristic?
                // But safer to rely on classes. If Gemini Canvas uses a specific class structure, we can handle it.
                // Assuming Gemini sometimes puts 'matlab' in class. 

                return `\n\n\`\`\`${lang}\n${content}\n\`\`\`\n\n`;
            }

            // Table Fix (Matrix-based Robustness)
            if (tag === 'table') {
                let md = '\n';
                let rows = Array.from(node.rows);
                if (rows.length === 0) rows = Array.from(node.querySelectorAll('tr'));

                // 1. Extract Data Matrix
                let matrix = rows.map(row => {
                    // Support th/td fallback
                    const cells = row.cells.length > 0 ? Array.from(row.cells) : Array.from(row.querySelectorAll('td, th'));
                    return cells.map(cell => {
                        // Recursively get markdown, flatten newlines, trim whitespace
                        return htmlToMarkdown(cell).replace(/(\r\n|\n|\r)/gm, " ").trim();
                    });
                });

                // 2. Remove Completely Empty Rows
                // A row is "empty" if all its cells are empty strings
                matrix = matrix.filter(row => row.some(cell => cell !== ''));

                if (matrix.length === 0) return '';

                // 3. Determine Table Dimensions
                const maxCols = matrix.reduce((max, row) => Math.max(max, row.length), 0);

                // 4. Prune Artifacts (Ghost Rows)
                // Heuristic: If first row has only 1 column (and is not empty, since we filtered those),
                // BUT the table clearly has multiple columns, it's likely a title/layout artifact.
                if (matrix.length > 1 && matrix[0].length === 1 && maxCols > 1) {
                    matrix.shift();
                }

                // 5. Build Markdown
                matrix.forEach((row, rIndex) => {
                    // Pad with empty cells to ensure rectangular shape
                    const currentCols = row.length;
                    const paddedRow = [...row];
                    for (let i = 0; i < maxCols - currentCols; i++) {
                        paddedRow.push('');
                    }

                    md += '| ' + paddedRow.join(' | ') + ' |\n';

                    // Separator after first row (Header)
                    if (rIndex === 0) {
                        md += '| ' + Array(maxCols).fill(':---').join(' | ') + ' |\n';
                    }
                });
                return md + '\n';
            }

            // List Indentation Fix
            let nextListLevel = listLevel;
            if (tag === 'ul' || tag === 'ol') {
                nextListLevel = listLevel + 1;
            }

            let childContent = '';
            node.childNodes.forEach(c => childContent += htmlToMarkdown(c, nextListLevel));

            switch (tag) {
                case 'h1': return `\n\n# ${childContent}\n\n`;
                case 'h2': return `\n\n## ${childContent}\n\n`;
                case 'h3': return `\n\n### ${childContent}\n\n`;
                case 'h4': return `\n\n#### ${childContent}\n\n`;
                case 'p': return `\n\n${childContent}\n\n`;
                case 'strong': case 'b': return `**${childContent}**`;
                case 'em': case 'i': return `*${childContent}*`;
                case 'code': return `\`${childContent}\``;
                case 'ul':
                case 'ol':
                    return `\n${childContent}\n`;
                case 'li':
                    const depth = Math.max(0, listLevel);
                    const indent = '  '.repeat(depth);
                    const parent = node.parentElement;
                    let prefix = '- ';
                    if (parent && parent.tagName.toLowerCase() === 'ol') {
                        // Calculate index for correct numbering (1., 2., 3...)
                        // instead of generic 1. (which users dislike in raw text)
                        const siblings = Array.from(parent.children).filter(n => n.tagName.toLowerCase() === 'li');
                        let index = siblings.indexOf(node) + 1;

                        // Respect 'start' attribute if present
                        const start = parseInt(parent.getAttribute('start'));
                        if (!isNaN(start)) {
                            index += (start - 1);
                        }

                        prefix = `${index}. `;
                    }
                    return `${indent}${prefix}${childContent.trim()}\n`;

                case 'a': return `[${childContent}](${node.href})`;
                case 'br': return '\n';
                case 'div':
                    if (!childContent.trim()) return '';
                    return `${childContent}\n`;
                case 'span': return childContent;
                default: return childContent;
            }
        }
        return '';
    };

    class StyleManager {
        constructor() { this.injectCSS(); }
        injectCSS() {
            const style = document.createElement('style');
            style.textContent = `
                /* CSS Remains same */
                #${PREFIX}-fab-container { position: fixed; bottom: 24px; right: 24px; display: flex; flex-direction: column; gap: 12px; z-index: 2147483647; align-items: center; pointer-events: none; }
                .${PREFIX}-fab-btn { width: 40px; height: 40px; border-radius: 50%; background-color: var(--gemini-surface, #fff); box-shadow: 0 4px 12px rgba(0,0,0,0.15); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); pointer-events: auto; color: #5f6368; }
                .${PREFIX}-fab-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.2); background-color: #f8f9fa; color: #202124; }
                .${PREFIX}-icon { width: 20px; height: 20px; background-color: currentColor; -webkit-mask-size: contain; mask-size: contain; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; }
                #${PREFIX}-sidebar { position: fixed; top: 64px; right: 0; width: 300px; max-height: calc(100vh - 100px); background-color: #fff; box-shadow: -4px 0 24px rgba(0,0,0,0.15); z-index: 2147483648; transform: translateX(110%); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; flex-direction: column; border-radius: 16px 0 0 16px; overflow: hidden; font-family: 'Google Sans', Roboto, sans-serif; border: 1px solid #e0e0e0; }
                #${PREFIX}-sidebar.active { transform: translateX(0); }
                .${PREFIX}-sidebar-header { padding: 16px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between; background: #fff; }
                .${PREFIX}-sidebar-content { flex: 1; overflow-y: auto; padding: 8px; background: #fafafa; }
                .${PREFIX}-list-item { padding: 10px 16px; border-radius: 8px; margin-bottom: 4px; cursor: pointer; font-size: 14px; color: #444746; border-left: 3px solid transparent; transition: background 0.1s; display: flex; align-items: center; gap: 8px; }
                .${PREFIX}-list-item:hover { background-color: rgba(0,0,0,0.05); }
                .${PREFIX}-list-item.active { background-color: #e8f0fe; color: #0b57d0; font-weight: 500; border-left-color: #0b57d0; }
                .${PREFIX}-fav-icon { min-width: 14px; width: 14px; height: 14px; background-color: #fbbc04; -webkit-mask-image: url('${ICONS.starFilled}'); mask-image: url('${ICONS.starFilled}'); -webkit-mask-size: contain; mask-size: contain; display: none; }
                .${PREFIX}-list-item.is-fav .${PREFIX}-fav-icon { display: block; }

                /* User Query Favorite Button */
                .${PREFIX}-query-fav-btn {
                    /* Match native mat-icon-button size (40px) for consistent hover area */
                    width: 40px; height: 40px; 
                    border-radius: 50%; /* Make it circular like native ripple targets */
                    cursor: pointer; 
                    margin-right: 4px; /* Space between star and bubble */
                    margin-left: -4px; /* Pull slightly left to sit closer to Edit button */
                    flex-shrink: 0;
                    color: var(--gemini-on-surface-variant, #444746);
                    display: inline-flex; align-items: center; justify-content: center;
                    transition: none; /* Instant appearance to match native buttons */
                    opacity: 0; 
                }
                /* Show on hover of the query content container */
                .query-content:hover .${PREFIX}-query-fav-btn,
                .user-query:hover .${PREFIX}-query-fav-btn,
                .${PREFIX}-query-fav-btn.is-fav { opacity: 1; }
                .${PREFIX}-query-fav-btn:hover { color: #fbbc04; }
                .${PREFIX}-query-fav-btn.is-fav { color: #fbbc04; opacity: 1; }
                
                /* Inline Button Styles - Default (Chat Mode) */
                .${PREFIX}-inline-wrapper { display: inline-flex; align-items: center; margin-left: 8px; vertical-align: middle; }
                .${PREFIX}-inline-btn { width: 32px; height: 32px; border-radius: 50%; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #5f6368; transition: background 0.2s; }
                .${PREFIX}-inline-btn:hover { background-color: rgba(60,64,67,0.08); color: #1a73e8; }
                .${PREFIX}-inline-btn.is-fav { color: #fbbc04; }
                
                /* Canvas (Immersive & Header) Specific Overrides - STRICT NATIVE MATCH */
                .immersive-editor-quick-actions .${PREFIX}-inline-btn,
                .action-buttons .${PREFIX}-inline-btn { 
                    width: 40px; height: 40px; /* Reduced to standard MD3 size */
                    margin: 0; /* Remove horizontal margin, trust flex gap or small specific margin */
                    margin-right: 4px; /* Slight spacing */
                    background: transparent; 
                    box-shadow: none; 
                    border-radius: 50%;
                    color: inherit; 
                    display: inline-flex;
                    align-items: center;
                    justify-content: center;
                }
                .immersive-editor-quick-actions .${PREFIX}-inline-btn:hover,
                .action-buttons .${PREFIX}-inline-btn:hover { 
                    background-color: rgba(60,64,67,0.08); 
                    color: inherit;
                }
                .immersive-editor-quick-actions .${PREFIX}-icon,
                .action-buttons .${PREFIX}-icon {
                    width: 20px; height: 20px; /* Reduced to 20px */
                    background-color: #444746; 
                }

                .${PREFIX}-inspector-active * { cursor: crosshair !important; }
                .${PREFIX}-inspector-active *:hover { outline: 2px solid #ff0000; background-color: rgba(255, 0, 0, 0.1); }

                @media (prefers-color-scheme: dark), body[data-is-dark-mode="true"] {
                    .${PREFIX}-fab-btn { background-color: #303134; color: #e3e3e3; }
                    .${PREFIX}-fab-btn:hover { background-color: #3c4043; }
                    #${PREFIX}-sidebar { background-color: #1e1f20; border-color: #444746; }
                    .${PREFIX}-sidebar-header { background-color: #1e1f20; border-bottom-color: #444746; color: #e3e3e3; }
                    .${PREFIX}-sidebar-content { background-color: #131314; }
                    .${PREFIX}-list-item { color: #c4c7c5; }
                    .${PREFIX}-list-item:hover { background-color: rgba(255,255,255,0.1); }
                    .${PREFIX}-list-item.active { background-color: #414242; color: #a8c7fa; border-left-color: #a8c7fa; }
                    .${PREFIX}-inline-btn { color: #c4c7c5; }
                    .${PREFIX}-inline-btn:hover { background-color: rgba(255,255,255,0.1); color: #a8c7fa; }
                    .${PREFIX}-inline-btn.is-fav { color: #fbbc04; }
                    .${PREFIX}-query-fav-btn { color: #c4c7c5; }
                    .${PREFIX}-query-fav-btn:hover, .${PREFIX}-query-fav-btn.is-fav { color: #fbbc04; }
                    
                    /* Canvas Dark Mode */
                    .immersive-editor-quick-actions .${PREFIX}-inline-btn,
                    .action-buttons .${PREFIX}-inline-btn { 
                         background: transparent; border: none;
                    }
                    .immersive-editor-quick-actions .${PREFIX}-inline-btn:hover,
                    .action-buttons .${PREFIX}-inline-btn:hover { 
                         background: rgba(255,255,255,0.1); 
                    }
                    .immersive-editor-quick-actions .${PREFIX}-icon,
                    .action-buttons .${PREFIX}-icon {
                        background-color: #c4c7c5;
                    }
                }
            `;
            document.head.appendChild(style);
        }
    }

    class GeminiEnhancer {
        constructor() {
            this.questions = [];
            this.currentQuestionIndex = -1;
            this.inspectorMode = false;
            new StyleManager();
            this.initUI();

            this.observer = new MutationObserver(() => this.scan());
            this.observer.observe(document.body, { childList: true, subtree: true });

            let lastUrl = location.href;
            setInterval(() => {
                if (location.href !== lastUrl) {
                    lastUrl = location.href;
                    setTimeout(() => this.scan(), 1000);
                }
            }, 1000);

            setTimeout(() => this.scan(), 1000);

            // Inspector Click Listener
            document.addEventListener('click', (e) => {
                if (!this.inspectorMode) return;
                e.preventDefault();
                e.stopPropagation();

                const target = e.target;
                const html = target.outerHTML;
                navigator.clipboard.writeText(html).then(() => {
                    this.showToast('✅ 元素 HTML 已复制!请发送给我。');
                    this.toggleInspector(false);
                }).catch(err => {
                    console.error('Inspector copy failed:', err);
                    this.showToast('❌ 复制失败,请重试');
                });
            }, true);
        }

        initUI() {
            this.sidebar = document.createElement('div');
            this.sidebar.id = `${PREFIX}-sidebar`;
            const header = document.createElement('div');
            header.className = `${PREFIX}-sidebar-header`;

            const titleSpan = document.createElement('span');
            titleSpan.style.fontWeight = 'bold';
            titleSpan.style.fontSize = '16px';
            titleSpan.textContent = '对话目录';
            header.appendChild(titleSpan);

            const closeBtn = document.createElement('div');
            closeBtn.className = `${PREFIX}-inline-btn`;
            closeBtn.style.margin = '0';
            const closeIcon = document.createElement('div');
            closeIcon.className = `${PREFIX}-icon`;
            closeIcon.style.webkitMaskImage = `url('${ICONS.close}')`;
            closeIcon.style.maskImage = `url('${ICONS.close}')`;
            closeBtn.appendChild(closeIcon);
            closeBtn.onclick = () => this.sidebar.classList.remove('active');
            header.appendChild(closeBtn);

            const content = document.createElement('div');
            content.className = `${PREFIX}-sidebar-content`;
            content.id = `${PREFIX}-list`;

            this.sidebar.appendChild(header);
            this.sidebar.appendChild(content);
            document.body.appendChild(this.sidebar);

            const fabContainer = document.createElement('div');
            fabContainer.id = `${PREFIX}-fab-container`;

            const addFab = (iconUrl, title, onClick, id) => {
                const btn = document.createElement('div');
                if (id) btn.id = id;
                btn.className = `${PREFIX}-fab-btn`;
                btn.title = title;
                const icon = document.createElement('div');
                icon.className = `${PREFIX}-icon`;
                icon.style.webkitMaskImage = `url('${iconUrl}')`;
                icon.style.maskImage = `url('${iconUrl}')`;
                btn.appendChild(icon);
                btn.onclick = (e) => { e.stopPropagation(); onClick(); };
                fabContainer.appendChild(btn);
                return btn;
            };

            addFab(ICONS.menu, "目录 / 刷新", () => {
                this.scan();
                this.sidebar.classList.toggle('active');
            });
            addFab(ICONS.top, "回到顶部", () => this.scroll(0));
            addFab(ICONS.arrowUp, "上一个提问", () => this.nav(-1));
            addFab(ICONS.arrowDown, "下一个提问", () => this.nav(1));
            addFab(ICONS.bottom, "滚动到底部", () => this.scroll(Infinity));
            // Debug button disabled for v1.0 release
            // this.inspectorBtn = addFab(ICONS.bug, "元素抓取 / 调试模式", () => this.toggleInspector(!this.inspectorMode), `${PREFIX}-inspector-btn`);

            document.body.appendChild(fabContainer);
        }

        toggleInspector(active) {
            this.inspectorMode = active;
            if (active) {
                document.body.classList.add(`${PREFIX}-inspector-active`);
                this.inspectorBtn.style.backgroundColor = '#f44336'; // Red
                this.inspectorBtn.style.color = '#fff';
                this.showToast('🐞 抓取模式已开启!请点击元素...');
            } else {
                document.body.classList.remove(`${PREFIX}-inspector-active`);
                this.inspectorBtn.style.backgroundColor = '';
                this.inspectorBtn.style.color = '';
            }
        }

        getScroller() {
            const explicit = document.querySelector('infinite-scroller[data-test-id="chat-history-container"]');
            if (explicit) return explicit;
            const scrollers = Array.from(document.querySelectorAll('infinite-scroller'));
            const candidates = scrollers.filter(el =>
                !el.closest('mat-sidenav') &&
                !el.closest('.sidenav-with-history-container')
            );
            if (candidates.length > 0) {
                candidates.sort((a, b) => b.clientWidth - a.clientWidth);
                return candidates[0];
            }
            return document.documentElement;
        }

        scroll(pos) {
            const scroller = this.getScroller();
            const targetPos = (pos === Infinity) ? scroller.scrollHeight : pos;
            if (scroller === document.documentElement || scroller === document.body) {
                window.scrollTo({ top: targetPos, behavior: 'smooth' });
            } else {
                scroller.scrollTo({ top: targetPos, behavior: 'smooth' });
            }
        }

        nav(dir) {
            this.scan();
            if (this.questions.length === 0) return;
            let next = this.currentQuestionIndex + dir;
            if (next < 0) next = 0;
            if (next >= this.questions.length) next = this.questions.length - 1;
            this.currentQuestionIndex = next;
            const target = this.questions[next].element;
            target.scrollIntoView({ behavior: 'smooth', block: 'start' });
            this.renderList();
        }

        scan() {
            const selectors = ['user-query', '[data-test-id="user-query"]', '.user-query'];
            let rawElements = [];
            selectors.forEach(sel => {
                document.querySelectorAll(sel).forEach(el => rawElements.push(el));
            });
            rawElements = [...new Set(rawElements)];
            // Sort by document position
            rawElements.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1);

            const newQuestions = rawElements.map((el, idx) => {
                const rawText = el.innerText || "";
                const title = rawText.split('\n').find(l => l.trim().length > 0) || `提问 ${idx + 1}`;

                // Find associated response to check favorite status
                let isFav = false;
                let responseEl = null;
                let next = el.nextElementSibling;
                while (next) {
                    // Check if 'next' is a model-response or contains one
                    if (next.tagName.toLowerCase() === 'model-response') responseEl = next;
                    else if (next.querySelector && next.querySelector('model-response')) responseEl = next.querySelector('model-response');

                    if (responseEl) {
                        isFav = (responseEl.dataset.isFavorite === 'true');
                        break;
                    }

                    // If we hit another user-query, stop searching for this item's response
                    if (next.matches && (next.matches('user-query') || next.matches('.user-query'))) break;
                    if (next.querySelector && (next.querySelector('user-query') || next.querySelector('.user-query'))) break;

                    next = next.nextElementSibling;
                }

                // Inject/Update User Query Favorite Button
                const bubble = el.querySelector('.user-query-bubble-with-background');
                if (bubble && responseEl) {
                    // Move Star to outside the bubble (alongside Copy/Edit buttons)
                    // Target parent .query-content
                    let container = bubble.parentElement;

                    // Fallback if structure is unexpected
                    if (!container) container = bubble;

                    // Cleanup legacy button inside bubble
                    const legacyBtn = bubble.querySelector(`.${PREFIX}-query-fav-btn`);
                    if (legacyBtn) legacyBtn.remove();

                    let queryBtn = container.querySelector(`.${PREFIX}-query-fav-btn`);
                    if (!queryBtn) {
                        queryBtn = document.createElement('div');
                        queryBtn.className = `${PREFIX}-query-fav-btn`;
                        queryBtn.title = isFav ? '取消收藏' : '收藏';

                        const icon = document.createElement('div');
                        icon.className = `${PREFIX}-icon`;
                        queryBtn.appendChild(icon);

                        queryBtn.onclick = (e) => {
                            e.stopPropagation();
                            const current = responseEl.dataset.isFavorite === 'true';
                            const newState = !current;
                            responseEl.dataset.isFavorite = newState ? 'true' : 'false';
                            this.scan(); // Refresh UI
                            this.showToast(newState ? '⭐ 已收藏' : '已取消收藏');
                        };

                        // Insert before the bubble (places it after existing buttons)
                        container.insertBefore(queryBtn, bubble);
                    }

                    // Update State
                    const iconDiv = queryBtn.querySelector(`.${PREFIX}-icon`);
                    if (isFav) {
                        queryBtn.classList.add('is-fav');
                        queryBtn.title = '取消收藏';
                        iconDiv.style.webkitMaskImage = `url('${ICONS.starFilled}')`;
                        iconDiv.style.maskImage = `url('${ICONS.starFilled}')`;
                    } else {
                        queryBtn.classList.remove('is-fav');
                        queryBtn.title = '收藏';
                        iconDiv.style.webkitMaskImage = `url('${ICONS.star}')`;
                        iconDiv.style.maskImage = `url('${ICONS.star}')`;
                    }
                }

                return { element: el, text: title.substring(0, 40), isFav: isFav };
            });

            // Use JSON stringify to compare, but exclude 'element' field to avoid circular structure issues and false negatives
            const simplified = q => ({ text: q.text, isFav: q.isFav });
            if (JSON.stringify(newQuestions.map(simplified)) !== JSON.stringify(this.questions.map(simplified))) {
                this.questions = newQuestions;
                if (Math.abs(this.questions.length - newQuestions.length) > 2) this.currentQuestionIndex = -1;
                this.renderList();
            }
            document.querySelectorAll('model-response').forEach(el => this.injectInline(el));
            this.scanCanvas();
        }

        scanCanvas() {
            const actionContainers = document.querySelectorAll('.action-buttons');
            actionContainers.forEach(container => {
                const printBtn = container.querySelector('print-button');
                if (printBtn) {
                    if (!container.querySelector(`.${PREFIX}-inline-btn`)) {
                        this.injectCanvasButtons(container, printBtn);
                    }
                }
            });
        }

        injectCanvasButtons(container, referenceNode) {
            const createBtn = (iconUrl, title, onClick) => {
                const btn = document.createElement('div');
                btn.className = `${PREFIX}-inline-btn`;
                btn.title = title;
                const icon = document.createElement('div');
                icon.className = `${PREFIX}-icon`;
                icon.style.webkitMaskImage = `url('${iconUrl}')`;
                icon.style.maskImage = `url('${iconUrl}')`;
                btn.appendChild(icon);
                btn.onclick = (e) => { e.stopPropagation(); onClick(); };
                container.insertBefore(btn, referenceNode);
            };

            const getContent = () => {
                const markdownEditor = document.querySelector('#extended-response-markdown-content');
                if (markdownEditor) return markdownEditor;
                const anyImmersive = document.querySelector('.immersive-editor.markdown');
                return anyImmersive;
            };

            createBtn(ICONS.download, "下载 Canvas 文档", () => {
                const el = getContent();
                if (el) this.download(el, 'gemini_canvas_export');
                else this.showToast('❌ 未找到文档内容');
            });

            createBtn(ICONS.copy, "复制 Canvas 文档", () => {
                const el = getContent();
                if (el) this.copyMarkdown(el);
                else this.showToast('❌ 未找到文档内容');
            });
        }

        renderList() {
            const list = document.getElementById(`${PREFIX}-list`);
            if (!list) return;
            while (list.firstChild) { list.removeChild(list.firstChild); }

            if (this.questions.length === 0) {
                const empty = document.createElement('div');
                empty.style.padding = '16px';
                empty.style.color = '#888';
                empty.textContent = '暂未发现提问';
                list.appendChild(empty);
                return;
            }
            this.questions.forEach((q, idx) => {
                const div = document.createElement('div');
                div.className = `${PREFIX}-list-item`;
                if (q.isFav) div.classList.add('is-fav');
                if (idx === this.currentQuestionIndex) div.classList.add('active');

                // Star Icon
                const star = document.createElement('div');
                star.className = `${PREFIX}-fav-icon`;
                div.appendChild(star);

                // Text
                const span = document.createElement('span');
                span.textContent = `${idx + 1}. ${q.text}`;
                span.style.flex = '1';
                span.style.overflow = 'hidden';
                span.style.textOverflow = 'ellipsis';
                span.style.whiteSpace = 'nowrap';
                div.appendChild(span);

                div.onclick = () => {
                    q.element.scrollIntoView({ behavior: 'smooth', block: 'start' });
                    this.currentQuestionIndex = idx;
                    this.renderList();
                };
                list.appendChild(div);
            });
        }

        injectInline(responseEl) {
            const toolbar = responseEl.querySelector('.buttons-container-v2, .actions-container-v2, .buttons-container');
            if (!toolbar) return;
            // Only inject if wrapper doesn't exist
            let wrapper = toolbar.querySelector(`.${PREFIX}-inline-wrapper`);
            if (!wrapper) {
                wrapper = document.createElement('div');
                wrapper.className = `${PREFIX}-inline-wrapper`;
                toolbar.appendChild(wrapper);
            }

            // Check if buttons key exists. If so, iterate to find Fav button and update state.
            // If empty, create buttons.

            const updateFavBtn = (btn, isFav) => {
                const iconDiv = btn.querySelector(`.${PREFIX}-icon`);
                if (isFav) {
                    btn.classList.add('is-fav');
                    btn.title = '取消收藏';
                    iconDiv.style.webkitMaskImage = `url('${ICONS.starFilled}')`;
                    iconDiv.style.maskImage = `url('${ICONS.starFilled}')`;
                    iconDiv.style.backgroundColor = '#fbbc04'; // Yellow
                } else {
                    btn.classList.remove('is-fav');
                    btn.title = '收藏';
                    iconDiv.style.webkitMaskImage = `url('${ICONS.star}')`;
                    iconDiv.style.maskImage = `url('${ICONS.star}')`;
                    iconDiv.style.backgroundColor = ''; // Inherit default
                }
            };

            const isFav = responseEl.dataset.isFavorite === 'true';

            // 1. Try to find existing Fav Button
            let favBtn = wrapper.querySelector(`.${PREFIX}-inline-btn`); // Assuming first one is always fav due to creation order
            // Better selector loop
            if (wrapper.children.length === 0) {
                // CREATE MODE
                // 1. Favorite Button
                favBtn = document.createElement('div');
                favBtn.className = `${PREFIX}-inline-btn`;
                const favIcon = document.createElement('div');
                favIcon.className = `${PREFIX}-icon`;
                favBtn.appendChild(favIcon);

                favBtn.onclick = (e) => {
                    e.stopPropagation();
                    const current = responseEl.dataset.isFavorite === 'true';
                    const newState = !current;
                    responseEl.dataset.isFavorite = newState ? 'true' : 'false';
                    this.scan(); // Refresh UI
                    this.showToast(newState ? '⭐ 已收藏' : '已取消收藏');
                };
                wrapper.appendChild(favBtn);

                // Helper for other buttons
                const createBtn = (iconUrl, title, onClick) => {
                    const btn = document.createElement('div');
                    btn.className = `${PREFIX}-inline-btn`;
                    btn.title = title;
                    const icon = document.createElement('div');
                    icon.className = `${PREFIX}-icon`;
                    icon.style.webkitMaskImage = `url('${iconUrl}')`;
                    icon.style.maskImage = `url('${iconUrl}')`;
                    btn.appendChild(icon);
                    btn.onclick = (e) => { e.stopPropagation(); onClick(); };
                    wrapper.appendChild(btn);
                };

                createBtn(ICONS.download, "下载 Markdown", () => this.download(responseEl));
                createBtn(ICONS.copy, "复制 Markdown", () => this.copyMarkdown(responseEl));
            } else {
                // UPDATE MODE
                favBtn = wrapper.firstElementChild; // Fav btn is always first
            }

            // Sync State
            if (favBtn) updateFavBtn(favBtn, isFav);
        }

        getMarkdownContent(responseEl) {
            let mdContent = htmlToMarkdown(responseEl);

            // Robust Citation Merging Logic
            // Detects cases like: "Context\n[1, 2]\nPunctuation" or "Context\n[1, 2]\nText"
            // And merges them into "Context [1, 2]Punctuation" or "Context [1, 2] Text"
            // Regex captures: 1=LastChar, 2=Citation, 3=NextChar
            mdContent = mdContent.replace(/([^\n])\n+(\[[\d,\s\.]+\])\n+([^\n])/g, (match, prevChar, citation, nextChar) => {
                // 1. Separator before citation
                // Use a space generally.
                const sepBefore = ' ';

                // 2. Separator after citation
                // If next is full-width punctuation, NO SPACE. Else SPACE.
                const isNextPunctuation = /[。,;:!?\.,;:\!\?]/.test(nextChar);
                const sepAfter = isNextPunctuation ? '' : ' ';

                return `${prevChar}${sepBefore}${citation}${sepAfter}${nextChar}`;
            });

            // Fix broken Reference List items
            // Matches: "- [1]" (with optional whitespace) at end of line, merges with next line content
            mdContent = mdContent.replace(/^(\s*[-*]\s*\[\d+\])\s*\n/gm, '$1 ');

            // Post-process to repair broken tables (Canvas Mode <p> tags issue)
            mdContent = this.repairMarkdownTables(mdContent);

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

        repairMarkdownTables(md) {
            const lines = md.split('\n');
            const result = [];
            let tableBuffer = [];

            const flushTable = () => {
                if (tableBuffer.length === 0) return;

                // 1. Parse buffer into matrix
                let matrix = tableBuffer.map(line => {
                    const content = line.trim();
                    if (!content.startsWith('|') || !content.endsWith('|')) return null;
                    const inner = content.substring(1, content.length - 1);
                    return inner.split('|').map(c => c.trim());
                }).filter(row => row !== null);

                // 2. Filter out completely empty rows (ghost rows)
                matrix = matrix.filter(row => row.some(cell => cell !== ''));

                if (matrix.length === 0) {
                    // Start over? No, if it was just ghost rows, discard.
                    tableBuffer = [];
                    return;
                }

                // 3. Statistics & Pruning
                const maxCols = matrix.reduce((max, row) => Math.max(max, row.length), 0);

                // Prune "Single Column" Artifacts if table is multi-column
                let filteredMatrix = matrix.filter(row => {
                    if (maxCols >= 2 && row.length === 1) {
                        // Likely a title row or artifact.
                        return false;
                    }
                    return true;
                });

                if (filteredMatrix.length === 0) return;

                // 4. Check/Inject Separator
                let hasSeparator = false;
                if (filteredMatrix.length >= 2) {
                    const secondRow = filteredMatrix[1];
                    const isSep = secondRow.every(cell => /^:?-+:?$/.test(cell));
                    if (isSep) hasSeparator = true;
                }

                // 5. Reconstruct
                filteredMatrix.forEach((row, idx) => {
                    while (row.length < maxCols) row.push('');

                    const line = '| ' + row.join(' | ') + ' |';
                    result.push(line);

                    if (idx === 0 && !hasSeparator) {
                        const separator = '| ' + Array(maxCols).fill(':---').join(' | ') + ' |';
                        result.push(separator);
                    }
                });

                tableBuffer = [];
            };

            for (let i = 0; i < lines.length; i++) {
                const line = lines[i].trim();
                if (line.startsWith('|') && line.endsWith('|')) {
                    tableBuffer.push(lines[i]);
                } else {
                    flushTable();
                    result.push(lines[i]);
                }
            }
            flushTable();

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

        download(responseEl, filenamePrefix = 'gemini_export') {
            const cleanMd = this.getMarkdownContent(responseEl);
            const blob = new Blob([cleanMd], { type: 'text/markdown' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `${filenamePrefix}_${Date.now()}.md`;
            a.click();
            URL.revokeObjectURL(url);
        }

        copyMarkdown(responseEl) {
            const cleanMd = this.getMarkdownContent(responseEl);
            navigator.clipboard.writeText(cleanMd).then(() => {
                this.showToast('✅ Markdown 已复制到剪贴板');
            }).catch(err => {
                console.error('Copy failed:', err);
                this.showToast('❌ 复制失败,请查看控制台');
            });
        }

        showToast(message) {
            let toast = document.getElementById(`${PREFIX}-toast`);
            if (!toast) {
                toast = document.createElement('div');
                toast.id = `${PREFIX}-toast`;
                toast.style.cssText = `
                    position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
                    background: #323232; color: white; padding: 12px 24px; border-radius: 8px;
                    z-index: 2147483648; font-family: 'Google Sans', sans-serif; font-size: 14px;
                    box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: opacity 0.3s; opacity: 0; pointer-events: none;
                `;
                document.body.appendChild(toast);
            }
            toast.textContent = message;
            toast.style.opacity = '1';
            setTimeout(() => { toast.style.opacity = '0'; }, 3000);
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => new GeminiEnhancer());
    } else {
        new GeminiEnhancer();
    }
})();