summaryrefslogtreecommitdiff
path: root/src/shared/settings-schema.js
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-09-01 20:10:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-09-01 20:10:20 +0900
commit55e581bab9b600e7c070ec596d3109fc897d2d86 (patch)
tree15ca196a1858696c216946dc9608fae613ad6e5f /src/shared/settings-schema.js
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)
Diffstat (limited to 'src/shared/settings-schema.js')
-rw-r--r--src/shared/settings-schema.js311
1 files changed, 311 insertions, 0 deletions
diff --git a/src/shared/settings-schema.js b/src/shared/settings-schema.js
new file mode 100644
index 0000000..fad7a5f
--- /dev/null
+++ b/src/shared/settings-schema.js
@@ -0,0 +1,311 @@
+/**
+ * Settings schema: defaults, merging and validation.
+ *
+ * Validation is used in three places (Specifications.md §12, Security.md
+ * SEC-013): options UI (before save), background settings-service (after
+ * load), and the Ollama client (host re-check before each request).
+ */
+
+import {
+ DEFAULT_OLLAMA_HOST,
+ LANGUAGES,
+ THINKING_MODES,
+ CONTEXT_PRESETS,
+ MAX_USER_SYSTEM_PROMPT_LENGTH
+} from './constants.js';
+
+/**
+ * Host validator: only `http://localhost:<port>` and `http://127.0.0.1:<port>`
+ * are accepted (scheme http fixed). This is a mandatory restriction for the
+ * initial version (Security.md §4.4, OLL-09).
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isValidOllamaHost(value) {
+ if (typeof value !== 'string' || value.length === 0 || value.length > 200) {
+ return false;
+ }
+ let url;
+ try {
+ url = new URL(value);
+ } catch {
+ return false;
+ }
+ if (url.protocol !== 'http:') return false;
+ if (url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') return false;
+ if (url.pathname !== '/' && url.pathname !== '') return false;
+ if (url.search !== '' || url.hash !== '') return false;
+ if (url.username !== '' || url.password !== '') return false;
+ // Port must be valid (empty means default port 80).
+ if (url.port !== '') {
+ const port = Number(url.port);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
+ }
+ return true;
+}
+
+/**
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isValidLanguage(value) {
+ return LANGUAGES.some((lang) => lang.code === value);
+}
+
+/**
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isValidThinking(value) {
+ return THINKING_MODES.includes(value);
+}
+
+/**
+ * Temperature: 0.0 - 2.0 in 0.1 steps.
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isValidTemperature(value) {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return false;
+ if (value < 0 || value > 2) return false;
+ // 0.1 step check with rounding tolerance.
+ const rounded = Math.round(value * 10);
+ return Math.abs(rounded / 10 - value) < 1e-9;
+}
+
+/**
+ * Context length must be one of the fixed presets.
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isValidContextLength(value) {
+ return CONTEXT_PRESETS.includes(value);
+}
+
+/**
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isValidSeedMode(value) {
+ return value === 'random' || value === 'fixed';
+}
+
+const ADVANCED_INT_FIELDS = ['numKeep', 'numPredict', 'topK', 'repeatLastN'];
+const ADVANCED_FLOAT_FIELDS = ['topP', 'minP', 'typicalP', 'repeatPenalty', 'presencePenalty', 'frequencyPenalty'];
+
+/**
+ * Validate one advanced option value. null means "not set" and is allowed.
+ * Validation focuses on type and obviously invalid values, not narrow ranges
+ * (Design.md §12.2).
+ * @param {string} field
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isValidAdvancedValue(field, value) {
+ if (value === null || value === undefined) return true;
+ if (field === 'stop') return true; // handled separately (string[])
+ if (ADVANCED_INT_FIELDS.includes(field)) {
+ if (!Number.isInteger(value)) return false;
+ // numPredict and repeatLastN allow -1; all int fields must not be absurd.
+ return value > -1e9 && value < 1e9;
+ }
+ if (ADVANCED_FLOAT_FIELDS.includes(field)) {
+ return typeof value === 'number' && Number.isFinite(value);
+ }
+ return false;
+}
+
+/**
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isValidStopSequences(value) {
+ if (value === null || value === undefined) return true;
+ if (!Array.isArray(value)) return false;
+ if (value.length > 16) return false;
+ return value.every((s) => typeof s === 'string' && s.length > 0 && s.length <= 256);
+}
+
+/**
+ * Default settings object (Design.md §11).
+ * @returns {object} fresh deep copy of defaults
+ */
+export function defaultSettings() {
+ return {
+ enabled: true,
+ ollamaHost: DEFAULT_OLLAMA_HOST,
+ model: '',
+ targetLanguage: 'ja',
+ thinking: 'off',
+ temperature: 0.2,
+ contextLength: 16384,
+ seedMode: 'random',
+ seedValue: null,
+ userSystemPrompt: '',
+ advanced: {
+ numKeep: null,
+ numPredict: null,
+ topK: null,
+ topP: null,
+ minP: null,
+ typicalP: null,
+ repeatLastN: null,
+ repeatPenalty: null,
+ presencePenalty: null,
+ frequencyPenalty: null,
+ stop: []
+ }
+ };
+}
+
+function pickNumber(source, key, { min, max, integer = false, step = null } = {}) {
+ const value = source[key];
+ if (value === null || value === undefined) return null;
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
+ if (value < min || value > max) return null;
+ if (integer && !Number.isInteger(value)) return null;
+ if (step !== null) {
+ const scaled = Math.round(value / step);
+ if (Math.abs(scaled * step - value) > 1e-9) return null;
+ }
+ return value;
+}
+
+/**
+ * Merge stored settings over defaults and validate every field. Invalid
+ * values fall back to their defaults (Design.md §17.2).
+ * @param {*} stored raw value from storage (may be anything)
+ * @returns {object} validated settings
+ */
+export function sanitizeSettings(stored) {
+ const result = defaultSettings();
+ if (stored === null || typeof stored !== 'object' || Array.isArray(stored)) {
+ return result;
+ }
+
+ if (typeof stored.enabled === 'boolean') {
+ result.enabled = stored.enabled;
+ }
+
+ if (isValidOllamaHost(stored.ollamaHost)) {
+ // Normalize: strip trailing slash.
+ result.ollamaHost = stored.ollamaHost.replace(/\/+$/, '') || DEFAULT_OLLAMA_HOST;
+ }
+
+ if (typeof stored.model === 'string' && stored.model.length <= 256) {
+ result.model = stored.model;
+ }
+
+ if (isValidLanguage(stored.targetLanguage)) {
+ result.targetLanguage = stored.targetLanguage;
+ }
+
+ if (isValidThinking(stored.thinking)) {
+ result.thinking = stored.thinking;
+ }
+
+ const temperature = pickNumber(stored, 'temperature', { min: 0, max: 2, step: 0.1 });
+ if (temperature !== null) {
+ result.temperature = temperature;
+ }
+
+ if (isValidContextLength(stored.contextLength)) {
+ result.contextLength = stored.contextLength;
+ }
+
+ if (isValidSeedMode(stored.seedMode)) {
+ result.seedMode = stored.seedMode;
+ }
+
+ if (typeof stored.seedValue === 'number' && Number.isInteger(stored.seedValue)) {
+ result.seedValue = stored.seedValue;
+ }
+
+ if (typeof stored.userSystemPrompt === 'string' && stored.userSystemPrompt.length <= MAX_USER_SYSTEM_PROMPT_LENGTH) {
+ result.userSystemPrompt = stored.userSystemPrompt;
+ }
+
+ const storedAdvanced = stored.advanced;
+ if (storedAdvanced !== null && typeof storedAdvanced === 'object' && !Array.isArray(storedAdvanced)) {
+ for (const field of ADVANCED_INT_FIELDS) {
+ const value = storedAdvanced[field];
+ if (isValidAdvancedValue(field, value)) result.advanced[field] = value === undefined ? null : value;
+ }
+ for (const field of ADVANCED_FLOAT_FIELDS) {
+ const value = storedAdvanced[field];
+ if (isValidAdvancedValue(field, value)) result.advanced[field] = value === undefined ? null : value;
+ }
+ if (isValidStopSequences(storedAdvanced.stop)) {
+ result.advanced.stop = storedAdvanced.stop === null || storedAdvanced.stop === undefined ? [] : storedAdvanced.stop;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Build the Ollama request options object from validated settings.
+ * Unset values are omitted (OLL-07). tfs_z / mirostat* are out of scope.
+ * @param {object} settings validated settings
+ * @returns {object} options object (possibly empty)
+ */
+export function buildOptions(settings) {
+ const options = {};
+ const advanced = settings.advanced || {};
+ const a = settings.advanced;
+
+ options.temperature = settings.temperature;
+
+ if (Number.isInteger(settings.contextLength)) {
+ options.num_ctx = settings.contextLength;
+ }
+
+ if (settings.seedMode === 'fixed' && Number.isInteger(settings.seedValue)) {
+ options.seed = settings.seedValue;
+ }
+
+ const map = {
+ numKeep: 'num_keep',
+ numPredict: 'num_predict',
+ topK: 'top_k',
+ topP: 'top_p',
+ minP: 'min_p',
+ typicalP: 'typical_p',
+ repeatLastN: 'repeat_last_n',
+ repeatPenalty: 'repeat_penalty',
+ presencePenalty: 'presence_penalty',
+ frequencyPenalty: 'frequency_penalty'
+ };
+ for (const [key, apiName] of Object.entries(map)) {
+ const value = advanced[key];
+ if (value !== null && value !== undefined) {
+ options[apiName] = value;
+ }
+ }
+
+ if (Array.isArray(a.stop) && a.stop.length > 0) {
+ options.stop = a.stop.slice();
+ }
+
+ return options;
+}
+
+/**
+ * Map the UI thinking setting to the Ollama API `think` field.
+ * off -> omit, on -> true, low/medium/high -> string.
+ * @param {string} thinking
+ * @returns {boolean|string|undefined}
+ */
+export function toApiThinking(settings) {
+ if (!settings || typeof settings.thinking !== 'string') return undefined;
+ switch (settings.thinking) {
+ case 'on':
+ return true;
+ case 'low':
+ case 'medium':
+ case 'high':
+ return settings.thinking;
+ default:
+ return undefined;
+ }
+} \ No newline at end of file