Chub AI: Gemini config

Панель настройки Gemini: выбор модели из API, параметры

目前為 2025-06-20 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Chub AI: Gemini config
// @license      MIT
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Панель настройки Gemini: выбор модели из API, параметры
// @author       Ko16aska
// @match        *://chub.ai/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // --- Ключи localStorage ---
    const STORAGE_SETTINGS_KEY = 'chubGeminiSettings';
    const STORAGE_PANEL_STATE_KEY = 'chubGeminiPanelState';
    const STORAGE_API_KEY = 'chubGeminiApiKey';

    // --- По умолчанию ---
    const DEFAULT_MODEL = 'custom';
    const API_MODELS_URL_BASE = 'https://generativelanguage.googleapis.com/v1beta/models?key=';

    // --- Переменные состояния ---
    let allSettings = {}; // { modelName: {temperature, maxOutputTokens, topP, topK} }
    let panelState = {collapsed: true, currentModel: DEFAULT_MODEL};
    let modelList = []; // список строк моделей типа 'gemini-2.5-flash'
    let apiKey = '';

    // --- Создание панели ---
    function createPanel() {
        // Создаём корневой div
        const panel = document.createElement('div');
        panel.id = 'gemini-settings-panel';
        if(panelState.collapsed) panel.classList.add('collapsed');

        panel.innerHTML = `
            <div class="toggle-button" title="Показать/Скрыть панель">&#9654;</div>
            <h4>Gemini 2.5 Settings</h4>
            <label>API Key:
                <input type="password" id="api-key-input" autocomplete="off" placeholder="Введите API ключ" />
            </label>
            <button id="btn-get-models" style="margin-bottom:12px;">Get models list</button>
            <label>Model:
                <select id="model-select"></select>
                <input type="text" id="custom-model-input" placeholder="Введите свою модель" style="display:none; margin-top:4px; width:100%;" />
            </label>
            <div class="param-group">
                <label>Temperature: <input type="number" step="0.01" id="param-temperature" style="width:60px;" /></label>
                <input type="range" id="range-temperature" min="0" max="2" step="0.01" />
            </div>
            <div class="param-group">
                <label>MaxOutputTokens: <input type="number" step="1" id="param-maxTokens" style="width:60px;" /></label>
                <input type="range" id="range-maxTokens" min="1" max="65536" step="1" />
            </div>
            <div class="param-group">
                <label>topP: <input type="number" step="0.01" id="param-topP" style="width:60px;" /></label>
                <input type="range" id="range-topP" min="0" max="1" step="0.01" />
            </div>
            <div class="param-group">
                <label>topK: <input type="number" step="1" id="param-topK" style="width:60px;" /></label>
                <input type="range" id="range-topK" min="0" max="100" step="1" />
            </div>
            <button id="btn-save-settings">Save Settings</button>
            <div id="save-toast">Settings saved!</div>
        `;

        document.body.appendChild(panel);

        // --- Стили ---
        const style = document.createElement('style');
        style.textContent = `
            #gemini-settings-panel {
                position: fixed;
                top: 50%;
                right: 0;
                transform: translateY(-50%) translateX(100%);
                background: rgba(30,30,30,0.85);
                color: #eee;
                border-left: 1px solid #444;
                border-radius: 8px 0 0 8px;
                padding: 12px 16px;
                width: 300px;
                box-shadow: 0 4px 16px rgba(0,0,0,0.7);
                font-family: Arial, sans-serif;
                font-size: 14px;
                z-index: 10000;
                transition: transform 0.4s ease;
                user-select: none;
            }
            #gemini-settings-panel:not(.collapsed) {
                transform: translateY(-50%) translateX(0);
            }
            #gemini-settings-panel h4 {
                text-align: center;
                margin: 0 0 10px;
            }
            #gemini-settings-panel label {
                display: block;
                margin-bottom: 8px;
                font-weight: 600;
            }
            #gemini-settings-panel input[type="number"],
            #gemini-settings-panel input[type="text"],
            #gemini-settings-panel input[type="password"],
            #gemini-settings-panel select {
                margin-left: 8px;
                background: #222;
                border: 1px solid #555;
                border-radius: 4px;
                color: #eee;
                padding: 2px 6px;
                font-size: 13px;
            }
            #gemini-settings-panel input[type="password"] {
                letter-spacing: 0.3em;
            }
            .param-group {
                margin-bottom: 12px;
            }
            .param-group input[type="range"] {
                width: 100%;
                margin-top: 4px;
                cursor: pointer;
            }
            #btn-save-settings, #btn-get-models {
                width: 100%;
                padding: 7px;
                border: none;
                border-radius: 5px;
                background: #4caf50;
                color: #fff;
                font-weight: 600;
                cursor: pointer;
                user-select: none;
                margin-top: 6px;
                transition: background-color 0.3s ease;
            }
            #btn-save-settings:hover, #btn-get-models:hover {
                background: #388e3c;
            }
            #save-toast {
                margin-top: 10px;
                text-align: center;
                background: #222;
                color: #0f0;
                padding: 6px;
                border-radius: 5px;
                opacity: 0;
                transition: opacity 0.5s ease;
                pointer-events: none;
                user-select: none;
            }
            #save-toast.show {
                opacity: 1;
            }
            .toggle-button {
                position: absolute;
                left: -28px;
                top: 50%;
                transform: translateY(-50%);
                width: 28px;
                height: 48px;
                background: rgba(30,30,30,0.85);
                border: 1px solid #444;
                border-radius: 8px 0 0 8px;
                color: #eee;
                text-align: center;
                line-height: 48px;
                font-size: 20px;
                cursor: pointer;
                user-select: none;
                transition: transform 0.3s ease;
            }
            #gemini-settings-panel.collapsed .toggle-button {
                transform: translateY(-50%) rotate(0deg);
            }
            #gemini-settings-panel:not(.collapsed) .toggle-button {
                transform: translateY(-50%) rotate(180deg);
            }
        `;
        document.head.appendChild(style);

        // --- Получаем элементы ---
        const toggleBtn = panel.querySelector('.toggle-button');
        const apiKeyInput = panel.querySelector('#api-key-input');
        const btnGetModels = panel.querySelector('#btn-get-models');
        const modelSelect = panel.querySelector('#model-select');
        const customModelInput = panel.querySelector('#custom-model-input');
        const btnSaveSettings = panel.querySelector('#btn-save-settings');
        const saveToast = panel.querySelector('#save-toast');

        const elems = {
            temperature: { num: panel.querySelector('#param-temperature'), range: panel.querySelector('#range-temperature') },
            maxTokens: { num: panel.querySelector('#param-maxTokens'), range: panel.querySelector('#range-maxTokens') },
            topP: { num: panel.querySelector('#param-topP'), range: panel.querySelector('#range-topP') },
            topK: { num: panel.querySelector('#param-topK'), range: panel.querySelector('#range-topK') }
        };

        // --- Восстановление API ключа из localStorage с маскировкой ---
        function maskKeyDisplay(key) {
            if (!key) return '';
            if (key.length <= 4) return '*'.repeat(key.length);
            return key[0] + '*'.repeat(key.length - 2) + key[key.length - 1];
        }
         function loadApiKey() {
             const storedKey = localStorage.getItem(STORAGE_API_KEY) || '';
             realApiKey = storedKey;
             apiKey = storedKey;
             apiKeyInput.value = maskKeyDisplay(realApiKey);
         }

        function saveApiKey(newKey) {
            apiKey = newKey.trim();
            localStorage.setItem(STORAGE_API_KEY, apiKey);
        }

        // --- Обработка ввода ключа ---
        let realApiKey = apiKey; // чтобы хранить настоящий ключ, а в input маска
        apiKeyInput.addEventListener('focus', () => {
            // При фокусе показываем настоящий ключ
            apiKeyInput.type = 'text';
            apiKeyInput.value = realApiKey;
        });
        apiKeyInput.addEventListener('blur', () => {
            // При потере фокуса сохраняем ключ и маскируем
            saveApiKey(apiKeyInput.value);
            realApiKey = apiKeyInput.value.trim();
            apiKeyInput.type = 'password';
            apiKeyInput.value = maskKeyDisplay(realApiKey);
        });
        // Изначально загрузим
        loadApiKey();

        // --- Заполнение списка моделей ---
        function fillModelSelect() {
            // Сначала очистить
            modelSelect.innerHTML = '';
            // Всегда добавляем custom как первый вариант
            const optCustom = document.createElement('option');
            optCustom.value = 'custom';
            optCustom.textContent = 'Custom';
            modelSelect.appendChild(optCustom);

            // Добавляем модели из modelList
            for (const m of modelList) {
                const opt = document.createElement('option');
                opt.value = m;
                opt.textContent = m;
                modelSelect.appendChild(opt);
            }
        }

        // --- Показ/скрытие кастомного инпута ---
        function updateCustomModelInputVisibility() {
            if(modelSelect.value === 'custom') {
                customModelInput.style.display = 'block';
            } else {
                customModelInput.style.display = 'none';
            }
        }

        // --- Загрузка параметров выбранной модели ---
        function loadModelSettings(model) {
            if (!model) model = DEFAULT_MODEL;
            const settings = allSettings[model] || {
                temperature: 1.0,
                maxOutputTokens: 2048,
                topP: 0.95,
                topK: 0
            };
            elems.temperature.num.value = settings.temperature;
            elems.temperature.range.value = settings.temperature;
            elems.maxTokens.num.value = settings.maxOutputTokens;
            elems.maxTokens.range.value = settings.maxOutputTokens;
            elems.topP.num.value = settings.topP;
            elems.topP.range.value = settings.topP;
            elems.topK.num.value = settings.topK;
            elems.topK.range.value = settings.topK;

            if(model === 'custom') {
                // Если есть сохранённая модель у custom - подставим
                if(allSettings.customModelString) {
                    customModelInput.value = allSettings.customModelString;
                } else {
                    customModelInput.value = '';
                }
            }
        }

        // --- Сохранение параметров выбранной модели ---
        function saveModelSettings(model) {
            if (!model) model = DEFAULT_MODEL;
            if (!allSettings) allSettings = {};
            allSettings[model] = {
                temperature: clamp(parseFloat(elems.temperature.num.value), 0, 2),
                maxOutputTokens: clamp(parseInt(elems.maxTokens.num.value), 1, 65536),
                topP: clamp(parseFloat(elems.topP.num.value), 0, 1),
                topK: clamp(parseInt(elems.topK.num.value), 0, 1000)
            };
            if(model === 'custom') {
                allSettings.customModelString = customModelInput.value.trim();
            }
            saveAllSettings();
        }

        // --- Ограничение значений ---
        function clamp(val, min, max) {
            if (isNaN(val)) return min;
            return Math.min(max, Math.max(min, val));
        }

        // --- Синхронизация range и number input ---
        function linkInputs(numInput, rangeInput, min, max, step) {
            numInput.min = min;
            numInput.max = max;
            numInput.step = step;
            rangeInput.min = min;
            rangeInput.max = max;
            rangeInput.step = step;

            numInput.addEventListener('input', () => {
                let v = clamp(parseFloat(numInput.value), min, max);
                numInput.value = v;
                rangeInput.value = v;
            });
            rangeInput.addEventListener('input', () => {
                let v = clamp(parseFloat(rangeInput.value), min, max);
                rangeInput.value = v;
                numInput.value = v;
            });
        }

        // --- Сохраняем все настройки в localStorage ---
        function saveAllSettings() {
            try {
                localStorage.setItem(STORAGE_SETTINGS_KEY, JSON.stringify(allSettings));
                // Сохраняем выбранную модель и состояние панели
                localStorage.setItem(STORAGE_PANEL_STATE_KEY, JSON.stringify(panelState));
                showSaveToast();
            } catch(e) {
                console.error('Ошибка сохранения настроек:', e);
            }
        }

        // --- Загружаем все настройки из localStorage ---
        // --- Загрузка всех настроек ---
        function loadAllSettings() {
            try {
                const s = localStorage.getItem(STORAGE_SETTINGS_KEY);
                if (s) {
                    allSettings = JSON.parse(s);
                } else {
                    allSettings = {};
                }
            } catch(e) {
                console.error('Ошибка загрузки настроек:', e);
                allSettings = {};
            }

            // Восстановим modelList если был
            if(allSettings.modelList && Array.isArray(allSettings.modelList)) {
                modelList = allSettings.modelList;
            } else {
                modelList = [];
            }
        }


        // --- Загружаем состояние панели и модель ---
        function loadPanelState() {
            try {
                const s = localStorage.getItem(STORAGE_PANEL_STATE_KEY);
                if(s) {
                    const state = JSON.parse(s);
                    panelState = {...panelState, ...state};
                }
            } catch(e) {
                console.error('Ошибка загрузки состояния панели:', e);
            }
        }

        // --- Показ уведомления о сохранении ---
        let toastTimeout = null;
        function showSaveToast() {
            saveToast.classList.add('show');
            if(toastTimeout) clearTimeout(toastTimeout);
            toastTimeout = setTimeout(() => {
                saveToast.classList.remove('show');
            }, 1800);
        }

        // --- Получение моделей с API ---
        async function fetchModelsFromApi() {
            if(!realApiKey) {
                alert('Insert API key here');
                return;
            }
            btnGetModels.disabled = true;
            btnGetModels.textContent = 'Loading...';
            try {
                const response = await fetch(API_MODELS_URL_BASE + encodeURIComponent(realApiKey));
                if(!response.ok) throw new Error('Network response was not ok');
                const data = await response.json();
                /*
                Пример структуры data:
                {
                  "models": [
                    {"name": "models/gemini-2.0-flash", ...},
                    ...
                  ]
                }
                */
                // Парсим список моделей с префиксом "models/gemini-"
                if(data.models && Array.isArray(data.models)) {
                    modelList = data.models
                        .map(m => m.name)
                        .filter(name => name.startsWith('models/gemini-'))
                        .map(name => name.substring('models/'.length));
                } else {
                    modelList = [];
                }
                fillModelSelect();
                allSettings.modelList = modelList;
                saveAllSettings();
                // Если есть сохранённая модель, выбираем её, иначе default
                if(panelState.currentModel && modelList.includes(panelState.currentModel)) {
                    modelSelect.value = panelState.currentModel;
                } else {
                    modelSelect.value = DEFAULT_MODEL;
                    panelState.currentModel = DEFAULT_MODEL;
                }
                updateCustomModelInputVisibility();
                loadModelSettings(panelState.currentModel);
            } catch (e) {
                alert('Ошибка загрузки моделей: ' + e.message);
                console.error(e);
            } finally {
                btnGetModels.disabled = false;
                btnGetModels.textContent = 'Get models list';
            }
        }

        // --- Подменяем модель в URL ---
        function replaceModelInUrl(url, modelName) {
            if(typeof url !== 'string') return url;
            if(modelName === 'custom') {
                modelName = allSettings.customModelString || '';
                if(!modelName) return url; // нет кастомной модели
            }
            // модель всегда окружена "models/" слева и ":" справа
            // нужно заменить только часть между "models/" и ":"
            // например: https://.../models/gemini-2.5-flash-preview-04-17:generateText
            return url.replace(/(models\/)([^:]+)(:)/, (m, p1, p2, p3) => p1 + modelName + p3);
        }

        // --- Обработка fetch ---
        const originalFetch = window.fetch;
        window.fetch = async function(input, init) {
            let url = input;

            // Меняем модель в URL, если есть ключ API и url с generativelanguage
            if(typeof url === 'string' && url.includes('generativelanguage.googleapis.com')) {
                url = replaceModelInUrl(url, panelState.currentModel);
            }

            // Проверяем тело запроса с generationConfig
            if(init && init.body && typeof init.body === 'string' && init.body.includes('"generationConfig"')) {
                try {
                    const requestBody = JSON.parse(init.body);
                    if(requestBody.generationConfig) {
                        // Подгружаем настройки текущей модели (или дефолт)
                        const s = allSettings[panelState.currentModel] || {
                            temperature: 2,
                            maxOutputTokens: 65536,
                            topP: 0.95,
                            topK: 0
                        };

                        // Меняем параметры
                        requestBody.generationConfig.temperature = s.temperature;
                        requestBody.generationConfig.maxOutputTokens = s.maxOutputTokens;
                        requestBody.generationConfig.topP = s.topP;
                        requestBody.generationConfig.topK = s.topK;

                        // Обновляем тело запроса
                        init.body = JSON.stringify(requestBody);
                    }
                } catch(e) {
                    console.error('Ошибка обработки тела запроса:', e);
                }
            }

            return originalFetch(url, init);
        };

        // --- Обработчики ---

        // Переключение панели
        toggleBtn.onclick = () => {
            panelState.collapsed = !panelState.collapsed;
            if(panelState.collapsed) panel.classList.add('collapsed');
            else panel.classList.remove('collapsed');
            saveAllSettings();
        };

        // При смене модели
        modelSelect.onchange = () => {
            panelState.currentModel = modelSelect.value;
            updateCustomModelInputVisibility();
            loadModelSettings(panelState.currentModel);
            saveAllSettings();
        };

        // Кастомная модель input меняется - сохраняем
        customModelInput.oninput = () => {
            saveModelSettings('custom');
        };

        // Связываем range и number inputs
        linkInputs(elems.temperature.num, elems.temperature.range, 0, 2, 0.01);
        linkInputs(elems.maxTokens.num, elems.maxTokens.range, 1, 65536, 1);
        linkInputs(elems.topP.num, elems.topP.range, 0, 1, 0.01);
        linkInputs(elems.topK.num, elems.topK.range, 0, 1000, 1);

        // При изменении параметров вручную сохраняем
        for (const key in elems) {
            elems[key].num.oninput = () => saveModelSettings(panelState.currentModel);
            elems[key].range.oninput = () => saveModelSettings(panelState.currentModel);
        }

        // Кнопка получения списка моделей
        btnGetModels.onclick = fetchModelsFromApi;

        // Кнопка сохранения параметров
        btnSaveSettings.onclick = () => {
            saveModelSettings(panelState.currentModel);
            saveAllSettings();
        };

        // --- Инициализация при загрузке ---
        loadPanelState();
        loadAllSettings();
        loadApiKey();

        // Заполняем селект, если уже были модели из localStorage
        if(allSettings.modelList) {
            modelList = allSettings.modelList;
        }

        fillModelSelect();

        if(panelState.currentModel && modelList.includes(panelState.currentModel)) {
            modelSelect.value = panelState.currentModel;
        } else {
            modelSelect.value = DEFAULT_MODEL;
            panelState.currentModel = DEFAULT_MODEL;
        }
        updateCustomModelInputVisibility();
        loadModelSettings(panelState.currentModel);

        // Отобразить или скрыть панель
        if(panelState.collapsed) {
            panel.classList.add('collapsed');
        } else {
            panel.classList.remove('collapsed');
        }
    }

    createPanel();

})();