summaryrefslogtreecommitdiff
path: root/src/shared/settings-schema.js
diff options
context:
space:
mode:
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