Gemini Model Switcher Buttons

Replaces Gemini model selection dropdown with easily accessible buttons, and reapplies your selection on conversation reload

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Gemini Model Switcher Buttons
// @namespace    http://tampermonkey.net/
// @version      2.4
// @description  Replaces Gemini model selection dropdown with easily accessible buttons, and reapplies your selection on conversation reload
// @author       treescandal & Gemini 3.0 Pro
// @match        https://gemini.google.com/*
// @license      MIT
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // ============ CONFIGURATION ============

    const MODE_CONFIG = {
        modes: [
            { icon: '⚡', label: 'Flash', index: 0 },
            { icon: '🧠', label: 'Think', index: 1 },
            { icon: '✨', label: 'Pro', index: 2 }
        ],
        containerSelector: '.leading-actions-wrapper',
        checkInterval: 300,
        compactBreakpoint: 620,
        reapplyDelay: 1500,
        minReapplyInterval: 4000,
        maxReapplyAttempts: 3
    };

    // ============ STATE MANAGEMENT ============

    let savedModeIndex = -1;
    let languageMap = null;
    let isSwitching = false;
    let switchTimeout = null;
    let reapplyAttempts = 0;
    let lastReapplyTime = 0;
    let conversationId = null;
    let lastContainerCheck = null;
    let resizeObserver = null;

    try {
        const savedMap = localStorage.getItem('gqs_language_map');
        if (savedMap) {
            languageMap = JSON.parse(savedMap);
        }

        const savedIndex = localStorage.getItem('gqs_last_index');
        if (savedIndex !== null) {
            savedModeIndex = parseInt(savedIndex, 10);
            console.log('Gemini Switcher: Loaded saved preference:', savedModeIndex);
        }
    } catch (e) {
        console.warn('Gemini Switcher: Could not load saved state', e);
    }

    // ============ STYLES ============

    const styles = `
        #gemini-quick-switch-bar {
            display: flex;
            gap: 6px;
            align-items: center;
            margin-left: auto;
            margin-right: 8px;
            height: 40px;
            z-index: 999;
        }

        .gqs-btn {
            background: rgba(0,0,0,0.05);
            border: 1px solid transparent;
            border-radius: 100px;
            padding: 0 14px 0 10px;
            height: 32px;
            font-size: 13px;
            font-weight: 500;
            color: #444746;
            cursor: pointer;
            transition: all 0.2s ease;
            white-space: nowrap;
            display: flex;
            align-items: center;
            gap: 4px;
        }

        .gqs-btn:hover {
            background: rgba(0,0,0,0.1);
        }

        .gqs-btn.active {
            background: #c2e7ff;
            color: #001d35;
        }

        .gqs-btn.compact {
            padding: 0 8px;
            min-width: 32px;
            justify-content: center;
        }

        .gqs-btn.compact .gqs-label {
            display: none;
        }

        .gqs-btn .gqs-icon {
            font-size: 16px;
            line-height: 1;
        }

        .gqs-btn .gqs-label {
            font-size: 13px;
        }

        @media (prefers-color-scheme: dark) {
            .gqs-btn {
                background: rgba(255,255,255,0.1);
                color: #e3e3e3;
            }
            .gqs-btn:hover {
                background: rgba(255,255,255,0.2);
            }
            .gqs-btn.active {
                background: #004a77;
                color: #c2e7ff;
            }
        }

        .model-picker-container {
            width: 0 !important;
            height: 0 !important;
            opacity: 0 !important;
            overflow: hidden !important;
            pointer-events: none !important;
            position: absolute !important;
        }

        .gds-mode-switch-menu {
            opacity: 0 !important;
            pointer-events: none !important;
            visibility: hidden !important;
        }

        .cdk-overlay-pane:has(> .gds-mode-switch-menu),
        .cdk-overlay-pane:has(> * > .gds-mode-switch-menu) {
            opacity: 0 !important;
            pointer-events: none !important;
            visibility: hidden !important;
        }

        .mat-bottom-sheet-container.gds-mode-switch-menu,
        .mat-bottom-sheet-container:has(.gds-mode-switch-menu) {
            opacity: 0 !important;
            pointer-events: none !important;
            visibility: hidden !important;
        }

        .mat-bottom-sheet-container:has([data-test-id^="bard-mode-option"]) {
            opacity: 0 !important;
            pointer-events: none !important;
            visibility: hidden !important;
        }

        .cdk-overlay-backdrop {
            transition: none !important;
        }

        .cdk-overlay-container:has(.gds-mode-switch-menu) .cdk-overlay-backdrop,
        .cdk-overlay-container:has([data-test-id^="bard-mode-option"]) .cdk-overlay-backdrop {
            opacity: 0 !important;
            pointer-events: none !important;
            visibility: hidden !important;
        }

        @supports not (selector(:has(*))) {
            .gds-mode-switch-menu * {
                opacity: 0 !important;
                pointer-events: none !important;
            }
        }

        @keyframes gqs-pulse {
            0% { opacity: 1; transform: scale(1); }
            50% { opacity: 0.7; transform: scale(0.98); }
            100% { opacity: 1; transform: scale(1); }
        }

        @keyframes gqs-success-flash {
            0% { background-color: #c2e7ff; }
            50% { background-color: #b7f7d6; border-color: #26a668; }
            100% { background-color: #c2e7ff; }
        }

        @media (prefers-color-scheme: dark) {
             @keyframes gqs-success-flash {
                0% { background-color: transparent; }
                50% { background-color: #22c55e; color: #ffffff; }
                100% { background-color: transparent; }
            }
        }

        .gqs-btn.working {
            animation: gqs-pulse 3s infinite ease-in-out !important;
            pointer-events: none;
            cursor: wait;
        }

        .gqs-btn.success-flash {
            animation: gqs-success-flash 0.6s ease-out;
        }
    `;

    const styleSheet = document.createElement('style');
    styleSheet.innerText = styles;

    if (document.head.firstChild) {
        document.head.insertBefore(styleSheet, document.head.firstChild);
    } else {
        document.head.appendChild(styleSheet);
    }

    // ============ URL DETECTION ============

    function getConversationId() {
        const pathParts = location.pathname.split('/').filter(p => p);

        let startIndex = 0;
        if (pathParts[0] === 'u' && !isNaN(pathParts[1])) {
            startIndex = 2;
        }

        const relevantParts = pathParts.slice(startIndex);

        if (relevantParts.length === 1) {
            return null;
        }

        if (relevantParts.length >= 2) {
            return relevantParts.slice(1).join('/');
        }

        return null;
    }

    function isOnConversationPage() {
        const path = location.pathname;
        return path.includes('/app/') || path.includes('/gem/') ||
               path.endsWith('/app') || path.endsWith('/gem');
    }

    // ============ MENU TRIGGER DETECTION ============

    function findMenuTrigger() {
        let trigger = document.querySelector('[data-test-id*="mode-menu"]');
        if (trigger) return { element: trigger, strategy: 'test-id' };

        const candidates = Array.from(document.querySelectorAll('button[aria-haspopup="true"]'));

        trigger = candidates.find(btn => {
            const hasRelevantContent = btn.querySelector('svg') ||
                                       btn.className.includes('model') ||
                                       btn.className.includes('mode');
            return hasRelevantContent;
        });

        if (trigger) return { element: trigger, strategy: 'aria-haspopup' };

        const structureMatch = Array.from(document.querySelectorAll('button')).find(btn => {
            const hasChevron = btn.querySelector('svg[viewBox*="24"]');
            return hasChevron;
        });

        if (structureMatch) return { element: structureMatch, strategy: 'structure' };

        return null;
    }

    // ============ MENU ITEMS DETECTION ============

    function findMenuItems() {
        const menuSelectors = [
            '[data-test-id^="bard-mode-option"]',
            '.gds-mode-switch-menu [role="menuitem"]',
            '.mat-bottom-sheet-container [role="menuitem"]',
            '[role="menu"] [role="menuitem"]',
            '[role="listbox"] [role="option"]',
        ];

        for (const selector of menuSelectors) {
            const items = Array.from(document.querySelectorAll(selector));
            if (items.length >= 2) {
                return items;
            }
        }

        const overlays = Array.from(document.querySelectorAll('.cdk-overlay-pane'));
        for (const overlay of overlays) {
            const rect = overlay.getBoundingClientRect();
            if (rect.height > 0) {
                const buttons = Array.from(overlay.querySelectorAll('button, [role="menuitem"], [role="option"]'));
                const validItems = buttons.filter(btn => {
                    const text = btn.innerText?.trim();
                    const btnRect = btn.getBoundingClientRect();
                    return text && text.length > 0 && btnRect.height > 20;
                });

                if (validItems.length >= 2) {
                    return validItems;
                }
            }
        }

        return [];
    }

    // ============ POLLING HELPER ============

    function waitForMenu(callback, maxWait = 500) {
        const startTime = Date.now();

        const check = () => {
            const items = findMenuItems();

            if (items.length >= 3) {
                callback(items);
            } else if (Date.now() - startTime < maxWait) {
                requestAnimationFrame(check);
            } else {
                console.warn('Gemini Switcher: Menu timeout');
                callback([]);
            }
        };

        requestAnimationFrame(check);
    }

    // ============ LANGUAGE MAP ============

    function buildLanguageMapFromMenu(menuItems) {
        const tempMap = {};

        menuItems.forEach((item, index) => {
            const titleElement = item.querySelector('.gds-title-m, [class*="title"]');
            if (titleElement) {
                const text = titleElement.innerText.trim().toLowerCase();
                if (text) {
                    tempMap[text] = index;
                }
            }
        });

        if (Object.keys(tempMap).length > 0) {
            languageMap = tempMap;
            try {
                localStorage.setItem('gqs_language_map', JSON.stringify(languageMap));
                console.log('Gemini Switcher: Language map built:', languageMap);
            } catch (e) {
                console.warn('Gemini Switcher: Could not save language map', e);
            }
        }
    }

    // ============ ACTIVE MODE DETECTION ============

    function detectActiveModeFromUI() {
        const triggerResult = findMenuTrigger();
        if (!triggerResult) return -1;

        const trigger = triggerResult.element;
        const text = trigger.innerText.toLowerCase().replace(/\s+/g, ' ').trim();

        if (!text || text.length < 2) return -1;

        if (languageMap) {
            for (const [keyword, index] of Object.entries(languageMap)) {
                if (text.includes(keyword)) {
                    return index;
                }
            }
        }

        return -1;
    }

    // ============ MODE SWITCHING ============

    function performModeSwitch(modeIndex, isAutoReapply = false) {
        if (isSwitching) {
            console.log('Gemini Switcher: Already switching, skipping');
            return;
        }

        const triggerExists = !!findMenuTrigger();
        console.log(`Gemini Switcher: performModeSwitch - mode: ${modeIndex}, isAutoReapply: ${isAutoReapply}, trigger exists: ${triggerExists}`);

        const targetBtn = document.querySelector(`.gqs-btn[data-mode-index="${modeIndex}"]`);
        if (targetBtn) targetBtn.classList.add('working');

        if (!isAutoReapply) {
            savedModeIndex = modeIndex;
            reapplyAttempts = 0;
            try {
                localStorage.setItem('gqs_last_index', modeIndex.toString());
            } catch (e) {
                console.warn('Gemini Switcher: Could not save preference', e);
            }
        } else {
            reapplyAttempts++;
            lastReapplyTime = Date.now();
        }

        isSwitching = true;
        clearTimeout(switchTimeout);

        // Update UI optimistically
        document.querySelectorAll('.gqs-btn').forEach((btn, idx) => {
            btn.classList.toggle('active', idx === modeIndex);
        });

        const triggerResult = findMenuTrigger();
        if (!triggerResult) {
            console.error('Gemini Switcher: Trigger not found');
            if (targetBtn) targetBtn.classList.remove('working');
            isSwitching = false;
            return;
        }

        console.log('Gemini Switcher: Clicking trigger with strategy:', triggerResult.strategy);
        triggerResult.element.click();

        waitForMenu((menuItems) => {
            if (menuItems.length === 0) {
                console.error('Gemini Switcher: Menu not found');
                if (targetBtn) targetBtn.classList.remove('working');
                isSwitching = false;
                return;
            }

            console.log('Gemini Switcher: Menu found with', menuItems.length, 'items');

            if (menuItems.length >= 3 && !languageMap) {
                buildLanguageMapFromMenu(menuItems);
            }

            if (menuItems.length > modeIndex) {
                console.log('Gemini Switcher: Clicking menu item', modeIndex);
                menuItems[modeIndex].click();

                if (targetBtn) {
                    targetBtn.classList.remove('working');
                    targetBtn.classList.add('success-flash');
                    setTimeout(() => targetBtn.classList.remove('success-flash'), 800);
                }

                switchTimeout = setTimeout(() => {
                    isSwitching = false;
                    console.log('Gemini Switcher: Switch completed');
                }, 800);
            } else {
                console.error(`Gemini Switcher: Only found ${menuItems.length} menu items, cannot switch to index ${modeIndex}`);
                if (targetBtn) targetBtn.classList.remove('working');
                isSwitching = false;
            }
        });
    }

    function checkAndReapply() {
        if (savedModeIndex === -1) {
            console.log('Gemini Switcher: No saved preference, skipping reapply');
            return;
        }

        if (isSwitching) {
            console.log('Gemini Switcher: Currently switching, skipping reapply');
            return;
        }

        if (reapplyAttempts >= MODE_CONFIG.maxReapplyAttempts) {
            console.log('Gemini Switcher: Max reapply attempts reached');
            return;
        }

        const now = Date.now();
        if (now - lastReapplyTime < MODE_CONFIG.minReapplyInterval) {
            console.log('Gemini Switcher: Rate limit - skipping reapply (too soon)');
            return;
        }

        const trigger = findMenuTrigger();
        if (!trigger) {
            console.log('Gemini Switcher: UI not ready for reapply (no trigger), skipping');
            return;
        }

        const currentMode = detectActiveModeFromUI();

        if (currentMode === -1) {
            console.log('Gemini Switcher: Cannot detect current mode, will build language map');
            performModeSwitch(savedModeIndex, true);
            return;
        }

        if (currentMode !== savedModeIndex) {
            console.log(`Gemini Switcher: Drift detected (current: ${currentMode}, saved: ${savedModeIndex})`);
            performModeSwitch(savedModeIndex, true);
        } else {
            console.log(`Gemini Switcher: Mode already correct (${currentMode}), no reapply needed`);
        }
    }

    // ============ UI STATE UPDATE ============

    function updateActiveState() {
        if (isSwitching) return;

        let activeIndex = savedModeIndex;

        if (activeIndex === -1) {
            activeIndex = detectActiveModeFromUI();
        }

        document.querySelectorAll('.gqs-btn').forEach((btn, idx) => {
            btn.classList.toggle('active', idx === activeIndex);
        });
    }

    // ============ RESPONSIVE LAYOUT ============

    function updateButtonLayout() {
        const container = document.querySelector(MODE_CONFIG.containerSelector);
        const buttons = document.querySelectorAll('.gqs-btn');

        if (!container || buttons.length === 0) return;

        const containerWidth = container.offsetWidth;
        const isCompact = containerWidth < MODE_CONFIG.compactBreakpoint;

        buttons.forEach(btn => {
            if (isCompact) {
                btn.classList.add('compact');
                btn.title = btn.querySelector('.gqs-label')?.textContent || '';
            } else {
                btn.classList.remove('compact');
                btn.removeAttribute('title');
            }
        });
    }

    // ============ UI INJECTION ============

    function injectButtons(container) {
        const bar = document.createElement('div');
        bar.id = 'gemini-quick-switch-bar';

        MODE_CONFIG.modes.forEach((mode, idx) => {
            const btn = document.createElement('button');
            btn.className = 'gqs-btn';

            const icon = document.createElement('span');
            icon.className = 'gqs-icon';
            icon.textContent = mode.icon;

            const label = document.createElement('span');
            label.className = 'gqs-label';
            label.textContent = mode.label;

            btn.appendChild(icon);
            btn.appendChild(label);
            btn.dataset.modeIndex = idx;
            btn.onclick = () => performModeSwitch(mode.index, false);
            bar.appendChild(btn);
        });

        container.appendChild(bar);
        console.log('Gemini Switcher: Buttons injected');

        setTimeout(() => {
            updateButtonLayout();
            updateActiveState();
        }, 100);
    }

    // ============ INITIALIZATION & MONITORING ============

    setInterval(() => {
        const container = document.querySelector(MODE_CONFIG.containerSelector);
        const existingBar = document.getElementById('gemini-quick-switch-bar');

        if (container && !existingBar) {
            injectButtons(container);
            lastContainerCheck = container;

            if (resizeObserver) {
                resizeObserver.disconnect();
            }
            resizeObserver = new ResizeObserver(updateButtonLayout);
            resizeObserver.observe(container);
        }

        if (existingBar && !document.body.contains(existingBar)) {
            lastContainerCheck = null;
            if (resizeObserver) {
                resizeObserver.disconnect();
                resizeObserver = null;
            }
        }

        if (existingBar) {
            updateActiveState();
        }
    }, MODE_CONFIG.checkInterval);

    window.addEventListener('resize', updateButtonLayout);

    // ============ NAVIGATION HANDLING ============

    let lastUrl = location.href;
    let navigationDebounceTimeout = null;
    conversationId = getConversationId();

    function handleNavigationChange() {
        const currentConversationId = getConversationId();

        if (currentConversationId !== conversationId) {
            conversationId = currentConversationId;

            console.log('Gemini Switcher: Conversation changed:', {
                id: conversationId,
                isOnConversationPage: isOnConversationPage()
            });

            if (isOnConversationPage() && savedModeIndex !== -1) {
                console.log('Gemini Switcher: On conversation page, will reapply preference');
                reapplyAttempts = 0;
                lastReapplyTime = 0;

                setTimeout(() => {
                    checkAndReapply();
                }, MODE_CONFIG.reapplyDelay);
            }
        }
    }

    new MutationObserver(() => {
        const currentUrl = location.href;

        if (currentUrl !== lastUrl) {
            lastUrl = currentUrl;
            lastContainerCheck = null;
            isSwitching = false;
            clearTimeout(switchTimeout);

            if (resizeObserver) {
                resizeObserver.disconnect();
                resizeObserver = null;
            }

            console.log('Gemini Switcher: Navigation detected');

            clearTimeout(navigationDebounceTimeout);
            navigationDebounceTimeout = setTimeout(() => {
                handleNavigationChange();
            }, 500);
        }

    }).observe(document.body, { childList: true, subtree: true });

    // ============ INITIAL LOAD HANDLING ============

    if (isOnConversationPage() && savedModeIndex !== -1) {
        console.log('Gemini Switcher: Initial load on conversation page, checking state...');

        const retryDelays = [1500, 2500, 4000];

        retryDelays.forEach(delay => {
            setTimeout(() => {
                if (reapplyAttempts < MODE_CONFIG.maxReapplyAttempts) {
                    const trigger = findMenuTrigger();
                    if (trigger) {
                        console.log(`Gemini Switcher: Trigger found at ${delay}ms, attempting reapply`);
                        checkAndReapply();
                    } else {
                        console.log(`Gemini Switcher: Trigger not found at ${delay}ms, will retry`);
                    }
                }
            }, delay);
        });
    }

})();