// Small, isolated persistence for AI Assistant settings — deliberately NOT // folded into stores/settings-store.ts tonight. That store's export/import // feature enumerates every field by hand; this is prototype-scope UI state // (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the // shared store belongs with whichever phase makes these settings real // product config rather than a local-AI test harness. export type AiProvider = 'local' | 'server' | 'public' | 'opencode'; /** * A named public-provider configuration (BYOK). Decision 2026-08-05: several * of these, not one — different models/providers for different questions, * picked case by case at Ask time (see `activeProfileId`). The API key * itself lives in `lib/ai/key-store.ts`, keyed by `id`, not here — so a * profile's metadata can be listed/edited without ever handling the secret. */ export interface AiProviderProfile { id: string; name: string; baseUrl: string; model: string; } export interface AiLocalSettings { provider: AiProvider | null; localBaseUrl: string; localModel: string | null; serverModel: string | null; opencodeModel: string | null; publicProfiles: AiProviderProfile[]; /** Which saved profile answers the next question. Not a permanent default — * the "Try it" UI lets this be changed per question. */ activeProfileId: string | null; publicConsentAccepted: boolean; } const STORAGE_KEY = 'vncmail:ai:settings'; export const DEFAULT_AI_SETTINGS: AiLocalSettings = { provider: null, localBaseUrl: 'http://127.0.0.1:11434', localModel: null, serverModel: null, opencodeModel: null, publicProfiles: [], activeProfileId: null, publicConsentAccepted: false, }; function newProfileId(): string { return `profile-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`; } /** * `activeProfileId` names either a personal BYOK profile (its own id, as * always) or an admin-managed preset (see PublicAiPreset), prefixed so the * two id spaces can never collide without adding a second field everywhere * that reads/writes activeProfileId. */ const PRESET_PREFIX = 'preset:'; export function presetActiveId(presetId: string): string { return PRESET_PREFIX + presetId; } export function isPresetActiveId(activeId: string | null): boolean { return !!activeId && activeId.startsWith(PRESET_PREFIX); } export function presetIdFromActiveId(activeId: string | null): string | null { return activeId && activeId.startsWith(PRESET_PREFIX) ? activeId.slice(PRESET_PREFIX.length) : null; } /** One-time upgrade from the earlier single-profile shape (a bare * publicBaseUrl/publicModel pair) into the profile list, so a browser that * already saved settings before profiles existed doesn't just lose them. */ function migrate(raw: Record): Partial { if (Array.isArray(raw.publicProfiles)) return raw as Partial; if (typeof raw.publicBaseUrl === 'string' && typeof raw.publicModel === 'string' && raw.publicModel) { const id = newProfileId(); return { ...raw, publicProfiles: [{ id, name: 'Default', baseUrl: raw.publicBaseUrl, model: raw.publicModel }], activeProfileId: id, }; } return raw as Partial; } export function loadAiSettings(): AiLocalSettings { if (typeof window === 'undefined') return { ...DEFAULT_AI_SETTINGS }; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULT_AI_SETTINGS }; return { ...DEFAULT_AI_SETTINGS, ...migrate(JSON.parse(raw)) }; } catch { return { ...DEFAULT_AI_SETTINGS }; } } export function saveAiSettings(settings: AiLocalSettings): void { if (typeof window === 'undefined') return; window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } export function createProfile(name: string, baseUrl: string, model: string): AiProviderProfile { return { id: newProfileId(), name, baseUrl, model }; }