Chub AI palm2 model list Enhancer

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Chub AI palm2 model list Enhancer
// @license      MIT
// @namespace    http://tampermonkey.net/
// @version      2.6
// @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 Settings</h4>
            <label>API Key:
                <input type="password" id="api-key-input" autocomplete="off" placeholder="Insert API key here" />
            </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(0deg);
            }
        `;
        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();

})();