'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { RefreshCw, CheckCircle, AlertTriangle, Loader2 } 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 } from '@/lib/ai/key-store'; import { loadAiSettings, saveAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings'; import { askMail, listLocalModels, 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]'; /** * Prototype scope (docs/AI-ASSISTANT-CONCEPT.md, decisions recorded * 2026-08-05 evening — see lib/ai/types.ts): `local` (loopback * Ollama-compatible runtime) ships free with no entitlement check; `public` * (BYOK, OpenAI-compatible) is available but explicitly unmonitored for now * — no seats, no metering, no server-recorded consent yet. `server` * (VNC-hosted) isn't wired up here; that infra is landing on the dev k8s * cluster separately. */ 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 canUsePublic = policy.entitlement.classes.includes('public'); // ── Local provider ── const [localModels, setLocalModels] = useState([]); const [refreshing, setRefreshing] = useState(false); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle'); const [testError, setTestError] = useState(null); const refreshModels = useCallback(async () => { setRefreshing(true); try { const models = await listLocalModels(settings.localBaseUrl); setLocalModels(models); if (!settings.localModel && models[0]) update('localModel', models[0]); } catch { setLocalModels([]); } finally { setRefreshing(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]); // ── Public provider ── const [hasSavedKey, setHasSavedKey] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(''); useEffect(() => { setHasSavedKey(!!getAiApiKey('public')); }, []); const saveKey = useCallback(() => { if (!apiKeyInput) return; setAiApiKey('public', apiKeyInput); setHasSavedKey(true); setApiKeyInput(''); }, [apiKeyInput]); // ── Ask ── const [question, setQuestion] = useState(''); const [asking, setAsking] = useState(false); const [askResult, setAskResult] = useState(null); const [askError, setAskError] = useState(null); const canAsk = question.trim().length > 0 && (settings.provider === 'local' ? canUseLocal && !!settings.localModel : settings.provider === 'public' ? canUsePublic && !!settings.publicModel && settings.publicConsentAccepted && hasSavedKey : false); const runAsk = useCallback(async () => { setAsking(true); setAskError(null); setAskResult(null); try { const result = await askMail(question.trim(), { provider: settings.provider as 'local' | 'public', localBaseUrl: settings.localBaseUrl, localModel: settings.localModel, publicBaseUrl: settings.publicBaseUrl, publicModel: settings.publicModel, publicApiKey: getAiApiKey('public'), }); setAskResult(result); } catch (err) { setAskError(err instanceof Error ? err.message : String(err)); } finally { setAsking(false); } }, [question, settings]); const providerOptions = useMemo( () => [ ...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []), ...(canUsePublic ? [{ value: 'public', label: 'Public (your API key)' }] : []), ], [canUseLocal, canUsePublic], ); if (policyLoading) { return (
Loading…
); } return (
{providerOptions.length > 0 ? ( update('provider', v as 'local' | 'public')} options={providerOptions} /> ) : ( No provider class available. )} {settings.provider === 'local' && canUseLocal && ( update('localBaseUrl', e.target.value)} spellCheck={false} className={inputClass} />
{localModels.length > 0 ? ( update('publicBaseUrl', e.target.value)} spellCheck={false} className={inputClass} /> update('publicModel', e.target.value)} placeholder="e.g. anthropic/claude-sonnet-4.5" spellCheck={false} className={inputClass} />
setApiKeyInput(e.target.value)} placeholder={hasSavedKey ? '•••• saved' : 'sk-...'} spellCheck={false} className={inputClass} />
update('publicConsentAccepted', v)} /> )} {settings.provider && (