diff options
Diffstat (limited to 'src/options/options.js')
| -rw-r--r-- | src/options/options.js | 250 |
1 files changed, 250 insertions, 0 deletions
diff --git a/src/options/options.js b/src/options/options.js new file mode 100644 index 0000000..606291b --- /dev/null +++ b/src/options/options.js @@ -0,0 +1,250 @@ +'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:<port> 形式で指定してください。'; + $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();
\ No newline at end of file |
