'use strict'; import { LANGUAGES, ERROR_CODES, RUNTIME_MESSAGES } from '../shared/constants.js'; import { defaultSettings, sanitizeSettings, CONTEXT_PRESETS, isValidOllamaHost } from '../shared/settings-schema.js'; const $form = /** @type {HTMLFormElement} */ (document.getElementById('options-form')); const $enabled = /** @type {HTMLInputElement} */ (document.getElementById('enabled')); const $host = /** @type {HTMLInputElement} */ (document.getElementById('ollama-host')); const $model = /** @type {HTMLSelectElement} */ (document.getElementById('model')); const $reloadModels = /** @type {HTMLButtonElement} */ (document.getElementById('reload-models')); const $modelStatus = /** @type {HTMLParagraphElement} */ (document.getElementById('model-status')); const $language = /** @type {HTMLSelectElement} */ (document.getElementById('target-language')); const $thinking = /** @type {HTMLSelectElement} */ (document.getElementById('thinking')); const $temperature = /** @type {HTMLInputElement} */ (document.getElementById('temperature')); const $temperatureValue = /** @type {HTMLOutputElement} */ (document.getElementById('temperature-value')); const $contextLength = /** @type {HTMLInputElement} */ (document.getElementById('context-length')); const $contextLengthValue = /** @type {HTMLOutputElement} */ (document.getElementById('context-length-value')); const $seedRandom = /** @type {HTMLInputElement} */ (document.querySelector('input[name="seed-mode"][value="random"]')); const $seedFixed = /** @type {HTMLInputElement} */ (document.querySelector('input[name="seed-mode"][value="fixed"]')); const $seedValueRow = /** @type {HTMLDivElement} */ (document.getElementById('seed-value-row')); const $seedValue = /** @type {HTMLInputElement} */ (document.getElementById('seed-value')); const $userPrompt = /** @type {HTMLTextAreaElement} */ (document.getElementById('user-system-prompt')); const $advancedFields = /** @type {HTMLDivElement} */ (document.getElementById('advanced-fields')); const $stop = /** @type {HTMLTextAreaElement} */ (document.getElementById('stop')); const $saveStatus = /** @type {HTMLSpanElement} */ (document.getElementById('save-status')); /** Advanced numeric fields: id, label, step. */ const ADVANCED_FIELDS = [ { id: 'numKeep', label: 'num_keep', step: '1', placeholder: '4' }, { id: 'numPredict', label: 'num_predict', step: '1', placeholder: '-1' }, { id: 'topK', label: 'top_k', step: '1', placeholder: '40' }, { id: 'topP', label: 'top_p', step: '0.05', placeholder: '0.9' }, { id: 'minP', label: 'min_p', step: '0.01', placeholder: '0.05' }, { id: 'typicalP', label: 'typical_p', step: '0.05', placeholder: '1.0' }, { id: 'repeatLastN', label: 'repeat_last_n', step: '1', placeholder: '64' }, { id: 'repeatPenalty', label: 'repeat_penalty', step: '0.05', placeholder: '1.1' }, { id: 'presencePenalty', label: 'presence_penalty', step: '0.05', placeholder: '0.0' }, { id: 'frequencyPenalty', label: 'frequency_penalty', step: '0.05', placeholder: '0.0' } ]; const CONTEXT_LABELS = ['4k', '8k', '16k', '32k', '128k', '256k']; /** @type {string[]} */ let knownModels = []; // Build advanced inputs dynamically. const advancedInputs = new Map(); for (const field of ADVANCED_FIELDS) { const wrapper = document.createElement('div'); wrapper.className = 'field inline'; const label = document.createElement('label'); label.setAttribute('for', `adv-${field.id}`); label.textContent = field.label; const input = document.createElement('input'); input.type = 'number'; input.id = `adv-${field.id}`; input.step = field.step; input.placeholder = field.placeholder; wrapper.append(label, input); $advancedFields.append(wrapper); advancedInputs.set(field.id, input); } function contextIndex(value) { const index = CONTEXT_PRESETS.indexOf(value); return index === -1 ? 2 : index; } function showStatus(message, isError) { $modelStatus.hidden = false; $modelStatus.textContent = message; $modelStatus.classList.toggle('error', Boolean(isError)); } function fillModels(models, selected) { $model.textContent = ''; if (models.length === 0) { const option = document.createElement('option'); option.value = ''; option.textContent = '(モデルなし / Ollama未起動)'; $model.append(option); } for (const name of models) { const option = document.createElement('option'); option.value = name; option.textContent = name; if (name === selected) option.selected = true; $model.append(option); } if (selected && !models.includes(selected)) { const option = document.createElement('option'); option.value = selected; option.textContent = `${selected}(保存済み・一覧にありません)`; option.selected = true; $model.append(option); showStatus('保存済みモデルがOllama上に見つかりません。', true); } } async function loadModels(selected) { showStatus('モデル一覧を取得中…', false); try { const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.LIST_MODELS }); if (response && response.ok && Array.isArray(response.models)) { knownModels = response.models; if (response.models.length === 0) { showStatus('モデルが見つかりません。Ollamaでモデルをpullしてください。', true); } else { $modelStatus.hidden = true; } } else if (response && response.error === ERROR_CODES.OLLAMA_UNAVAILABLE) { knownModels = []; showStatus('Ollamaに接続できません。起動しているか確認してください。', true); } else { knownModels = []; showStatus('モデル一覧の取得に失敗しました。', true); } } catch { knownModels = []; showStatus('モデル一覧の取得に失敗しました。', true); } fillModels(knownModels, selected || ''); } function applySettings(settings) { $enabled.checked = Boolean(settings.enabled); $host.value = settings.ollamaHost; $language.value = settings.targetLanguage; $thinking.value = settings.thinking; $temperature.value = String(settings.temperature); $temperatureValue.textContent = settings.temperature.toFixed(1); $contextLength.value = String(contextIndex(settings.contextLength)); $contextLengthValue.textContent = CONTEXT_LABELS[contextIndex(settings.contextLength)]; if (settings.seedMode === 'fixed') { $seedFixed.checked = true; } else { $seedRandom.checked = true; } $seedValueRow.hidden = !$seedFixed.checked; $seedValue.value = settings.seedValue === null || settings.seedValue === undefined ? '' : String(settings.seedValue); $userPrompt.value = settings.userSystemPrompt; for (const [key, input] of advancedInputs.entries()) { const value = settings.advanced[key]; input.value = value === null || value === undefined ? '' : String(value); } $stop.value = (settings.advanced.stop || []).join('\n'); fillModels(knownModels, settings.model); } async function loadSettings() { const result = await browser.storage.local.get({ settings: null }); const settings = result.settings || defaultSettings(); applySettings(settings); await loadModels(settings.model); } function readForm() { const advanced = {}; for (const [key, input] of advancedInputs.entries()) { const raw = input.value.trim(); advanced[key] = raw === '' ? null : Number(raw); } const stopRaw = $stop.value; const stop = stopRaw .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0); return { enabled: $enabled.checked, ollamaHost: $host.value.trim(), model: $model.value, targetLanguage: $language.value, thinking: $thinking.value, temperature: Number($temperature.value), contextLength: CONTEXT_PRESETS[Number($contextLength.value)], seedMode: $seedFixed.checked ? 'fixed' : 'random', seedValue: $seedFixed.checked ? Math.trunc(Number($seedValue.value)) : null, userSystemPrompt: $userPrompt.value, advanced: { ...advanced, stop } }; } $temperature.addEventListener('input', () => { $temperatureValue.textContent = Number($temperature.value).toFixed(1); }); $contextLength.addEventListener('input', () => { $contextLengthValue.textContent = CONTEXT_LABELS[Number($contextLength.value)] || ''; }); for (const radio of [$seedRandom, $seedFixed]) { radio.addEventListener('change', () => { $seedValueRow.hidden = !$seedFixed.checked; }); } $reloadModels.addEventListener('click', () => { void loadModels($model.value); }); $form.addEventListener('submit', (event) => { event.preventDefault(); const host = $host.value.trim(); if (!isValidOllamaHost(host)) { $saveStatus.textContent = 'Ollama Hostが不正です。http://localhost: 形式で指定してください。'; $saveStatus.classList.add('error'); return; } const raw = readForm(); const validated = sanitizeSettings(raw); // Report fields that were sanitized (fell back to defaults). const warnings = []; if (validated.temperature !== raw.temperature) warnings.push('Temperature'); if (!CONTEXT_PRESETS.includes(raw.contextLength)) warnings.push('Context Length'); if (validated.seedMode !== raw.seedMode) warnings.push('Seed モード'); if (validated.seedMode === 'fixed' && validated.seedValue !== raw.seedValue) warnings.push('Seed 値'); for (const key of advancedInputs.keys()) { if (validated.advanced[key] !== raw.advanced[key] && raw.advanced[key] !== null) { warnings.push(key); } } void (async () => { await browser.storage.local.set({ settings: validated }); if (warnings.length > 0) { $saveStatus.textContent = `保存しました(修正された項目: ${warnings.join(', ')})`; } else { $saveStatus.textContent = '保存しました。'; } $saveStatus.classList.remove('error'); })(); }); for (const language of LANGUAGES) { const option = document.createElement('option'); option.value = language.code; option.textContent = language.label; $language.append(option); } void loadSettings();