'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2 } from 'lucide-react'; import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/browser-navigation'; import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities'; import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store'; import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings'; import { askMail, listLocalModels, listServerModels, testLocalConnection, type AskResult, } from '@/lib/ai/local-client'; const inputClass = 'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 flex-1 min-w-[220px]'; /** * Decisions recorded 2026-08-05 (see lib/ai/types.ts, lib/ai/entitlement.ts): * `local` (loopback Ollama) ships free, no entitlement check. `server` * (centrally-hosted, proxied through this app's own backend) is real and * entitlement-enforced — every call re-checks a licensed seat server-side. * `public` (BYOK) supports several named provider profiles, picked case by * case per question, and is explicitly unmonitored for now. */ export function AiAssistantSettings() { const [policy, setPolicy] = useState(DEFAULT_AI_POLICY); const [policyLoading, setPolicyLoading] = useState(true); const [settings, setSettings] = useState(() => loadAiSettings()); useEffect(() => { let cancelled = false; (async () => { try { const res = await apiFetch('/api/ai/policy'); if (res.ok && !cancelled) setPolicy(await res.json()); } finally { if (!cancelled) setPolicyLoading(false); } })(); return () => { cancelled = true; }; }, []); const update = useCallback((key: K, value: AiLocalSettings[K]) => { setSettings((prev) => { const next = { ...prev, [key]: value }; saveAiSettings(next); return next; }); }, []); const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local'); const canUseServer = policy.entitlement.classes.includes('server'); const canUsePublic = policy.entitlement.classes.includes('public'); // ── Local provider ── const [localModels, setLocalModels] = useState([]); const [refreshingLocal, setRefreshingLocal] = useState(false); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle'); const [testError, setTestError] = useState(null); const refreshLocalModels = useCallback(async () => { setRefreshingLocal(true); try { const models = await listLocalModels(settings.localBaseUrl); setLocalModels(models); if (!settings.localModel && models[0]) update('localModel', models[0]); } catch { setLocalModels([]); } finally { setRefreshingLocal(false); } }, [settings.localBaseUrl, settings.localModel, update]); const runTestConnection = useCallback(async () => { setTestStatus('testing'); setTestError(null); const result = await testLocalConnection(settings.localBaseUrl); if (result.ok) { setTestStatus('ok'); } else { setTestStatus('error'); setTestError(result.error ?? 'Connection failed'); } }, [settings.localBaseUrl]); // ── Server provider ── const [serverModels, setServerModels] = useState([]); const [refreshingServer, setRefreshingServer] = useState(false); const [serverError, setServerError] = useState(null); const [seatNotice, setSeatNotice] = useState(null); const refreshServerModels = useCallback(async () => { setRefreshingServer(true); setServerError(null); try { const models = await listServerModels(); setServerModels(models); if (!settings.serverModel && models[0]) update('serverModel', models[0]); } catch (err) { setServerModels([]); setServerError(err instanceof Error ? err.message : String(err)); } finally { setRefreshingServer(false); } }, [settings.serverModel, update]); // ── Public provider — several named profiles, one picked per question ── const [newProfileName, setNewProfileName] = useState(''); const [newProfileBaseUrl, setNewProfileBaseUrl] = useState('https://openrouter.ai/api/v1'); const [newProfileModel, setNewProfileModel] = useState(''); const [newProfileKey, setNewProfileKey] = useState(''); const addProfile = useCallback(() => { if (!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey) return; const profile = createProfile(newProfileName, newProfileBaseUrl, newProfileModel); setAiApiKey(profile.id, newProfileKey); update('publicProfiles', [...settings.publicProfiles, profile]); if (!settings.activeProfileId) update('activeProfileId', profile.id); setNewProfileName(''); setNewProfileBaseUrl('https://openrouter.ai/api/v1'); setNewProfileModel(''); setNewProfileKey(''); }, [newProfileName, newProfileBaseUrl, newProfileModel, newProfileKey, settings.publicProfiles, settings.activeProfileId, update]); const removeProfile = useCallback( (id: string) => { clearAiApiKey(id); const remaining = settings.publicProfiles.filter((p) => p.id !== id); update('publicProfiles', remaining); if (settings.activeProfileId === id) update('activeProfileId', remaining[0]?.id ?? null); }, [settings.publicProfiles, settings.activeProfileId, update], ); // ── Ask ── const [question, setQuestion] = useState(''); const [asking, setAsking] = useState(false); const [askResult, setAskResult] = useState(null); const [askError, setAskError] = useState(null); const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; const canAsk = question.trim().length > 0 && (settings.provider === 'local' ? canUseLocal && !!settings.localModel : settings.provider === 'server' ? canUseServer && !!settings.serverModel : settings.provider === 'public' ? canUsePublic && !!activeProfile && settings.publicConsentAccepted : false); const runAsk = useCallback(async () => { setAsking(true); setAskError(null); setAskResult(null); setSeatNotice(null); try { const key = activeProfile ? getAiApiKey(activeProfile.id) : null; const result = await askMail(question.trim(), { provider: settings.provider as 'local' | 'server' | 'public', localBaseUrl: settings.localBaseUrl, localModel: settings.localModel, serverModel: settings.serverModel, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, }); setAskResult(result); if (result.seatJustAssigned) { setSeatNotice('A licensed seat on the server-hosted class was just assigned to your account.'); } } catch (err) { setAskError(err instanceof Error ? err.message : String(err)); } finally { setAsking(false); } }, [question, settings, activeProfile]); const providerOptions = useMemo( () => [ ...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []), ...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []), ...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []), ], [canUseLocal, canUseServer, canUsePublic], ); if (policyLoading) { return (
Loading…
); } return (
{providerOptions.length > 0 ? ( update('provider', v as 'local' | 'server' | 'public')} options={providerOptions} /> ) : ( No provider class available. )} {settings.provider === 'local' && canUseLocal && ( update('localBaseUrl', e.target.value)} spellCheck={false} className={inputClass} />
{localModels.length > 0 ? ( update('serverModel', v)} options={serverModels.map((m) => ({ value: m, label: m }))} /> ) : ( {settings.serverModel || 'None selected'} )}
{serverError && ( {serverError} )}
)} {settings.provider === 'public' && canUsePublic && ( {settings.publicProfiles.length > 0 && (
{settings.publicProfiles.map((p) => (

{p.name}

{p.model} · {p.baseUrl}

))}
)}
setNewProfileName(e.target.value)} placeholder="Name, e.g. Claude via OpenRouter" spellCheck={false} className={inputClass} /> setNewProfileModel(e.target.value)} placeholder="Model, e.g. anthropic/claude-sonnet-4.5" spellCheck={false} className={inputClass} />
setNewProfileBaseUrl(e.target.value)} placeholder="Base URL" spellCheck={false} className={inputClass} /> setNewProfileKey(e.target.value)} placeholder="sk-..." spellCheck={false} className={inputClass} />
update('publicConsentAccepted', v)} />
)} {settings.provider && (
{settings.provider === 'public' && settings.publicProfiles.length > 0 && (