'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2, Sparkles, X } 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 { useAccountStore } from '@/stores/account-store'; 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 { discoverLocalOllama, recommendDefaultModel, largestModel, isLocalDiscoveryDismissed, dismissLocalDiscovery, type LocalDiscoveryResult, } from '@/lib/ai/local-discovery'; import { askMail, listLocalModels, listServerModels, listOpencodeModels, type OpencodeModelOption, 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()); // The index is written under the ACTIVE account's cookie slot, so retrieval // must read the same one — see fetchLocalLeg in lib/ai/local-client.ts. const activeSlot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot); 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'); const canUseOpencode = policy.entitlement.classes.includes('opencode'); // ── 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]); // ── Local discovery — proactively find an already-running Ollama and // offer a one-click connect, rather than making the user hunt down and // type a base URL + model name by hand. ── const [discovery, setDiscovery] = useState(null); const [discoveryDismissed, setDiscoveryDismissed] = useState(true); useEffect(() => { setDiscoveryDismissed(isLocalDiscoveryDismissed()); }, []); useEffect(() => { if (!canUseLocal || discoveryDismissed || settings.localModel) return; let cancelled = false; (async () => { const result = await discoverLocalOllama(); if (!cancelled) setDiscovery(result); })(); return () => { cancelled = true; }; }, [canUseLocal, discoveryDismissed, settings.localModel]); const connectDiscoveredLocal = useCallback(() => { if (!discovery) return; const recommended = recommendDefaultModel(discovery.models) ?? discovery.models[0]?.name ?? null; if (!recommended) return; setSettings((prev) => { const next: AiLocalSettings = { ...prev, provider: 'local', localBaseUrl: discovery.baseUrl, localModel: recommended }; saveAiSettings(next); return next; }); setLocalModels(discovery.models.filter((m) => m.capabilities.includes('completion')).map((m) => m.name)); setDiscovery(null); }, [discovery]); const dismissDiscoveryBanner = useCallback(() => { dismissLocalDiscovery(); setDiscoveryDismissed(true); setDiscovery(null); }, []); // ── OpenCode provider — a locally-running `opencode serve`. No key to // manage (opencode holds provider auth itself) and a real model list, which // is why this is its own class rather than another BYOK profile. ── const [opencodeModels, setOpencodeModels] = useState([]); const [refreshingOpencode, setRefreshingOpencode] = useState(false); const [opencodeError, setOpencodeError] = useState(null); const refreshOpencodeModels = useCallback(async () => { setRefreshingOpencode(true); setOpencodeError(null); try { const models = await listOpencodeModels(); setOpencodeModels(models); if (!settings.opencodeModel && models[0]) update('opencodeModel', models[0].ref); } catch (err) { setOpencodeModels([]); setOpencodeError(err instanceof Error ? err.message : String(err)); } finally { setRefreshingOpencode(false); } }, [settings.opencodeModel, update]); // ── 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 [profileError, setProfileError] = useState(null); const addProfile = useCallback(() => { if (!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey) return; setProfileError(null); // Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6.1) — advisory, // client-side only, checked here at save time. const allowlist = policy.publicProviderAllowlist; if (allowlist && !allowlist.some((prefix) => newProfileBaseUrl.startsWith(prefix))) { setProfileError(`This base URL isn't on the admin-approved list (${allowlist.join(', ')}).`); 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, policy.publicProviderAllowlist]); 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 === 'opencode' ? canUseOpencode && !!settings.opencodeModel : 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' | 'opencode', localBaseUrl: settings.localBaseUrl, localModel: settings.localModel, serverModel: settings.serverModel, opencodeModel: settings.opencodeModel, slot: activeSlot, 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, activeSlot]); const providerOptions = useMemo( () => [ ...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []), ...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []), ...(canUseOpencode ? [{ value: 'opencode', label: 'OpenCode (local agent)' }] : []), ...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []), ], [canUseLocal, canUseServer, canUsePublic, canUseOpencode], ); if (policyLoading) { return (
Loading…
); } const discoveryRecommended = discovery ? recommendDefaultModel(discovery.models) : null; const discoveryLargest = discovery ? largestModel(discovery.models) : null; return (
{discovery && discoveryRecommended && (

Local AI found on this machine

Ollama is running at {discovery.baseUrl} with {discovery.models.length} model{discovery.models.length === 1 ? '' : 's'} installed. Recommended for quick answers: {discoveryRecommended}. {discoveryLargest && discoveryLargest !== discoveryRecommended && ( <> Also available for higher-quality answers: {discoveryLargest}. )}

)} {providerOptions.length > 0 ? ( update('provider', v as 'local' | 'server' | 'public' | 'opencode')} options={providerOptions} /> ) : ( No provider class available. )} {settings.provider === 'local' && canUseLocal && ( update('localBaseUrl', e.target.value)} spellCheck={false} className={inputClass} />
{localModels.length > 0 ? ( update('opencodeModel', v)} options={opencodeModels.map((m) => ({ value: m.ref, label: m.label }))} /> ) : ( {settings.opencodeModel || 'None selected'} )}
{opencodeError && ( {opencodeError} )}
)} {settings.provider === 'server' && canUseServer && (
{serverModels.length > 0 ? ( 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} />
{profileError &&

{profileError}

}
update('publicConsentAccepted', v)} /> )} {settings.provider && (
{settings.provider === 'public' && settings.publicProfiles.length > 0 && (