import { validateSttBaseUrl } from './piagentui-server-core.js' import type { StoredSttSettings, StoredTtsSettings } from './piagentui-settings-store.js' export type SettingsUpdateResult = { ok: true; next: T } | { ok: false; error: string } type Fields = Record /** Assigns a validated value to `next`; returns false when the value is invalid. */ type FieldRule = (value: unknown, next: Fields) => boolean const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'] as const export function isTtsResponseFormat(value: unknown): value is NonNullable { return typeof value === 'string' && (TTS_RESPONSE_FORMATS as readonly string[]).includes(value) } const booleanField = (key: string): FieldRule => (value, next) => { next[key] = value === true return true } const oneOfField = (key: string, allowed: readonly string[]): FieldRule => (value, next) => { if (typeof value !== 'string' || !allowed.includes(value)) return false next[key] = value return true } const endpointField = (key: string): FieldRule => (value, next) => { if (typeof value !== 'string' || !value.startsWith('/')) return false next[key] = value return true } const trimmedField = (key: string): FieldRule => (value, next) => { if (typeof value !== 'string' || !value.trim()) return false next[key] = value.trim() return true } const baseUrlField: FieldRule = (value, next) => { if (typeof value !== 'string' || !validateSttBaseUrl(value)) return false next.baseUrl = value.replace(/\/+$/, '') return true } const languageField: FieldRule = (value, next) => { if (value === null || value === '') delete next.language else if (typeof value === 'string') next.language = value.trim() else return false return true } const speedField: FieldRule = (value, next) => { if (typeof value !== 'number' || value < 0.25 || value > 4) return false next.speed = value return true } const PROVIDER_KINDS = ['openai', 'openai-compatible'] as const const STT_RULES: Array<[string, FieldRule]> = [ ['enabled', booleanField('enabled')], ['providerKind', oneOfField('providerKind', PROVIDER_KINDS)], ['mode', oneOfField('mode', ['file', 'realtime'])], ['baseUrl', baseUrlField], ['apiKeyRequired', booleanField('apiKeyRequired')], ['transcriptionEndpoint', endpointField('transcriptionEndpoint')], ['transcriptionModel', trimmedField('transcriptionModel')], ['language', languageField], ['insertMode', oneOfField('insertMode', ['append', 'replace'])], ] const TTS_RULES: Array<[string, FieldRule]> = [ ['enabled', booleanField('enabled')], ['providerKind', oneOfField('providerKind', PROVIDER_KINDS)], ['baseUrl', baseUrlField], ['apiKeyRequired', booleanField('apiKeyRequired')], ['speechEndpoint', endpointField('speechEndpoint')], ['model', trimmedField('model')], ['voice', trimmedField('voice')], ['responseFormat', oneOfField('responseFormat', TTS_RESPONSE_FORMATS)], ['speed', speedField], ] function applyUpdate( existing: T, update: Record, rules: Array<[string, FieldRule]>, ): SettingsUpdateResult { const next: Fields = { ...(existing as Fields) } for (const [field, rule] of rules) { if (update[field] === undefined) continue if (!rule(update[field], next)) return { ok: false, error: `Invalid ${field}` } } if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) { if (update.apiKey === null) delete next.apiKey else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim() } return { ok: true, next: next as T } } /** Validates a PATCH-style STT update against the stored settings. Pure: does not persist anything. */ export function applySttSettingsUpdate( existing: StoredSttSettings, update: Record, ): SettingsUpdateResult { return applyUpdate(existing, update, STT_RULES) } /** Validates a PATCH-style TTS update against the stored settings. Pure: does not persist anything. */ export function applyTtsSettingsUpdate( existing: StoredTtsSettings, update: Record, ): SettingsUpdateResult { return applyUpdate(existing, update, TTS_RULES) }