mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
170 lines
7.9 KiB
TypeScript
170 lines
7.9 KiB
TypeScript
import vine from "@vinejs/vine";
|
|
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
|
|
import type { KVStoreKey } from "../../types/kv_store.js";
|
|
import { CONTEXT_LADDER } from "../utils/context_window.js";
|
|
import { RESPONSE_STYLE_PRESETS } from "../../constants/ollama.js";
|
|
|
|
export const getSettingSchema = vine.compile(vine.object({
|
|
key: vine.enum(SETTINGS_KEYS),
|
|
}))
|
|
|
|
export const updateSettingSchema = vine.compile(vine.object({
|
|
key: vine.enum(SETTINGS_KEYS),
|
|
value: vine.any().optional(),
|
|
}))
|
|
|
|
const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/
|
|
|
|
/**
|
|
* Validate the *value* for keys that have format constraints beyond the generic
|
|
* enum/any check (the generic validator only constrains the key). Returns an
|
|
* error message string when invalid, or null when the value is acceptable.
|
|
*/
|
|
export function validateSettingValue(key: KVStoreKey, value: unknown): string | null {
|
|
switch (key) {
|
|
case 'autoUpdate.windowStart':
|
|
case 'autoUpdate.windowEnd':
|
|
case 'contentAutoUpdate.windowStart':
|
|
case 'contentAutoUpdate.windowEnd':
|
|
if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) {
|
|
return 'Time window values must be in 24-hour HH:MM format (e.g. "20:00").'
|
|
}
|
|
return null
|
|
case 'autoUpdate.cooloffHours':
|
|
case 'contentAutoUpdate.cooloffHours': {
|
|
const num = Number(value)
|
|
if (!Number.isInteger(num) || num < 0 || num > 8760) {
|
|
return 'Cool-off must be a whole number of hours between 0 and 8760.'
|
|
}
|
|
return null
|
|
}
|
|
case 'system.internetStatusTestUrl': {
|
|
// Empty clears the setting (reverts to env var / built-in defaults).
|
|
if (value === '' || value === undefined || value === null) {
|
|
return null
|
|
}
|
|
if (typeof value !== 'string') {
|
|
return 'Test URL must be a string.'
|
|
}
|
|
try {
|
|
const url = new URL(value)
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
return 'Test URL must use http or https.'
|
|
}
|
|
} catch {
|
|
return 'Test URL must be a valid URL (e.g. "https://example.com").'
|
|
}
|
|
return null
|
|
}
|
|
case 'ui.serviceLogsUrl': {
|
|
if (value === undefined || value === null) {
|
|
return null
|
|
}
|
|
if (typeof value !== 'string') {
|
|
return 'Service logs URL must be a string.'
|
|
}
|
|
// Empty clears the setting (reverts to the port-derived default). The Advanced
|
|
// page trims before submitting, so whitespace-only has to mean "clear" too.
|
|
const trimmed = value.trim()
|
|
if (trimmed === '') {
|
|
return null
|
|
}
|
|
// A bare host ("logs.lan", "10.0.0.5:9999") is accepted and normalized to
|
|
// http:// by normalizeCustomUrl before it becomes an href, so validate the
|
|
// same way rather than demanding a scheme. An explicitly declared
|
|
// non-http(s) scheme ("file://", "ftp://") is refused rather than silently
|
|
// reinterpreted as a hostname by that http:// prefix.
|
|
const isHttpUrl = /^https?:\/\//i.test(trimmed)
|
|
if (!isHttpUrl && /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
return 'Service logs URL must use http or https.'
|
|
}
|
|
const withScheme = isHttpUrl ? trimmed : `http://${trimmed}`
|
|
try {
|
|
const url = new URL(withScheme)
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
return 'Service logs URL must use http or https.'
|
|
}
|
|
} catch {
|
|
return 'Service logs URL must be a valid URL (e.g. "https://logs.example.com").'
|
|
}
|
|
return null
|
|
}
|
|
case 'contentAutoUpdate.maxBytesPerWindow': {
|
|
// Per-window download budget in bytes. 0 = unlimited.
|
|
const num = Number(value)
|
|
if (!Number.isInteger(num) || num < 0) {
|
|
return 'The per-window data cap must be a whole number of bytes (0 = unlimited).'
|
|
}
|
|
return null
|
|
}
|
|
case 'ai.contextWindow': {
|
|
// "auto" (or empty) hands sizing to ContextWindowService. An explicit
|
|
// value is a *cap*, so it only ever lowers the resolved window — but it
|
|
// still has to be a real ladder rung, since a value the backend can't
|
|
// honour would silently fall back to the default and confuse the user.
|
|
if (value === '' || value === 'auto' || value === undefined || value === null) {
|
|
return null
|
|
}
|
|
const num = Number(value)
|
|
if (!CONTEXT_LADDER.includes(num as (typeof CONTEXT_LADDER)[number])) {
|
|
return `Context window must be "auto" or one of: ${CONTEXT_LADDER.join(', ')}.`
|
|
}
|
|
return null
|
|
}
|
|
case 'rag.minRelevance': {
|
|
// Empty/'auto' clears the setting and reverts to RAG_MIN_FINAL_SCORE.
|
|
// An explicit 0 is a real choice — it turns the floor off — so it is
|
|
// deliberately not treated as "unset".
|
|
// Trimmed first so the accepted set matches parseMinRelevance's:
|
|
// Number(' ') is 0, which would otherwise validate as "floor off"
|
|
// for a value the parser reads as unset.
|
|
const raw = typeof value === 'string' ? value.trim() : value
|
|
if (raw === '' || raw === 'auto' || raw === undefined || raw === null) {
|
|
return null
|
|
}
|
|
const num = Number(raw)
|
|
if (!Number.isFinite(num) || num < 0 || num > 1) {
|
|
return 'Relevance threshold must be a number between 0 and 1, or empty to use the recommended default.'
|
|
}
|
|
return null
|
|
}
|
|
case 'ai.responseStyle': {
|
|
// Empty/unset means 'auto'. Anything else has to be a style we know,
|
|
// since an unrecognised value silently resolves back to the default
|
|
// and would leave the select showing a choice nothing honours.
|
|
if (value === '' || value === undefined || value === null) {
|
|
return null
|
|
}
|
|
const styles = RESPONSE_STYLE_PRESETS.map((preset) => preset.value)
|
|
if (typeof value !== 'string' || !styles.includes(value as (typeof styles)[number])) {
|
|
return `Response style must be one of: ${styles.join(', ')}.`
|
|
}
|
|
return null
|
|
}
|
|
case 'ai.keepAlive': {
|
|
// Ollama duration format: "10m", "1h", "30s", "-1" (forever), "0" (evict).
|
|
if (value === '' || value === undefined || value === null) {
|
|
return null
|
|
}
|
|
if (typeof value !== 'string' || !/^(-?\d+|\d+(\.\d+)?(ms|s|m|h))$/.test(value)) {
|
|
return 'Keep-alive must be a duration like "10m", "1h", or "-1" to keep the model loaded indefinitely.'
|
|
}
|
|
return null
|
|
}
|
|
case 'ai.amdHsaOverride': {
|
|
// Lands in the Ollama container env as HSA_OVERRIDE_GFX_VERSION, so only
|
|
// the forms DockerService._resolveAmdHsaOverride honours are accepted.
|
|
// Empty clears it and 'none' disables the gfx-marker default.
|
|
const raw = typeof value === 'string' ? value.trim().toLowerCase() : value
|
|
if (raw === '' || raw === undefined || raw === null || raw === 'none') {
|
|
return null
|
|
}
|
|
if (typeof raw !== 'string' || !/^\d{1,2}\.\d{1,2}\.\d{1,2}$/.test(raw)) {
|
|
return 'GFX override must be a version like "11.0.0", "none", or empty to use the detected default.'
|
|
}
|
|
return null
|
|
}
|
|
default:
|
|
return null
|
|
}
|
|
} |