From 55e581bab9b600e7c070ec596d3109fc897d2d86 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Tue, 1 Sep 2026 20:10:20 +0900 Subject: feat: Firefox selection translation extension with local Ollama (initial version) Implement the complete initial version of a Firefox WebExtension (Manifest V3) that translates selected text on web pages using a local Ollama LLM and shows the result in a small popover near the selection. Specification alignment (Phase 0) - Unified Specifications.md / Design.md / Security.md on the same approach: Ollama REST API + Fetch API + NDJSON streaming (no ollama-js SDK dependency) - Fixed host_permissions to http://localhost/* and http://127.0.0.1/* because Firefox match patterns do not support port numbers (bug 1362809/1468162); the actual connection target is restricted to localhost/127.0.0.1 + any port by application-side validation (defense in depth, OLL-09) - Clipboard: use navigator.clipboard.writeText() only inside the copy button click (user activation); no deprecated execCommand fallback Implementation (zero dependencies, vanilla JS, no npm/Node required) - manifest.json: MV3, background.scripts + type:module (event page, no service_worker), action popup, options_ui, content_scripts for http/https, permissions: storage + localhost host permissions only - src/shared: constants (ports, limits, presets, error codes), message validation, settings schema (defaults / merge / sanitize / buildOptions / toApiThinking), system prompt builder (fixed prompt first + user prompt) - src/background: message-router (runtime.Port streaming + runtime.onMessage for model list), translation-session (single active request, abort previous on new request, stale chunk suppression), ollama-client (fetch against /api/tags and /api/chat with buffering NDJSON line parser tolerant to chunk boundaries inside JSON objects, AbortController, host re-validation before every request, HTTP status to error-code mapping), settings-service (storage.local load/save with validation and fallback to defaults) - src/content/content.js: single classic script (Firefox does not support ES modules in content scripts) with selection detection (selectionchange + mouseup + keyup, debounced), Shadow DOM action button, Shadow DOM popover with HIDDEN/LOADING/STREAMING/COMPLETED/ERROR state machine, port client with requestId-based stale chunk filtering, enabled-state sync via storage.onChanged; all dynamic text rendered via textContent only - src/popup + src/options: full settings UI (enable toggle, Ollama host, model list from Ollama with saved-model warning, target language, thinking mode, temperature slider 0.0-2.0 step 0.1, context length presets 4k/8k/16k/32k/128k/256k, seed random/fixed with integer value, user system prompt, advanced numeric options with steppers, stop sequences textarea) - icons generated by tools/make-icons.ps1 (System.Drawing, no external tools) - build.ps1 packages manifest.json + icons/ + src/ into dist zip, excluding tests/, doc/, ollama-js/ and tools/ Tests (71 passed, 0 failed) - In-browser test runner (tests/test.html) with custom describe/it/assert framework; no npm, no Node.js - Unit: constants sync between content mirror and shared module, Ollama host validation (localhost/127.0.0.1 + any port only; rejects external hosts, LAN IPs, https, credentials, paths), settings schema defaults and fallback, temperature 0.1-step validation, context length presets, seed modes, advanced option types, buildOptions omission of unset values and absence of tfs_z/mirostat fields, system prompt composition order, message validation with selection length boundary values - Integration: NDJSON streaming across awkward byte boundaries, thinking chunk forwarding, malformed line tolerance, error payload mapping, think/ options inclusion rules, model list, TranslationSession abort semantics, settings round-trip with storage.onChanged cache refresh, full port flow (started/chunk/completed), newer request aborts older with stale chunk discard, malformed message rejection, OLLAMA_UNAVAILABLE and INVALID_SETTINGS error paths, untrusted sender rejection Security audit (Design.md section 24 checklist: all pass) - No innerHTML/outerHTML/document.write/eval/new Function/localStorage - No remote JS/CSS/fonts/CDN resources; no inline scripts in extension pages - Ollama access limited to background; content scripts never fetch Ollama - Background validates message type/length/requestId; single concurrent translation enforced - Clipboard API only (no execCommand fallback) - Minimal permissions: storage + localhost host permissions Docs - README.md with build/load/usage instructions, API list, permission rationale - doc/Testing-Results.md with automated test results, security audit results, and a 14-item manual Firefox verification checklist (to be executed on a real Firefox + Ollama environment) --- src/options/options.css | 143 +++++++++++++++++++++++++++ src/options/options.html | 104 ++++++++++++++++++++ src/options/options.js | 250 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 497 insertions(+) create mode 100644 src/options/options.css create mode 100644 src/options/options.html create mode 100644 src/options/options.js (limited to 'src/options') diff --git a/src/options/options.css b/src/options/options.css new file mode 100644 index 0000000..7de8418 --- /dev/null +++ b/src/options/options.css @@ -0,0 +1,143 @@ +body { + margin: 0; + padding: 24px; + font-family: system-ui, sans-serif; + font-size: 14px; + color: #1f1f1f; + background: #fafafa; +} + +main { + max-width: 640px; + margin: 0 auto; + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + padding: 20px 24px; +} + +h1 { + font-size: 18px; + margin: 0 0 16px; +} + +fieldset { + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 6px; + margin: 0 0 16px; + padding: 12px 16px; +} + +legend { + font-weight: 600; + padding: 0 6px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 14px; +} + +.field.inline { + flex-direction: row; + align-items: center; + gap: 8px; +} + +label, +.label-text { + font-weight: 500; +} + +.hint { + color: #5f6368; + font-size: 11px; + margin: 2px 0 0; +} + +.status { + color: #5f6368; + font-size: 12px; +} + +.status.error { + color: #c5221f; +} + +input[type='text'], +input[type='number'], +select, +textarea { + font-family: system-ui, sans-serif; + font-size: 13px; + padding: 4px 6px; + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; +} + +textarea { + resize: vertical; +} + +.inline { + display: flex; + gap: 6px; + align-items: center; +} + +.inline select { + flex: 1; + min-width: 0; +} + +.inline input[type='number'] { + width: 120px; +} + +.segmented { + display: flex; + gap: 12px; + margin: 4px 0; +} + +.segmented label { + font-weight: 400; + display: flex; + align-items: center; + gap: 4px; +} + +#seed-value-row { + margin-top: 4px; +} + +.actions { + display: flex; + align-items: center; + gap: 10px; +} + +#save { + padding: 6px 24px; + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; + background: #1a73e8; + color: #fff; + cursor: pointer; + font-size: 13px; +} + +#save:hover { + background: #1765cc; +} + +.dev-link { + margin-top: 16px; + font-size: 11px; +} + +.dev-link a { + color: #5f6368; +} \ No newline at end of file diff --git a/src/options/options.html b/src/options/options.html new file mode 100644 index 0000000..b60e66b --- /dev/null +++ b/src/options/options.html @@ -0,0 +1,104 @@ + + + + + Ollama Translations - 設定 + + + +
+

Ollama Translations 設定

+ +
+
+ 基本設定 + +
+ +
+ +
+ + +

初期版では http://localhost:<port> / http://127.0.0.1:<port> のみ接続できます。

+
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

4k / 8k / 16k / 32k / 128k / 256k

+
+ +
+ Seed +
+ + +
+ +
+ +
+ + +

アプリ固定プロンプトと結合して送信されます。

+
+
+ +
+ 詳細設定(未設定項目は送信されません) +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+ + + \ No newline at end of file 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: 形式で指定してください。'; + $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 -- cgit v1.2.3