/** * 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:` and `http://127.0.0.1:` * 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; // Only the root path ("http://host/" or "http://host") is allowed. 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; } /** * Normalize a validated host for storage: strip the trailing slash so * "http://localhost:11434/" and "http://localhost:11434" are stored * identically. * @param {string} host already validated host * @returns {string} */ export function normalizeOllamaHost(host) { return host.replace(/\/+$/, '') || 'http://localhost'; } /** * @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 = normalizeOllamaHost(stored.ollamaHost) || 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; } }