114 lines
3.9 KiB
JavaScript
114 lines
3.9 KiB
JavaScript
/**
|
|
* Runtime copilot settings.
|
|
*
|
|
* Precedence: saved settings file > environment > built-in defaults.
|
|
*
|
|
* Settings are persisted to a small JSON file rather than held only in memory, so
|
|
* a model chosen while preparing a demo survives a server restart. They are
|
|
* deliberately NOT written back into .env - that file holds the API key, and a
|
|
* process that rewrites its own secrets file is a bad habit to build.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
export const SETTINGS_FILE = path.join(HERE, '..', '..', '.copilot-settings.json');
|
|
|
|
/** Fields a client is allowed to change, with validation for each. */
|
|
const FIELDS = {
|
|
provider: {
|
|
validate: (v) => ['auto', 'openrouter', 'ollama', 'fallback'].includes(v),
|
|
error: 'provider must be auto, openrouter, ollama or fallback',
|
|
},
|
|
openRouterModel: {
|
|
validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 200,
|
|
error: 'openRouterModel must be a non-empty model id',
|
|
},
|
|
ollamaModel: {
|
|
validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 200,
|
|
error: 'ollamaModel must be a non-empty model id',
|
|
},
|
|
ollamaHost: {
|
|
validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 300,
|
|
error: 'ollamaHost must be a URL or host:port',
|
|
},
|
|
temperature: {
|
|
validate: (v) => Number.isFinite(v) && v >= 0 && v <= 2,
|
|
error: 'temperature must be between 0 and 2',
|
|
},
|
|
maxTokens: {
|
|
validate: (v) => Number.isInteger(v) && v >= 128 && v <= 8000,
|
|
error: 'maxTokens must be an integer between 128 and 8000',
|
|
},
|
|
reasoningEffort: {
|
|
validate: (v) => ['none', 'low', 'medium', 'high'].includes(v),
|
|
error: 'reasoningEffort must be none, low, medium or high',
|
|
},
|
|
};
|
|
|
|
export function defaultsFromEnv(env = process.env) {
|
|
return {
|
|
// 'auto' resolves by availability at detect() time.
|
|
provider: (env.COPILOT_PROVIDER || 'auto').trim().toLowerCase(),
|
|
openRouterModel: (env.OPENROUTER_MODEL || 'meta-llama/llama-3.3-70b-instruct').trim(),
|
|
ollamaModel: (env.OLLAMA_MODEL || 'qwen3.5-4b-32k:latest').trim(),
|
|
ollamaHost: (env.OLLAMA_HOST || 'http://127.0.0.1:11434').trim(),
|
|
temperature: 0.3,
|
|
maxTokens: 2000,
|
|
// Reasoning tokens count against the output budget on most providers, so
|
|
// 'low' keeps answers from being swallowed by a reasoning block and keeps
|
|
// time-to-first-token usable in a live demo.
|
|
reasoningEffort: 'low',
|
|
};
|
|
}
|
|
|
|
export function loadSettings(env = process.env) {
|
|
const base = defaultsFromEnv(env);
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
|
for (const key of Object.keys(FIELDS)) {
|
|
if (raw[key] !== undefined && FIELDS[key].validate(raw[key])) base[key] = raw[key];
|
|
}
|
|
} catch {
|
|
/* no saved settings yet, or unreadable - environment defaults stand */
|
|
}
|
|
return base;
|
|
}
|
|
|
|
/**
|
|
* Validate a patch. Returns { ok, errors, clean }.
|
|
*
|
|
* Unknown keys are rejected rather than ignored, so a typo in a field name fails
|
|
* loudly instead of silently doing nothing.
|
|
*/
|
|
export function validatePatch(patch) {
|
|
const errors = [];
|
|
const clean = {};
|
|
for (const [key, value] of Object.entries(patch || {})) {
|
|
const field = FIELDS[key];
|
|
if (!field) { errors.push(`unknown setting "${key}"`); continue; }
|
|
if (!field.validate(value)) { errors.push(field.error); continue; }
|
|
clean[key] = value;
|
|
}
|
|
return { ok: errors.length === 0, errors, clean };
|
|
}
|
|
|
|
export function saveSettings(settings) {
|
|
const out = {};
|
|
for (const key of Object.keys(FIELDS)) {
|
|
if (settings[key] !== undefined) out[key] = settings[key];
|
|
}
|
|
fs.writeFileSync(SETTINGS_FILE, `${JSON.stringify(out, null, 2)}\n`, 'utf8');
|
|
return out;
|
|
}
|
|
|
|
export function resetSettings() {
|
|
try {
|
|
fs.unlinkSync(SETTINGS_FILE);
|
|
} catch {
|
|
/* nothing saved - already at defaults */
|
|
}
|
|
}
|