Google Plus & Bing Plus

Add Gemini response, improve speed to search results(Bing), add Google/Bing search buttons, Gemini On/Off toggle

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Google Plus & Bing Plus
// @version      8.7
// @description  Add Gemini response, improve speed to search results(Bing), add Google/Bing search buttons, Gemini On/Off toggle
// @author       monit8280
// @match        https://www.bing.com/search*
// @match        https://www.google.com/search*
// @match        https://www.google.co.kr/search*
// @match        https://*.google.com/search*
// @match        https://*.google.co.kr/search*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      generativelanguage.googleapis.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js
// @license      MIT
// @namespace http://tampermonkey.net/
// ==/UserScript==

(function () {
    'use strict';

    /**
     * ==========================================================================================
     * 1. CONFIGURATION & CONSTANTS
     * ==========================================================================================
     */
    const Config = {
        API: {
            DEFAULT_MODEL: 'gemini-3.1-flash-lite',
            BASE_URL: 'https://generativelanguage.googleapis.com/v1beta/models/'
        },
        MODELS: [
            { id: 'gemini-3.1-flash-lite', name: '3.1 Flash Lite' },
            { id: 'gemini-3.5-flash', name: '3.5 Flash' },
            { id: 'gemini-3.1-pro-preview', name: '3.1 Pro' }
        ],
        STORAGE: {
            PREFIX: 'gemini_',
            API_KEY: 'geminiApiKey',
            ENABLED: 'geminiEnabled',
            RAG_ENABLED: 'geminiRagEnabled',
            THEME: 'themeMode',
            MODEL: 'selectedModel'
        },
        UI: {
            PADDING: 16,
            BORDER_RADIUS: '12px', // Rounded aesthetics
            ANIMATION_SPEED: '0.2s'
        },
        ASSETS: {
            GOOGLE_LOGO: 'https://www.gstatic.com/images/branding/searchlogo/ico/favicon.ico',
            BING_LOGO: 'https://account.microsoft.com/favicon.ico',
            GEMINI_LOGO: 'https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg',
            REFRESH_ICON: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIFVwbG9hZGVkIHRvOiBTVkcgUmVwbywgd3d3LnN2Z3JlcG8uY29tLCBHZW5lcmF0b3I6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPg0KPHN2ZyB3aWR0aD0iODAwcHgiIGhlaWdodD0iODAwcHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCjxwYXRoIGQ9Ik00LjA2MTg5IDEzQzQuMDIxMDQgMTIuNjcyNCA0IDEyLjMzODcgNCAxMkM0IDcuNTgxNzIgNy41ODE3MiA0IDEyIDRDMTQuNTAwNiA0IDE2LjczMzIgNS4xNDcyNyAxOC4yMDAyIDYuOTQ0MTZNMTkuOTM4MSAxMUMxOS45NzkgMTEuMzI3NiAyMCAxMS42NjEzIDIwIDEyQzIwIDE2LjQxODMgMTYuNDE4MyAyMCAxMiAyMEM5LjYxMDYxIDIwIDcuNDY1ODkgMTguOTUyNSA2IDE3LjI5MTZNOSAxN0g2VjE3LjI5MTZNMTguMjAwMiA0VjYuOTQ0MTZNMTguMjAwMiA2Ljk0NDE2VjYuOTk5OTNMMTUuMjAwMiA3TTYgMjBWMTcuMjkxNiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPg0KPC9zdmc+',
            LIGHT_ICON: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gVXBsb2FkZWQgdG86IFNWRyBSZXBvLCB3d3cuc3ZncmVwby5jb20sIEdlbmVyYXRvcjogU1ZHIFJlcG8gTWl4ZXIgVG9vbHMgLS0+DQo8c3ZnIHdpZHRoPSI4MDBweCIgaGVpZ2h0PSI4MDBweCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgY2xhc3M9Imljb24iICB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg2MSA2NTYuN2wxNDQuNi0xNDQuNkw4NjEgMzY3LjZWMTYzLjFINjU2LjZMNTEyIDE4LjYgMzY3LjQgMTYzLjFIMTYzdjIwNC41TDE4LjQgNTEyLjEgMTYzIDY1Ni43djIwNC40aDIwNC40TDUxMiAxMDA1LjdsMTQ0LjYtMTQ0LjZIODYxeiIgZmlsbD0iI0ZDRDE3MCIgLz48cGF0aCBkPSJNNTEyIDEwMTUuN2MtMi42IDAtNS4xLTEtNy4xLTIuOUwzNjMuMyA4NzEuMUgxNjNjLTUuNSAwLTEwLTQuNS0xMC0xMFY2NjAuOEwxMS40IDUxOS4yYy0xLjktMS45LTIuOS00LjQtMi45LTcuMSAwLTIuNyAxLjEtNS4yIDIuOS03LjFMMTUzIDM2My40VjE2My4xYzAtNS41IDQuNS0xMCAxMC0xMGgyMDAuM0w1MDQuOSAxMS41YzEuOS0xLjkgNC40LTIuOSA3LjEtMi45czUuMiAxLjEgNy4xIDIuOWwxNDEuNiAxNDEuNkg4NjFjNS41IDAgMTAgNC41IDEwIDEwdjIwMC4zTDEwMTIuNiA1MDVjMS45IDEuOSAyLjkgNC40IDIuOSA3LjEgMCAyLjctMS4xIDUuMi0yLjkgNy4xTDg3MSA2NjAuOHYyMDAuM2MwIDUuNS00LjUgMTAtMTAgMTBINjYwLjdsLTE0MS42IDE0MS42Yy0yIDItNC41IDMtNy4xIDN6TTE3MyA4NTEuMWgxOTQuNGMyLjcgMCA1LjIgMS4xIDcuMSAyLjlMNTEyIDk5MS42bDEzNy41LTEzNy41YzEuOS0xLjkgNC40LTIuOSA3LjEtMi45SDg1MVY2NTYuN2MwLTIuNyAxLjEtNS4yIDIuOS03LjFsMTM3LjUtMTM3LjUtMTM3LjUtMTM3LjVjLTEuOS0xLjktMi45LTQuNC0yLjktNy4xVjE3My4xSDY1Ni42Yy0yLjcgMC01LjItMS4xLTcuMS0yLjlMNTEyIDMyLjcgMzc0LjUgMTcwLjJjLTEuOSAxLjktNC40IDIuOS03LjEgMi45SDE3M3YxOTQuNGMwIDIuNy0xLjEgNS4yLTIuOSA3LjFMMzIuNiA1MTIuMWwxMzcuNSAxMzcuNWMxLjkgMS45IDIuOSA0LjQgMi45IDcuMXYxOTQuNHoiIGZpbGw9IiIgLz48cGF0aCBkPSJNNTEyIDUxMi4xbS0yNTcuOCAwYTI1Ny44IDI1Ny44IDAgMSAwIDUxNS42IDAgMjU3LjggMjU3LjggMCAxIDAtNTE1LjYgMFoiIGZpbGw9IiNGN0REQUQiIC8+PHBhdGggZD0iTTUxMiA3NzkuOWMtNzEuNSAwLTEzOC44LTI3LjktMTg5LjQtNzguNC01MC42LTUwLjYtNzguNC0xMTcuOC03OC40LTE4OS40czI3LjktMTM4LjggNzguNC0xODkuNGM1MC42LTUwLjYgMTE3LjgtNzguNCAxODkuNC03OC40IDcxLjUgMCAxMzguOCAyNy45IDE4OS40IDc4LjQgNTAuNiA1MC42IDc4LjQgMTE3LjggNzguNCAxODkuNFM3NTIgNjUwLjkgNzAxLjQgNzAxLjUgNTgzLjUgNzc5LjkgNTEyIDc3OS45eiBtMC01MTUuNmMtNjYuMiAwLTEyOC40IDI1LjgtMTc1LjIgNzIuNi00Ni44IDQ2LjgtNzIuNiAxMDktNzIuNiAxNzUuMnMyNS44IDEyOC40IDcyLjYgMTc1LjJjNDYuOCA0Ni44IDEwOSA3Mi42IDE3NS4yIDcyLjYgNjYuMiAwIDEyOC40LTI1LjggMTc1LjItNzIuNiA0Ni44LTQ2LjggNzIuNiAxMDkgNzIuNi0xNzUuMlM3MzQgMzgzLjcgNjg3LjIgMzM2LjljLTQ2LjgtNDYuOC0xMDktNzIuNi0xNzUuMi03Mi42eiIgZmlsbD0iIiAvPjwvc3ZnPg==',
            DARK_ICON: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIFVwbG9hZGVkIHRvOiBTVkcgUmVwbywgd3d3LnN2Z3JlcG8uY29tLCBHZW5lcmF0b3I6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPg0KPHN2ZyB3aWR0aD0iODAwcHgiIGhlaWdodD0iODAwcHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCjxwYXRoIGQ9Ik0xOS45MDAxIDIuMzA3MTlDMTkuNzM5MiAxLjg5NzYgMTkuMTYxNiAxLjg5NzYgMTkuMDAwNyAyLjMwNzE5TDE4LjU3MDMgMy40MDI0N0MxOC41MjEyIDMuNTI3NTIgMTguNDIyNiAzLjYyNjUxIDE4LjI5OCAzLjY3NTgzTDE3LjIwNjcgNC4xMDc4QzE2Ljc5ODYgNC4yNjkzNCAxNi43OTg2IDQuODQ5IDE3LjIwNjcgNS4wMTA1NEwxOC4yOTggNS40NDI1MkMxOC40MjI2IDUuNDkxODQgMTguNTIxMiA1LjU5MDgyIDE4LjU3MDMgNS43MTU4N0wxOS4wMDA3IDYuODExMTVDMTkuMTYxNiA3LjIyMDc0IDE5LjczOTIgNy4yMjA3NCAxOS45MDAxIDYuODExMTZMMjAuMzMwNSA1LjcxNTg3QzIwLjM3OTYgNS41OTA4MiAyMC40NzgyIDUuNDkxODQgMjAuNjAyOCA1LjQ0MjUyTDIxLjY5NDEgNS4wMTA1NEMyMi4xMDIyIDQuODQ5IDIyLjEwMjIgNC4yNjkzNCAyMS42OTQxIDQuMTA3OEwyMC42MDI4IDMuNjc1ODNDMjAuNDc4MiAzLjYyNjUxIDIwLjM3OTYgMy41Mjc1MiAyMC4zMzA1IDMuNDAyNDdMMTkuOTAwMSAyLjMwNzE5WiIgZmlsbD0iIzFDMjc0QyIvPg0KPHBhdGggZD0iTTE2LjAzMjggOC4xMjk2N0MxNS44NzE4IDcuNzIwMDkgMTUuMjk0MyA3LjcyMDA5IDE1LjEzMzMgOC4xMjk2N0wxNC45NzY0IDguNTI5MDJDMTQuOTI3MyA4LjY1NDA3IDE0LjgyODcgOC43NTMwNSAxNC43MDQxIDguODAyMzdMMTQuMzA2MiA4Ljk1OTg3QzEzLjg5ODEgOS4xMjE0MSAxMy44OTgxIDkuNzAxMDcgMTQuMzA2MiA5Ljg2MjYxTDE0LjcwNDEgMTAuMDIwMUMxNC44Mjg3IDEwLjA2OTQgMTQuOTI3MyAxMC4xNjg0IDE0Ljk3NjQgMTAuMjkzNUwxNS4xMzMzIDEwLjY5MjhDMTUuMjk0MyAxMS4xMDI0IDE1Ljg3MTggMTEuMTAyNCAxNi4wMzI4IDEwLjY5MjhMMTYuMTg5NyAxMC4yOTM1QzE2LjIzODggMTAuMTY4NCAxNi4zMzc0IDEwLjA2OTQgMTYuNDYyIDEwLjAyMDFMMTYuODU5OSA5Ljg2MjYxQzE3LjI2OCA5LjcwMTA3IDE3LjI2OCA5LjEyMTQxIDE2Ljg1OTkgOC45NTk4N0wxNi40NjIgOC44MDIzN0MxNi4zMzc0IDguNzUzMDUgMTYuMjM4OCA4LjY1NDA3IDE2LjE4OTcgOC41MjkwMkwxNi4wMzI4IDguMTI5NjdaIiBmaWxsPSIjMUMyNzRDIi8+DQo8cGF0aCBkPSJNMTIgMjJDMTcuNTIyOCAyMiAyMiAxNy41MjI4IDIyIDEyQzIyIDExLjUzNzMgMjEuMzA2NSAxMS40NjA4IDIxLjA2NzIgMTEuODU2OEMxOS45Mjg5IDEzLjc0MDYgMTcuODYxNSAxNSAxNS41IDE1QzExLjkxMDEgMTUgOSAxMi4wODk5IDkgOC41QzkgNi4xMzg0NSAxMC4yNTk0IDQuMDcxMDUgMTIuMTQzMiAyLjkzMjc2QzEyLjUzOTIgMi42OTM0NyAxMi40NjI3IDIgMTIgMkM2LjQ3NzE1IDIgMiA2LjQ3NzE1IDIgMTJDMiAxNy41MjI4IDYuNDc3MTUgMjIgMTIgMjJaIiBmaWxsPSIjMUMyNzRDIi8+DQo8L3N2Zz4='
        }
    };

    /**
     * ==========================================================================================
     * 2. CORE UTILITIES
     * ==========================================================================================
     */
    const Utils = {
        Storage: {
            // GM 저장소: 페이지 스크립트에서 접근 불가 + google/bing 도메인 간 설정 공유
            get: (key) => GM_getValue(key, null),
            set: (key, val) => GM_setValue(key, val),
            getBool: (key, def = true) => {
                const val = GM_getValue(key, null);
                return val === null ? def : val === 'true';
            }
        },
        Env: {
            isGoogle: () => window.location.hostname.includes('google.'),
            isBing: () => window.location.hostname.includes('bing.com'),
            getQuery: () => new URLSearchParams(window.location.search).get('q'),
            // UA만 검사 — 터치스크린 노트북이 모바일로 오판되던 문제 방지
            isMobile: () => /Android|iPhone|Mobile/i.test(navigator.userAgent),
            isKorean: () => navigator.language.includes('ko')
        },
        I18n: {
            MESSAGES: {
                prompt: { ko: '"${query}"에 대한 정보를 마크다운 형식으로 작성해줘', default: 'Please write information about "${query}" in markdown format' },
                enterApiKey: { ko: 'Gemini API 키 입력:', default: 'Enter Gemini API Key:' },
                geminiEmpty: { ko: '⚠️ 응답이 비어있습니다.', default: '⚠️ Response is empty.' },
                error: { ko: '오류 발생:', default: 'Error:' },
                networkError: { ko: '⚠️ 네트워크 오류가 발생했습니다.', default: '⚠️ Network error occurred.' },
                timeoutError: { ko: '⚠️ 30초 내에 응답이 오지 않았습니다 (시간 초과).', default: '⚠️ No response within 30s (timed out).' },
                promptBlocked: { ko: '⚠️ 요청이 차단되었습니다 (${reason}).', default: '⚠️ Prompt blocked (${reason}).' },
                noContent: { ko: '⚠️ 응답 내용이 없습니다 (${reason}).', default: '⚠️ No content returned (${reason}).' },
                apiKeyHint: { ko: '요청에 문제가 있나요? API 키가 잘못되었을 수 있습니다. 우측 하단 🔑 버튼을 눌러 키를 변경해 보세요.', default: 'Something went wrong? Your API key may be invalid. Click the 🔑 button at the bottom-right to change it.' },
                changeApiKey: { ko: 'API 키 변경', default: 'Change API key' },
                save: { ko: '저장 후 새로고침', default: 'Save & Refresh' },
                copy: { ko: '복사', default: 'Copy' },
                copied: { ko: '복사됨!', default: 'Copied!' },
                copyFailed: { ko: '실패', default: 'Failed' },
                toggleTheme: { ko: '테마 전환', default: 'Toggle Theme' },
                refresh: { ko: '새로고침', default: 'Refresh' },
                ragTooltip: { ko: '${count}개의 검색 결과를 반영하여 보강된 답변입니다.', default: 'Answer augmented with ${count} search results.' },
                ragOn: { ko: 'RAG ON (실시간 검색 보강 사용 중)', default: 'RAG ON (search augmentation active)' },
                ragOff: { ko: 'RAG OFF (실시간 검색 보강 사용 안 함)', default: 'RAG OFF (search augmentation disabled)' },
                geminiOff: { ko: 'Gemini가 꺼져있습니다.', default: 'Gemini is OFF' },
                searchGoogle: { ko: 'Google 검색', default: 'Search on Google' },
                searchBing: { ko: 'Bing 검색', default: 'Search on Bing' }
            },
            get(key, vars = {}) {
                const lang = Utils.Env.isKorean() ? 'ko' : 'default';
                const tmpl = this.MESSAGES[key]?.[lang] || this.MESSAGES[key]?.default || '';
                return tmpl.replace(/\$\{(.*?)\}/g, (_, k) => vars[k] || '');
            }
        }
    };

    /**
     * ==========================================================================================
     * 3. STATE MANAGEMENT (Theme & Settings)
     * ==========================================================================================
     */
    const State = {
        theme: 'light',
        init() {
            const saved = Utils.Storage.get(Config.STORAGE.THEME);
            this.theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
            this.applyTheme();
            window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
                // 사용자가 수동으로 테마를 저장해뒀으면 OS 변경을 무시 (수동 선택 우선)
                if (Utils.Storage.get(Config.STORAGE.THEME)) return;
                this.theme = e.matches ? 'dark' : 'light';
                this.applyTheme();
            });
        },
        toggleTheme() {
            this.theme = this.theme === 'light' ? 'dark' : 'light';
            Utils.Storage.set(Config.STORAGE.THEME, this.theme);
            this.applyTheme();
        },
        applyTheme() {
            document.documentElement.classList.toggle('gemini-dark-mode', this.theme === 'dark');
            const btn = document.getElementById('gemini-theme-toggle-btn');
            if (btn) btn.src = this.getThemeIcon();
        },
        getThemeIcon() {
            return this.theme === 'light' ? Config.ASSETS.DARK_ICON : Config.ASSETS.LIGHT_ICON;
        },
        // Model & API Key
        getApiKey: () => Utils.Storage.get(Config.STORAGE.API_KEY),
        setApiKey: (key) => Utils.Storage.set(Config.STORAGE.API_KEY, key),
        getModel: () => Utils.Storage.get(Config.STORAGE.MODEL) || Config.API.DEFAULT_MODEL,
        setModel: (model) => Utils.Storage.set(Config.STORAGE.MODEL, model),
        isEnabled: () => Utils.Storage.getBool(Config.STORAGE.ENABLED),
        setEnabled: (v) => Utils.Storage.set(Config.STORAGE.ENABLED, v ? 'true' : 'false'),
        isRagEnabled: () => Utils.Storage.getBool(Config.STORAGE.RAG_ENABLED, true),
        setRagEnabled: (v) => Utils.Storage.set(Config.STORAGE.RAG_ENABLED, v ? 'true' : 'false')
    };

    /**
     * ==========================================================================================
     * 4. STYLES
     * ==========================================================================================
     */
    const Styles = {
        inject() {
            const css = `
                :root {
                    --g-bg: #fff; --g-border: #e0e0e0; --g-text: #000; --g-title: #000;
                    --g-btn-bg: #f0f3ff; --g-btn-border: #ccc; --g-code-bg: #f0f0f0;
                    --g-icon-filter: none;
                }
                .gemini-dark-mode {
                    --g-bg: #282c34; --g-border: #444; --g-text: #e0e0e0; --g-title: #fff;
                    --g-btn-bg: #3a3f4b; --g-btn-border: #555; --g-code-bg: #3b3b3b;
                    --g-icon-filter: invert(1);
                }
                /* Layout */
                #gemini-wrapper { margin-bottom: 20px; font-family: sans-serif; }
                #gemini-top-row { display: flex; gap: 8px; margin-bottom: 12px; width: 100%; }

                /* Buttons & Selects */
                .gemini-btn, .gemini-select {
                    height: 40px; border-radius: ${Config.UI.BORDER_RADIUS}; font-size: 13px;
                    display: flex; align-items: center; justify-content: center;
                    cursor: pointer; transition: all ${Config.UI.ANIMATION_SPEED};
                    border: 1px solid var(--g-btn-border);
                    background: var(--g-btn-bg) !important; /* Bing 스타일 오버라이드 */
                    color: var(--g-title) !important;
                    box-sizing: border-box; overflow: hidden; /* 오버플로우 방지 */
                }
                .gemini-btn img {
                    width: 16px !important; height: 16px !important;
                    object-fit: contain; /* 이미지 비율 유지 */
                }
                .gemini-btn:hover, .gemini-select:hover { transform: translateY(-2px); box-shadow: 0 2px 5px rgba(0,0,0,0.15); }
                /* flex 비율은 createTopRow의 인라인 style이 단일 소스 */
                #google-search-btn, #bing-search-btn { gap: 8px; }
                #gemini-model-select-top {
                    appearance: none; text-align-last: center; text-align: center;
                    outline: none; padding: 0 !important; margin: 0;
                }
                #gemini-model-select-top option { text-align: center; }

                /* Main Box */
                #gemini-box {
                    width: 100%;
                    padding: ${Config.UI.PADDING}px;
                    border: 1px solid var(--g-border); border-radius: ${Config.UI.BORDER_RADIUS};
                    background: var(--g-bg); color: var(--g-text);
                    box-sizing: border-box; overflow: hidden;
                }
                #gemini-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
                #gemini-time-info { font-size: 12px; color: #888; font-weight: normal; margin-left: 6px; }
                #gemini-logo { width: 24px; height: 24px; cursor: pointer; transition: transform 0.2s; }
                #gemini-logo:hover { transform: scale(1.1); }
                #gemini-actions { display: flex; align-items: center; gap: 10px; }

                /* Content & Markdown */
                #gemini-content {
                    font-size: 14px; line-height: 1.6; color: var(--g-text);
                    padding-top: 4px;
                    max-height: 650px; overflow-y: auto;
                    scrollbar-width: none; /* Firefox: 스크롤바 숨김 (스크롤은 유지) */
                }
                #gemini-content p { margin: 0 0 12px 0; } /* 문단 간격 */
                #gemini-content::-webkit-scrollbar { display: none; } /* WebKit: 스크롤바 숨김 */
                #gemini-content h1, #gemini-content h2, #gemini-content h3 { margin-top: 20px; margin-bottom: 10px; }
                #gemini-content ul, #gemini-content ol { padding-left: 20px; margin: 10px 0; }
                #gemini-content li { margin-bottom: 5px; }
                #gemini-content pre {
                    background: #1e1e1e;
                    color: #abb2bf;
                    padding: 38px 12px 12px 12px;
                    border-radius: 8px;
                    overflow-x: auto;
                    margin: 12px 0;
                    position: relative;
                    border: 1px solid var(--g-border);
                }
                .gemini-dark-mode #gemini-content pre {
                    background: #181a1f;
                }
                .code-header {
                    position: absolute;
                    top: 0; left: 0; right: 0;
                    height: 30px;
                    background: rgba(0, 0, 0, 0.3);
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    padding: 0 12px;
                    font-size: 11px;
                    font-family: monospace;
                    color: #888;
                    border-bottom: 1px solid rgba(255, 255, 255, 0.05);
                }
                .code-copy-btn-new {
                    background: transparent;
                    color: #abb2bf;
                    border: none;
                    font-size: 11px;
                    cursor: pointer;
                    padding: 2px 6px;
                    border-radius: 4px;
                    transition: background 0.2s;
                }
                .code-copy-btn-new:hover {
                    background: rgba(255, 255, 255, 0.1);
                }
                .gemini-divider { height: 1px; border: 0; background: var(--g-border); }
                #gemini-divider { margin: 8px 0 16px 0; }
                #gemini-footer-divider { margin: 12px 0 8px 0; }
                /* 구분선 바로 아래 한 줄에 RAG 태그(좌)와 🔑(우) — flex로 같은 줄에 붙여 구분선과의 간격 어긋남 방지 */
                #gemini-footer-bar { display: flex; align-items: center; justify-content: space-between; }

                /* Icons - Specific Animations */
                .gemini-icon-btn {
                    width: 20px; height: 20px; cursor: pointer; opacity: 0.6; transition: 0.3s ease;
                    vertical-align: middle;
                }
                .gemini-icon-btn:hover { opacity: 1; }

                #gemini-theme-toggle-btn:hover { transform: scale(1.2); }

                #gemini-refresh-btn { transition: transform 0.6s ease-in-out; filter: var(--g-icon-filter); }
                #gemini-refresh-btn:hover { transform: rotate(360deg); }

                /* Toggle Switch */
                .g-toggle { width: 44px; height: 24px; border-radius: 12px; position: relative; cursor: pointer; transition: background 0.2s; }
                .g-knob { width: 22px; height: 22px; background: #fff; border-radius: 50%; position: absolute; top: 1px; left: 1px; transition: left 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
                .g-toggle.on { background: #d1d5db; } .g-toggle.off { background: #3c4043; }
                .g-toggle.on .g-knob { left: 21px; }

                /* API Key Input */
                #gemini-api-container { display: flex; flex-direction: column; gap: 10px; padding: 10px 0; }
                #gemini-api-input { width: 95%; padding: 10px; border-radius: 8px; border: 1px solid var(--g-border); background: var(--g-bg); color: var(--g-text); }
                #gemini-api-save-btn { align-self: flex-end; padding: 8px 16px; background: #4285f4; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
                #gemini-api-hint { margin-top: 8px; font-size: 12px; color: #888; }

                /* API Key Button (하단 바 우측) */
                #gemini-key-btn {
                    cursor: pointer; font-size: 15px; line-height: 1; padding: 2px 4px;
                    border-radius: 6px; background: var(--g-bg); opacity: 0.5;
                    transition: opacity 0.2s, transform 0.2s;
                }
                #gemini-key-btn:hover { opacity: 1; transform: scale(1.15); }

                /* RAG Badge (하단 바 좌측) — visibility로 숨김: 숨겨도 자리를 유지해야
                   justify-content: space-between에서 🔑가 우측에 고정됨 (display:none이면 🔑가 좌측으로 밀림) */
                #gemini-rag-badge {
                    visibility: hidden;
                    font-size: 10px; font-weight: bold; line-height: 1;
                    padding: 4px 6px; border-radius: 4px; color: #fff;
                    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                    box-shadow: 0 1px 3px rgba(0,0,0,0.12);
                }

                /* Device Specific */
                #b_context { width: 432px !important; }

                /* Skeleton Loading Animation */
                @keyframes shimmer {
                    0% { background-position: 200% 0; }
                    100% { background-position: -200% 0; }
                }
                .skeleton-loader { display: flex; flex-direction: column; gap: 12px; padding: 4px 0; }
                .skeleton-line {
                    height: 14px; width: 100%; border-radius: 4px;
                    background: linear-gradient(90deg, var(--g-bg) 25%, var(--g-border) 50%, var(--g-bg) 75%);
                    background-size: 200% 100%; animation: shimmer 1.5s infinite;
                }
                .skeleton-line.short { width: 60%; }
                .skeleton-line.medium { width: 85%; }

                /* RAG 토글 — ON은 기본 버튼과 동일, OFF만 색상 구분 (.gemini-btn의 !important를 이기려면 여기도 !important 필요) */
                #gemini-rag-toggle-btn { position: relative; }
                #gemini-rag-toggle-btn.rag-off {
                    background: rgba(239, 68, 68, 0.08) !important;
                    color: #ef4444 !important;
                }
                #gemini-rag-toggle-btn.rag-off::after {
                    content: ''; position: absolute; inset: 0; pointer-events: none;
                    background: linear-gradient(
                        to bottom left,
                        transparent calc(50% - 1px),
                        var(--g-btn-border) calc(50% - 1px),
                        var(--g-btn-border) calc(50% + 1px),
                        transparent calc(50% + 1px)
                    );
                }
            `;
            GM_addStyle(css);
        }
    };

    /**
     * ==========================================================================================
     * 5. API & SERVICES
     * ==========================================================================================
     */
    const SearchScraper = {
        // 셀렉터를 우선순위 순으로 시도 (querySelector 콤마 목록은 "문서 순서상 첫 매치"라 덜 구체적인 셀렉터가 먼저 잡히는 문제 방지)
        firstMatch(item, selectors) {
            return selectors.reduce((el, sel) => el || item.querySelector(sel), null);
        },

        getResults() {
            if (!State.isRagEnabled()) return []; // RAG 비활성화 시 빈 결과 반환
            const results = [];
            const isGoogle = Utils.Env.isGoogle();
            const isBing = Utils.Env.isBing();

            if (isGoogle) {
                // 구글 검색 결과 아이템 셀렉터 (div.g, div.MjjYud, .tF2Cxc 등 포함)
                const items = document.querySelectorAll('div.g, div.MjjYud, .tF2Cxc');
                items.forEach(item => {
                    const titleEl = item.querySelector('h3');
                    // 스니펫: 구체적인 셀렉터부터 우선 시도 (line-clamp 스타일 div 등 새 레이아웃 대응)
                    const snippetEl = this.firstMatch(item, ['div.VwiC3b', 'span.aCOpbc', 'div.MUxGec', 'div[style*="-webkit-line-clamp"]', '.yXM1m', '.kb0pb']);
                    if (titleEl && snippetEl) {
                        const title = titleEl.textContent.trim();
                        const snippet = snippetEl.textContent.trim();
                        if (title && snippet) {
                            results.push({ title, snippet });
                        }
                    }
                });
            } else if (isBing) {
                // 빙 검색 결과 아이템 셀렉터 (li.b_algo)
                const items = document.querySelectorAll('li.b_algo');
                items.forEach(item => {
                    const titleEl = item.querySelector('h2');
                    // 스니펫: 구체적인 캡션부터 우선, 무방비한 p는 마지막 폴백
                    const snippetEl = this.firstMatch(item, ['div.b_caption p', '.b_algoSlug', 'div.b_attribution + p', 'p']);
                    if (titleEl && snippetEl) {
                        const title = titleEl.textContent.trim();
                        const snippet = snippetEl.textContent.trim();
                        if (title && snippet) {
                            results.push({ title, snippet });
                        }
                    }
                });
            }

            // 중복 제거 및 상위 5개 결과 추출
            const unique = [];
            const seen = new Set();
            for (const res of results) {
                const key = res.title + res.snippet;
                if (!seen.has(key)) {
                    seen.add(key);
                    unique.push(res);
                }
            }
            return unique.slice(0, 5);
        }
    };

    const GeminiAPI = {
        buildRagPrompt(query, results) {
            const defaultPrompt = Utils.I18n.get('prompt', { query });
            if (!results || results.length === 0) {
                return defaultPrompt;
            }

            let context = "Below are the top search results from the search engine for this query:\n\n";
            results.forEach((res, index) => {
                context += `[Search Result ${index + 1}]\nTitle: ${res.title}\nSnippet: ${res.snippet}\n\n`;
            });

            if (Utils.Env.isKorean()) {
                return `${context}
위 제공된 검색 결과 요약(Search Results)들을 신뢰성 있는 주요 정보원으로 사용하여, 다음 질문에 친절하고 상세하게 한국어로 답변해 주세요.
답변할 때 다음 지침을 지키십시오:
1. 제공된 검색 결과의 핵심 팩트를 우선적으로 정리하고 왜곡하지 마세요.
2. 필요하다면 본인의 일반 지식(우회 지식)을 결합하여 설명하되, 검색 결과와 모순되거나 잘못된 정보가 생기지 않도록 주의하세요.
3. 답변은 읽기 쉽도록 명확한 문단 구분과 마크다운(Markdown) 형식을 사용해 작성해 주세요.

질문: ${query}`;
            } else {
                return `${context}
Using the provided search results as the primary reliable sources of information, write a helpful and detailed response to the query: "${query}"
Please follow these guidelines:
1. Synthesize the core facts from the search results accurately without distortion.
2. If necessary, combine with your general knowledge, but ensure there are no contradictions with the search results.
3. Write the response in a structured and easy-to-read markdown format.`;
            }
        },

        activeReq: null,
        activeTimer: null,

        // 캐시 키에 모델 포함 → 모델 전환 시 캐시가 모델별로 공존
        cacheKey(query) {
            return `${Config.STORAGE.PREFIX}${State.getModel()}_${query}`;
        },
        // 메타 키는 \x1f(unit separator) 구분자 사용 — 쿼리가 '_time'/'_rag_count'로 끝나도 답변 키와 충돌하지 않음
        metaKey(key, suffix) {
            return `${key}\x1f${suffix}`;
        },

        // 진행 중인 요청/스톱워치 정리 — 늦게 도착한 응답이 화면을 덮어쓰는 경합 방지
        // (캐시 히트·토글 OFF·키 미설정 등 fetch를 안 거치는 경로에서도 호출해야 함)
        abort() {
            if (this.activeReq) { this.activeReq.abort(); this.activeReq = null; }
            if (this.activeTimer) { clearInterval(this.activeTimer); this.activeTimer = null; }
        },

        fetch(query, container, apiKey) {
            this.abort();

            container.innerHTML = `
                <div class="skeleton-loader">
                    <div class="skeleton-line medium"></div>
                    <div class="skeleton-line"></div>
                    <div class="skeleton-line short"></div>
                </div>
            `;

            // 시간 표시 초기화
            UI.setTimeInfo('0.00');
            const timeSpan = document.getElementById('gemini-time-info');

            const model = State.getModel();
            const results = SearchScraper.getResults();
            UI.updateRagBadge(results.length);

            const prompt = this.buildRagPrompt(query, results);
            const startTime = performance.now();

            // 스톱워치 시작
            if (timeSpan) {
                this.activeTimer = setInterval(() => {
                    const current = ((performance.now() - startTime) / 1000).toFixed(2);
                    timeSpan.textContent = `(${current}s)`;
                }, 10);
            }

            const finish = () => {
                if (this.activeTimer) {
                    clearInterval(this.activeTimer);
                    this.activeTimer = null;
                }
                this.activeReq = null;
            };

            const req = GM_xmlhttpRequest({
                method: 'POST',
                url: `${Config.API.BASE_URL}${model}:generateContent`,
                // API 키는 URL 대신 헤더로 전송 (프록시/로그 노출 방지)
                headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },
                data: JSON.stringify({
                    contents: [{ parts: [{ text: prompt }] }]
                }),
                timeout: 30000,
                onload: (res) => {
                    if (req !== this.activeReq) return; // 취소된 요청의 늦은 응답 무시
                    finish();
                    try {
                        const duration = ((performance.now() - startTime) / 1000).toFixed(2);
                        if (timeSpan) timeSpan.textContent = `(${duration}s)`;

                        if (res.status !== 200) {
                            let msg = `HTTP ${res.status}`;
                            try { msg = JSON.parse(res.responseText).error?.message || msg; } catch (_) { }
                            throw new Error(msg);
                        }

                        const data = JSON.parse(res.responseText);
                        const cand = data.candidates?.[0];
                        // 여러 part로 쪼개진 텍스트를 모두 이어붙임 (첫 조각만 읽으면 답변이 잘림)
                        const text = cand?.content?.parts?.filter(p => typeof p.text === 'string').map(p => p.text).join('') || '';
                        if (!text) {
                            // 안전 차단·비정상 종료 사유를 '응답 비어있음'으로 뭉뚱그리지 않고 구체적으로 표시
                            const blockReason = data.promptFeedback?.blockReason;
                            const finishReason = cand?.finishReason;
                            if (blockReason) throw new Error(Utils.I18n.get('promptBlocked', { reason: blockReason }));
                            if (finishReason && finishReason !== 'STOP') throw new Error(Utils.I18n.get('noContent', { reason: finishReason }));
                            throw new Error(data.error?.message || Utils.I18n.get('geminiEmpty'));
                        }

                        // 렌더를 먼저 — 캐시 저장(setItem)이 quota로 실패해도 받은 답변은 잃지 않도록
                        UI.renderMarkdown(container, text);
                        try {
                            const key = this.cacheKey(query);
                            sessionStorage.setItem(key, text);
                            sessionStorage.setItem(this.metaKey(key, 'time'), duration); // 시간 저장
                            sessionStorage.setItem(this.metaKey(key, 'rag_count'), results.length); // RAG 개수 저장
                        } catch (_) { /* QuotaExceededError 등은 무시 — 캐시는 있으면 좋은 것 */ }
                    } catch (e) {
                        UI.showError(container, `${Utils.I18n.get('error')} ${e.message}`);
                    }
                },
                onerror: (res) => {
                    if (req !== this.activeReq) return;
                    finish();
                    const detail = res && (res.error || res.statusText);
                    UI.showError(container, detail ? `${Utils.I18n.get('networkError')} (${detail})` : Utils.I18n.get('networkError'));
                },
                ontimeout: () => {
                    if (req !== this.activeReq) return;
                    finish();
                    UI.showError(container, Utils.I18n.get('timeoutError'));
                }
            });
            this.activeReq = req;
        }
    };

    const LinkCleaner = {
        clean(root = document) {
            root.querySelectorAll('a[href]').forEach(a => {
                try {
                    const url = new URL(a.href);
                    // Google
                    if (url.hostname.includes('google.') && url.pathname === '/url') {
                        // searchParams.get()이 이미 디코딩함 — 이중 디코딩 금지
                        const q = url.searchParams.get('q') || url.searchParams.get('url');
                        // http(s)만 허용 — javascript: 등 위험 스킴 주입 방지
                        if (q && /^https?:\/\//i.test(q)) a.href = q;
                    }
                    // Bing
                    else if (url.hostname.includes('bing.com') && url.pathname.includes('/ck')) {
                        const u = url.searchParams.get('u');
                        if (u) {
                            try {
                                // Bing uses 'a1' prefix followed by base64 encoded URL
                                const target = u.replace(/^a1/, '');
                                const b64 = target.replace(/-/g, '+').replace(/_/g, '/');
                                // Add padding if needed
                                const pad = b64.length % 4;
                                const padded = pad ? b64 + '='.repeat(4 - pad) : b64;

                                const decoded = decodeURIComponent(atob(padded));
                                if (decoded.startsWith('http://') || decoded.startsWith('https://')) {
                                    a.href = decoded;
                                }
                            } catch (e) {
                                // If base64 fails, fallback to standard link
                            }
                        }
                    }
                } catch (e) { }
            });
        }
    };

    /**
     * ==========================================================================================
     * 6. UI COMPONENTS
     * ==========================================================================================
     */
    const UI = {
        create(tag, { id, className, style, html, attributes, events } = {}) {
            const el = document.createElement(tag);
            if (id) el.id = id;
            if (className) el.className = className;
            if (style) Object.assign(el.style, style);
            if (html) el.innerHTML = html;
            if (attributes) Object.entries(attributes).forEach(([k, v]) => el.setAttribute(k, v));
            if (events) Object.entries(events).forEach(([k, v]) => el.addEventListener(k, v));
            return el;
        },

        Controls: {
            createToggle() {
                const enabled = State.isEnabled();
                const wrap = UI.create('div', { style: { display: 'flex', alignItems: 'center', cursor: 'pointer', gap: '8px' } });

                // Text Label
                const label = UI.create('span', {
                    html: enabled ? 'ON' : 'OFF',
                    style: { fontSize: '13px', fontWeight: 'bold', color: enabled ? 'var(--g-title)' : '#888' }
                });

                // Switch
                const switchEl = UI.create('div', { className: `g-toggle ${enabled ? 'on' : 'off'}` });
                switchEl.appendChild(UI.create('div', { className: 'g-knob' }));

                wrap.onclick = () => {
                    const next = !State.isEnabled();
                    State.setEnabled(next);

                    // Update UI
                    switchEl.className = `g-toggle ${next ? 'on' : 'off'}`;
                    label.textContent = next ? 'ON' : 'OFF';
                    label.style.color = next ? 'var(--g-title)' : '#888';

                    App.refresh();
                };

                wrap.append(label, switchEl);
                return wrap;
            }
        },

        // 마크다운 렌더 공통 경로: sanitize + 새 탭 링크 + 코드 복사 버튼
        renderMarkdown(container, md) {
            container.innerHTML = DOMPurify.sanitize(marked.parse(md));
            container.querySelectorAll('a').forEach(a => {
                a.target = '_blank';
                a.rel = 'noopener';
            });
            UI.addCodeCopyButtons(container);
        },

        // 소요 시간 표시 (fetch/캐시 경로 공용)
        setTimeInfo(sec) {
            const el = document.getElementById('gemini-time-info');
            if (el && sec != null) el.textContent = `(${sec}s)`;
        },

        // RAG 뱃지 노출/툴팁 갱신 (fetch/캐시 경로 공용)
        // visibility 사용 — display:none이면 flex space-between에서 🔑가 좌측으로 밀려버림
        updateRagBadge(count) {
            const badge = document.getElementById('gemini-rag-badge');
            if (!badge) return;
            badge.style.visibility = count > 0 ? 'visible' : 'hidden';
            if (count > 0) badge.title = Utils.I18n.get('ragTooltip', { count });
        },

        // 에러 메시지 + 🔑 키 변경 안내를 함께 표시 (msg는 API 오류 문구를 포함할 수 있어 textContent로 삽입)
        showError(container, msg) {
            container.textContent = msg;
            const hint = UI.create('div', { id: 'gemini-api-hint', html: Utils.I18n.get('apiKeyHint') });
            container.appendChild(hint);
        },

        // Google↔Bing 검색 전환 버튼 (데스크톱 상단바 + 모바일 공용)
        createSearchSwitchBtn(query, style = {}) {
            const isGoogle = Utils.Env.isGoogle();
            return UI.create('div', {
                id: isGoogle ? 'bing-search-btn' : 'google-search-btn',
                className: 'gemini-btn',
                html: `<img src="${isGoogle ? Config.ASSETS.BING_LOGO : Config.ASSETS.GOOGLE_LOGO}"> <span>${Utils.I18n.get(isGoogle ? 'searchBing' : 'searchGoogle')}</span>`,
                events: { click: () => window.open(`https://${isGoogle ? 'bing' : 'google'}.com/search?q=${encodeURIComponent(query)}`) },
                style
            });
        },

        createTopRow(query) {
            const row = UI.create('div', { id: 'gemini-top-row' });

            // 1. Search Switch Button
            const btn = UI.createSearchSwitchBtn(query, { flex: '6' });

            // 2. Model Selector (모델별 캐시가 있으므로 강제 새로고침 불필요)
            const select = UI.create('select', {
                id: 'gemini-model-select-top',
                className: 'gemini-select',
                events: { change: (e) => { State.setModel(e.target.value); App.refresh(); } },
                style: { flex: '2' }
            });
            Config.MODELS.forEach(m => {
                const opt = document.createElement('option');
                opt.value = m.id; opt.textContent = m.name;
                opt.selected = m.id === State.getModel();
                select.appendChild(opt);
            });

            // 3. RAG Toggle Button (색상은 CSS #gemini-rag-toggle-btn / .rag-off에서 관리)
            const ragEnabled = State.isRagEnabled();
            const ragBtn = UI.create('div', {
                id: 'gemini-rag-toggle-btn',
                className: ragEnabled ? 'gemini-btn' : 'gemini-btn rag-off',
                html: '<span>RAG</span>',
                style: { flex: '2' },
                attributes: { title: Utils.I18n.get(ragEnabled ? 'ragOn' : 'ragOff') },
                events: {
                    click: () => {
                        const next = !State.isRagEnabled();
                        State.setRagEnabled(next);
                        ragBtn.classList.toggle('rag-off', !next);
                        ragBtn.setAttribute('title', Utils.I18n.get(next ? 'ragOn' : 'ragOff'));
                        App.refresh(true);
                    }
                }
            });

            row.appendChild(btn);
            row.appendChild(select);
            row.appendChild(ragBtn);
            return row;
        },

        createApiKeyInput() {
            const container = UI.create('div', { id: 'gemini-api-container' });
            container.innerHTML = `<p style="margin:0; font-size:13px; font-weight:bold;">${Utils.I18n.get('enterApiKey')}</p>`;

            const input = UI.create('input', { id: 'gemini-api-input', attributes: { type: 'password', placeholder: 'AIza...' } });
            const saveKey = () => {
                const val = input.value.trim();
                if (val) { State.setApiKey(val); App.refresh(true); }
            };

            const btn = UI.create('button', {
                id: 'gemini-api-save-btn', html: Utils.I18n.get('save'), events: {
                    click: saveKey
                }
            });

            input.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') {
                    saveKey();
                }
            });

            container.appendChild(input);
            container.appendChild(btn);
            return container;
        },

        createMainBox() {
            const box = UI.create('div', { id: 'gemini-box' });

            // Header
            const header = UI.create('div', { id: 'gemini-header' });

            // Title Container (Wrapper)
            const titleContainer = UI.create('div', { style: { display: 'flex', alignItems: 'center', gap: '0px' } });

            // Link (Clickable)
            const titleLink = UI.create('a', {
                id: 'gemini-title-link',
                attributes: { href: 'https://gemini.google.com/', target: '_blank' },
                style: { display: 'flex', alignItems: 'center', gap: '8px', textDecoration: 'none', color: 'inherit' }
            });
            // 언어와 무관하게 항상 영어로 고정 (i18n 불필요)
            titleLink.innerHTML = `<img id="gemini-logo" src="${Config.ASSETS.GEMINI_LOGO}"> <h3 style="margin:0; font-size:16px; font-weight:bold; color:var(--g-title);">Gemini Results</h3>`;

            // Time Info (Non-clickable)
            const timeSpan = UI.create('span', { id: 'gemini-time-info' });

            titleContainer.append(titleLink, timeSpan);

            const actions = UI.create('div', { id: 'gemini-actions' });
            const toggle = this.Controls.createToggle();
            const themeBtn = UI.create('img', {
                id: 'gemini-theme-toggle-btn', className: 'gemini-icon-btn',
                // src 갱신은 State.applyTheme()가 id로 처리하므로 여기선 토글만 호출
                attributes: { src: State.getThemeIcon(), title: Utils.I18n.get('toggleTheme') },
                events: { click: () => State.toggleTheme() }
            });
            const refreshBtn = UI.create('img', {
                id: 'gemini-refresh-btn', className: 'gemini-icon-btn',
                attributes: { src: Config.ASSETS.REFRESH_ICON, title: Utils.I18n.get('refresh') },
                events: { click: () => App.refresh(true) }
            });

            actions.append(toggle, themeBtn, refreshBtn);
            header.append(titleContainer, actions);

            // Content
            const content = UI.create('div', { id: 'gemini-content' });
            const divider = UI.create('hr', { id: 'gemini-divider', className: 'gemini-divider' });

            // 하단 바: 구분선 바로 아래 한 줄에 좌측 RAG 뱃지 + 우측 🔑 (언제든 API 키 변경)
            const footerDivider = UI.create('hr', { id: 'gemini-footer-divider', className: 'gemini-divider' });
            const ragBadge = UI.create('span', { id: 'gemini-rag-badge', html: 'RAG' });
            const keyBtn = UI.create('div', {
                id: 'gemini-key-btn',
                html: '🔑',
                attributes: { title: Utils.I18n.get('changeApiKey') },
                events: { click: () => App.showApiKeyInput() }
            });
            const footerBar = UI.create('div', { id: 'gemini-footer-bar' });
            footerBar.append(ragBadge, keyBtn);

            box.append(header, divider, content, footerDivider, footerBar);
            return { box, content, refreshBtn };
        },

        addCodeCopyButtons(container) {
            container.querySelectorAll('pre').forEach(pre => {
                if (pre.querySelector('.code-header')) return;

                // Extract language from class if marked added it, e.g. class="language-javascript"
                const codeEl = pre.querySelector('code');
                let lang = 'code';
                if (codeEl) {
                    const match = [...codeEl.classList].find(c => c.startsWith('language-'));
                    if (match) {
                        lang = match.replace('language-', '');
                    }
                }

                // 언어 라벨은 LLM 출력에서 온 클래스명이므로 textContent로 삽입 (innerHTML 주입 방지)
                const header = UI.create('div', { className: 'code-header' });
                const langSpan = UI.create('span', { className: 'code-lang' });
                langSpan.textContent = lang.toUpperCase();
                header.appendChild(langSpan);

                const copyLabel = Utils.I18n.get('copy');
                const btn = UI.create('button', {
                    className: 'code-copy-btn-new',
                    html: copyLabel,
                    events: {
                        click: () => {
                            const code = codeEl?.innerText || pre.innerText;
                            const flash = (label) => {
                                btn.textContent = label;
                                setTimeout(() => btn.textContent = copyLabel, 2000);
                            };
                            const fallbackCopy = (text) => {
                                const textArea = document.createElement("textarea");
                                textArea.value = text;
                                textArea.style.position = "fixed";
                                document.body.appendChild(textArea);
                                textArea.focus();
                                textArea.select();
                                try {
                                    document.execCommand('copy');
                                    flash(Utils.I18n.get('copied'));
                                } catch (err) {
                                    flash(Utils.I18n.get('copyFailed'));
                                }
                                document.body.removeChild(textArea);
                            };

                            if (navigator.clipboard && navigator.clipboard.writeText) {
                                navigator.clipboard.writeText(code)
                                    .then(() => flash(Utils.I18n.get('copied')))
                                    .catch(() => fallbackCopy(code));
                            } else {
                                fallbackCopy(code);
                            }
                        }
                    }
                });

                header.appendChild(btn);
                pre.prepend(header);
            });
        }
    };

    /**
     * ==========================================================================================
     * 7. MAIN APP CONTROLLER
     * ==========================================================================================
     */
    const App = {
        elements: null,

        init() {
            State.init();
            Styles.inject();
            try { marked.setOptions({ breaks: true, gfm: true }); } catch (e) { } // 마크다운 옵션 설정
            this.handleNavigation();
            LinkCleaner.clean();

            // Delayed init to wait for page load
            const onReady = () => {
                this.render(Utils.Env.isMobile() ? null : this.findContainer());
            };

            if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', onReady);
            else onReady();
        },

        // 사이드바 컨테이너를 찾고, 없으면 직접 생성 — 항상 우측 위치 보장
        findContainer() {
            if (Utils.Env.isGoogle()) {
                let rhs = document.getElementById('rhs');
                if (!rhs) {
                    const rcnt = document.getElementById('rcnt');
                    if (rcnt) {
                        rhs = UI.create('div', { id: 'rhs', style: { marginLeft: '16px', width: '432px' } });
                        rcnt.appendChild(rhs);
                    }
                }
                return rhs;
            }
            // Bing: .b_right 클래스 fallback 제거 — 엉뚱한 요소를 잡아 박스가 하단에 뜨던 원인 후보
            let ctx = document.getElementById('b_context');
            if (!ctx) {
                const content = document.getElementById('b_content');
                if (content) {
                    ctx = UI.create('div', { id: 'b_context', style: { float: 'right' } });
                    content.prepend(ctx);
                }
            }
            return ctx;
        },

        render(container) {
            const query = Utils.Env.getQuery();
            if (!query) return;

            // 1. Mobile/Tablet Mode (Search Button Only)
            if (Utils.Env.isMobile()) {
                document.getElementById('mobile-search-btn-wrapper')?.remove();
                const btn = UI.create('div', {
                    id: 'mobile-search-btn-wrapper',
                    style: { margin: '0', padding: '15px 16px', background: 'var(--g-bg)' }
                });

                // 모바일용 버튼 생성
                const isGoogle = Utils.Env.isGoogle();
                const searchBtn = UI.createSearchSwitchBtn(query, { width: '100%' }); // 모바일은 꽉 차게
                btn.appendChild(searchBtn);

                // Insert Position Logic
                if (isGoogle) {
                    const target = document.getElementById('main') || document.getElementById('rcnt');
                    if (target) target.parentNode.insertBefore(btn, target);
                } else {
                    const target = document.getElementById('b_content');
                    if (target) target.prepend(btn);
                }
                return; // Gemini 로직 중단
            }

            // 2. Desktop Mode (Existing Logic)
            if (!container) return;

            // Google Width Enforcement
            if (Utils.Env.isGoogle() && container.id === 'rhs') {
                container.style.width = '432px';
                container.style.minWidth = '432px';
            }

            // Remove existing
            document.getElementById('gemini-wrapper')?.remove();

            // Structure
            const wrapper = UI.create('div', { id: 'gemini-wrapper' });
            wrapper.appendChild(UI.createTopRow(query));

            const { box, content, refreshBtn } = UI.createMainBox();
            this.elements = { content, refreshBtn }; // Cache for updates
            wrapper.appendChild(box);

            container.prepend(wrapper);
            this.loadContent(query);
        },

        // 🔑 클릭 시: 키가 이미 있어도 언제든 다시 입력받음
        showApiKeyInput() {
            if (!this.elements) return;
            GeminiAPI.abort();
            this.elements.content.innerHTML = '';
            this.elements.content.appendChild(UI.createApiKeyInput());
        },

        loadContent(query, forceRefresh = false) {
            if (!this.elements) return;
            const { content, refreshBtn } = this.elements;
            // 진행 중이던 요청이 있으면 취소 — 캐시 히트/OFF/키 미설정 경로에서 늦은 응답이 화면을 덮지 않도록
            GeminiAPI.abort();
            const apiKey = State.getApiKey();

            if (!apiKey) {
                content.innerHTML = '';
                content.appendChild(UI.createApiKeyInput());
                refreshBtn.style.display = 'none';
                return;
            }
            refreshBtn.style.display = 'block';

            if (!State.isEnabled()) {
                content.textContent = Utils.I18n.get('geminiOff');
                return;
            }

            const cacheKey = GeminiAPI.cacheKey(query);
            const cached = !forceRefresh && sessionStorage.getItem(cacheKey);
            if (cached) {
                UI.setTimeInfo(sessionStorage.getItem(GeminiAPI.metaKey(cacheKey, 'time')));
                UI.updateRagBadge(parseInt(sessionStorage.getItem(GeminiAPI.metaKey(cacheKey, 'rag_count'))) || 0);
                UI.renderMarkdown(content, cached);
            } else {
                GeminiAPI.fetch(query, content, apiKey);
            }
        },

        refresh(force = false) {
            const query = Utils.Env.getQuery();
            if (!query) return;
            if (force) {
                // 현재 쿼리+모델의 캐시만 삭제 — 다른 쿼리/모델 캐시는 보존
                const key = GeminiAPI.cacheKey(query);
                [key, GeminiAPI.metaKey(key, 'time'), GeminiAPI.metaKey(key, 'rag_count')].forEach(k => sessionStorage.removeItem(k));
            }
            this.loadContent(query, force);
        },

        _navGen: 0,

        // 검색 결과 DOM이 새 쿼리로 갱신될 때까지 기다렸다가 렌더 — 고정 지연이 SERP 갱신보다 빨라
        // 이전 쿼리 결과를 RAG로 긁던 문제 방지. mutation이 300ms간 잠잠해지면(또는 최대 2.5s 후) 렌더.
        renderAfterNav() {
            // 안정적인 바깥 컨테이너를 관찰 — 결과 노드가 통째로 교체돼도 subtree mutation으로 잡힘
            const results = Utils.Env.isGoogle()
                ? (document.getElementById('rcnt') || document.getElementById('search'))
                : (document.getElementById('b_content') || document.getElementById('b_results'));

            // 세대 토큰: 이 호출보다 나중의 네비게이션이 시작되면 이전 옵저버는 무효화
            const gen = ++this._navGen;
            let settleTimer = null;
            const done = () => {
                if (gen !== this._navGen) { obs.disconnect(); return; } // 더 최근 네비게이션이 대기 중 → 폐기
                this._navGen++; // 이 세대 종료 처리 (중복 done 방지)
                clearTimeout(settleTimer);
                clearTimeout(killTimer);
                obs.disconnect();
                this.render(Utils.Env.isMobile() ? null : this.findContainer());
                LinkCleaner.clean();
            };
            const obs = new MutationObserver(() => {
                clearTimeout(settleTimer);
                settleTimer = setTimeout(done, 300);
            });
            if (results) obs.observe(results, { childList: true, subtree: true });
            // mutation이 안 오거나 컨테이너가 없어도 최대 2.5s 뒤엔 렌더
            const killTimer = setTimeout(done, 2500);
        },

        handleNavigation() {
            // SPA Navigation Watcher
            let lastUrl = location.href;
            const check = () => {
                if (location.href !== lastUrl) {
                    lastUrl = location.href;
                    this.renderAfterNav();
                }
            };

            // Listen to standard popstate and hashchange
            window.addEventListener('popstate', check);
            window.addEventListener('hashchange', check);

            // Hook History API pushState/replaceState
            const originalPush = history.pushState;
            history.pushState = function (...args) {
                originalPush.apply(this, args);
                check();
            };
            const originalReplace = history.replaceState;
            history.replaceState = function (...args) {
                originalReplace.apply(this, args);
                check();
            };
        }
    };

    // Run
    App.init();

})();