Files
SRCmail/components/settings/ai-assistant-settings.tsx
T
Bernd RodlerandClaude Sonnet 5 f121678e2a feat(ai): Paperclip-style env-var provider presets + zero-config local default
Two product decisions from tonight:

1. Public AI providers can now be published by an admin as named presets
   (lib/ai/types.ts's PublicAiPreset: name/baseUrl/model/apiKeyEnvVar).
   The admin names an env var, never a secret value - the actual key is
   whatever ops has set in the server's real environment, same custody
   model as the existing AI_SERVER_BASE_URL var. A new server route
   (app/api/ai/public/chat) resolves it and makes the call itself, which
   also sidesteps the CORS/wrong-base-URL failure class chatPublic hit
   earlier tonight. Users pick a preset from a dropdown in Settings -
   Answer with - no key field at all; personal BYOK (paste your own key)
   stays available as a secondary "Add your own key" option, not removed.
   Admin UI: new "Public - org-managed presets" card in the AI policy tab.

2. AI now defaults ON instead of requiring setup (lib/ai/auto-provision.ts):
   on first load, if no provider is chosen yet, probe OpenCode (this app
   auto-spawns `opencode serve` itself, so it's the one local option with
   zero external install step) then Ollama via the existing auto-discovery,
   and adopt whichever answers. Never overrides an explicit choice - only
   fires while provider is still null. Wired into both AI entry points
   (the Ask button and the Settings pane) so it resolves before either
   renders its "not configured" state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:01:00 +02:00

820 lines
37 KiB
TypeScript

'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, presetActiveId, presetIdFromActiveId,
type AiLocalSettings,
} from '@/lib/ai/local-settings';
import { ensureDefaultProvider } from '@/lib/ai/auto-provision';
import {
discoverLocalOllama,
recommendDefaultModel,
largestModel,
isLocalDiscoveryDismissed,
dismissLocalDiscovery,
type LocalDiscoveryResult,
} from '@/lib/ai/local-discovery';
import {
askMail,
listLocalModels,
listServerModels,
listOpencodeModels,
listOpencodeProviders,
addOpencodeProvider,
removeOpencodeProvider,
type OpencodeProviderOption,
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<AiPolicy>(DEFAULT_AI_POLICY);
const [policyLoading, setPolicyLoading] = useState(true);
const [settings, setSettings] = useState<AiLocalSettings>(() => 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) {
const loadedPolicy = (await res.json()) as AiPolicy;
setPolicy(loadedPolicy);
// Zero-config default (lib/ai/auto-provision.ts) — a no-op once a
// provider is already chosen, so this is safe to run on every
// visit to this pane, not just first-run.
const next = await ensureDefaultProvider(loadedPolicy);
// Separately, once an admin has published at least one org-managed
// preset, make IT the default "Answer with" pick too — pasting a
// personal key should be the fallback a user reaches for, not the
// thing they have to do to get any answer at all.
if (!next.activeProfileId && loadedPolicy.publicPresets[0]) {
next.activeProfileId = presetActiveId(loadedPolicy.publicPresets[0].id);
saveAiSettings(next);
}
if (!cancelled) setSettings(next);
}
} finally {
if (!cancelled) setPolicyLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const update = useCallback(<K extends keyof AiLocalSettings>(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<string[]>([]);
const [refreshingLocal, setRefreshingLocal] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [testError, setTestError] = useState<string | null>(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<LocalDiscoveryResult | null>(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<OpencodeModelOption[]>([]);
const [refreshingOpencode, setRefreshingOpencode] = useState(false);
const [opencodeError, setOpencodeError] = useState<string | null>(null);
// ── OpenCode provider management — "add any LLM OpenCode supports" from
// inside this app, not only whatever its own CLI already authenticated. ──
const [opencodeProviders, setOpencodeProviders] = useState<OpencodeProviderOption[]>([]);
const [loadingProviders, setLoadingProviders] = useState(false);
const [providerSearch, setProviderSearch] = useState('');
const [addingProviderId, setAddingProviderId] = useState<string | null>(null);
const [newProviderKey, setNewProviderKey] = useState('');
const [providerBusyId, setProviderBusyId] = useState<string | null>(null);
const [providerActionError, setProviderActionError] = useState<string | null>(null);
const [showProviderManager, setShowProviderManager] = useState(false);
const refreshOpencodeProviders = useCallback(async () => {
setLoadingProviders(true);
setProviderActionError(null);
try {
setOpencodeProviders(await listOpencodeProviders());
} catch (err) {
setProviderActionError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingProviders(false);
}
}, []);
const handleAddProvider = useCallback(async (providerId: string) => {
if (!newProviderKey.trim()) return;
setProviderBusyId(providerId);
setProviderActionError(null);
try {
await addOpencodeProvider(providerId, newProviderKey.trim());
setAddingProviderId(null);
setNewProviderKey('');
await refreshOpencodeProviders();
} catch (err) {
setProviderActionError(err instanceof Error ? err.message : String(err));
} finally {
setProviderBusyId(null);
}
}, [newProviderKey, refreshOpencodeProviders]);
const handleRemoveProvider = useCallback(async (providerId: string) => {
setProviderBusyId(providerId);
setProviderActionError(null);
try {
await removeOpencodeProvider(providerId);
await refreshOpencodeProviders();
} catch (err) {
setProviderActionError(err instanceof Error ? err.message : String(err));
} finally {
setProviderBusyId(null);
}
}, [refreshOpencodeProviders]);
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<string[]>([]);
const [refreshingServer, setRefreshingServer] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [seatNotice, setSeatNotice] = useState<string | null>(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<string | null>(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<AskResult | null>(null);
const [askError, setAskError] = useState<string | null>(null);
const activePresetId = presetIdFromActiveId(settings.activeProfileId);
const activeProfile = activePresetId ? null : settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
const activePublicSelection = !!activeProfile || (!!activePresetId && policy.publicPresets.some((p) => p.id === activePresetId));
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 && activePublicSelection && 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,
publicPresetId: activePresetId,
});
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, activePresetId, 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 (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Loading
</div>
);
}
const discoveryRecommended = discovery ? recommendDefaultModel(discovery.models) : null;
const discoveryLargest = discovery ? largestModel(discovery.models) : null;
return (
<div className="space-y-6">
{discovery && discoveryRecommended && (
<div className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 p-4">
<Sparkles className="w-5 h-5 mt-0.5 text-primary shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<p className="text-sm font-medium text-foreground">Local AI found on this machine</p>
<p className="text-sm text-muted-foreground">
Ollama is running at {discovery.baseUrl} with {discovery.models.length} model{discovery.models.length === 1 ? '' : 's'} installed.
Recommended for quick answers: <span className="font-medium text-foreground">{discoveryRecommended}</span>.
{discoveryLargest && discoveryLargest !== discoveryRecommended && (
<> Also available for higher-quality answers: <span className="font-medium text-foreground">{discoveryLargest}</span>.</>
)}
</p>
<div className="flex gap-2">
<Button size="sm" onClick={connectDiscoveredLocal}>
<Sparkles className="w-3.5 h-3.5 me-1.5" /> Connect
</Button>
<Button size="sm" variant="outline" onClick={dismissDiscoveryBanner}>
<X className="w-3.5 h-3.5 me-1.5" /> Not now
</Button>
</div>
</div>
</div>
)}
<SettingsSection
title="AI Assistant"
description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; server is centrally hosted and licensed per seat; public sends your question to a provider you choose, using your own API key."
>
<SettingItem label="Provider">
{providerOptions.length > 0 ? (
<RadioGroup
value={settings.provider ?? ''}
onChange={(v) => update('provider', v as 'local' | 'server' | 'public' | 'opencode')}
options={providerOptions}
/>
) : (
<span className="text-sm text-muted-foreground">No provider class available.</span>
)}
</SettingItem>
</SettingsSection>
{settings.provider === 'local' && canUseLocal && (
<SettingsSection
title="Local runtime"
description={
localLlmNeedsCorsSetup()
? "Reaches Ollama on this machine directly from the browser. If the test below fails, Ollama's OLLAMA_ORIGINS setting likely doesn't allow this page's origin yet."
: 'Reaches Ollama on this machine directly — no extra setup needed in the desktop app.'
}
>
<SettingItem label="Base URL">
<input
type="text"
value={settings.localBaseUrl}
onChange={(e) => update('localBaseUrl', e.target.value)}
spellCheck={false}
className={inputClass}
/>
</SettingItem>
<SettingItem label="Model" description={localModels.length === 0 ? 'Refresh to list installed models.' : undefined}>
<div className="flex items-center gap-2 flex-wrap">
{localModels.length > 0 ? (
<Select
value={settings.localModel ?? ''}
onChange={(v) => update('localModel', v)}
options={localModels.map((m) => ({ value: m, label: m }))}
/>
) : (
<span className="text-sm text-muted-foreground">{settings.localModel || 'None selected'}</span>
)}
<Button variant="outline" size="sm" onClick={refreshLocalModels} disabled={refreshingLocal}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingLocal ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</SettingItem>
<SettingItem label="Connection">
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={runTestConnection} disabled={testStatus === 'testing'}>
{testStatus === 'testing' && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
Test connection
</Button>
{testStatus === 'ok' && (
<span className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-500">
<CheckCircle className="w-3.5 h-3.5" /> Reachable
</span>
)}
{testStatus === 'error' && (
<span className="flex items-center gap-1.5 text-sm text-destructive">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> {testError}
</span>
)}
</div>
</SettingItem>
</SettingsSection>
)}
{settings.provider === 'opencode' && canUseOpencode && (
<SettingsSection
title="OpenCode (local agent)"
description="Uses a locally-running OpenCode server on this machine. OpenCode holds its own provider credentials, so there is no API key to enter here — and it reports the exact models it can reach, so there is nothing to type by hand."
>
<SettingItem label="Model" description={opencodeModels.length === 0 ? 'Refresh to list the models OpenCode can reach.' : undefined}>
<div className="flex items-center gap-2 flex-wrap">
{opencodeModels.length > 0 ? (
<Select
value={settings.opencodeModel ?? ''}
onChange={(v) => update('opencodeModel', v)}
options={opencodeModels.map((m) => ({ value: m.ref, label: m.label }))}
/>
) : (
<span className="text-sm text-muted-foreground">{settings.opencodeModel || 'None selected'}</span>
)}
<Button variant="outline" size="sm" onClick={refreshOpencodeModels} disabled={refreshingOpencode}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingOpencode ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</SettingItem>
{opencodeError && (
<SettingItem label="Status">
<span className="flex items-start gap-1.5 text-sm text-destructive">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" /> {opencodeError}
</span>
</SettingItem>
)}
<SettingItem
label="Providers"
description="Add credentials for any provider OpenCode supports — a key entered here is stored by OpenCode itself, not by this app. Providers that only offer a browser sign-in (OAuth) aren't manageable here yet; use the opencode CLI for those."
>
<Button
variant="outline" size="sm"
onClick={() => {
const next = !showProviderManager;
setShowProviderManager(next);
if (next && opencodeProviders.length === 0) void refreshOpencodeProviders();
}}
>
{showProviderManager ? 'Hide' : 'Manage providers'}
</Button>
</SettingItem>
{showProviderManager && (
<div className="px-4 pb-4 space-y-3">
{providerActionError && (
<p className="flex items-start gap-1.5 text-sm text-destructive">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" /> {providerActionError}
</p>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={providerSearch}
onChange={(e) => setProviderSearch(e.target.value)}
placeholder="Search providers (e.g. anthropic, openai, groq)…"
spellCheck={false}
className={inputClass}
/>
<Button variant="outline" size="sm" onClick={refreshOpencodeProviders} disabled={loadingProviders}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${loadingProviders ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{opencodeProviders.length === 0 && !loadingProviders && (
<p className="text-xs text-muted-foreground">No providers loaded yet click Refresh.</p>
)}
<div className="max-h-72 overflow-y-auto space-y-1.5">
{opencodeProviders
.filter((p) => {
const q = providerSearch.trim().toLowerCase();
return !q || p.id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q);
})
// Connected first (already sorted server-side), then cap what
// renders — 180 providers in one scroll box is noise, not choice.
.slice(0, providerSearch.trim() ? 40 : 20)
.map((p) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
<div className="flex-1 min-w-0">
<span className="text-sm">{p.name}</span>
<span className="ms-1.5 text-xs text-muted-foreground">{p.id}</span>
</div>
{p.connected ? (
<>
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-500">
<CheckCircle className="w-3.5 h-3.5" /> Connected
</span>
<Button
variant="outline" size="sm"
onClick={() => handleRemoveProvider(p.id)}
disabled={providerBusyId === p.id}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</>
) : p.supportsApiKey ? (
addingProviderId === p.id ? (
<div className="flex items-center gap-1.5">
<input
type="password"
value={newProviderKey}
onChange={(e) => setNewProviderKey(e.target.value)}
placeholder="API key"
autoFocus
className="px-2 py-1 text-xs rounded-md bg-muted border border-border w-36"
/>
<Button size="sm" onClick={() => handleAddProvider(p.id)} disabled={providerBusyId === p.id || !newProviderKey.trim()}>
Save
</Button>
<Button variant="outline" size="sm" onClick={() => { setAddingProviderId(null); setNewProviderKey(''); }}>
<X className="w-3.5 h-3.5" />
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => { setAddingProviderId(p.id); setNewProviderKey(''); }}>
<Plus className="w-3.5 h-3.5 me-1" /> Add key
</Button>
)
) : (
<span className="text-xs text-muted-foreground">Browser sign-in only</span>
)}
</div>
))}
</div>
</div>
)}
</SettingsSection>
)}
{settings.provider === 'server' && canUseServer && (
<SettingsSection
title="Server (VNC-hosted)"
description="Centrally hosted — no setup needed on your side. Licensed per seat; using this for the first time consumes one automatically if seats remain."
>
<SettingItem label="Model" description={serverModels.length === 0 ? 'Refresh to list available models.' : undefined}>
<div className="flex items-center gap-2 flex-wrap">
{serverModels.length > 0 ? (
<Select
value={settings.serverModel ?? ''}
onChange={(v) => update('serverModel', v)}
options={serverModels.map((m) => ({ value: m, label: m }))}
/>
) : (
<span className="text-sm text-muted-foreground">{settings.serverModel || 'None selected'}</span>
)}
<Button variant="outline" size="sm" onClick={refreshServerModels} disabled={refreshingServer}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingServer ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</SettingItem>
{serverError && (
<SettingItem label="Status">
<span className="flex items-center gap-1.5 text-sm text-destructive">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> {serverError}
</span>
</SettingItem>
)}
</SettingsSection>
)}
{settings.provider === 'public' && canUsePublic && (
<SettingsSection
title="Public providers"
description="Save several — different models for different questions. Any OpenAI-compatible endpoint works. Keys are stored only in this browser and, for now, use of this class is not monitored or metered by VNC."
>
{policy.publicPresets.length > 0 && (
<SettingItem
label="Org-managed providers"
description="Set up by your admin. Pick one below in “Answer with” — no key to paste, it's resolved on the server."
>
<div className="flex flex-col gap-2 w-full">
{policy.publicPresets.map((p) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground truncate">{p.model} · managed by admin</p>
</div>
</div>
))}
</div>
</SettingItem>
)}
{settings.publicProfiles.length > 0 && (
<SettingItem label="Your own keys">
<div className="flex flex-col gap-2 w-full">
{settings.publicProfiles.map((p) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground truncate">{p.model} · {p.baseUrl}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => removeProfile(p.id)} aria-label={`Remove ${p.name}`}>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
</Button>
</div>
))}
</div>
</SettingItem>
)}
<SettingItem label="Add your own key" description="Prefer to bring your own instead of an org-managed provider above.">
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-2 flex-wrap">
<input
type="text"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Name, e.g. Claude via OpenRouter"
spellCheck={false}
className={inputClass}
/>
<input
type="text"
value={newProfileModel}
onChange={(e) => setNewProfileModel(e.target.value)}
placeholder="Model, e.g. anthropic/claude-sonnet-4.5"
spellCheck={false}
className={inputClass}
/>
</div>
<div className="flex gap-2 flex-wrap">
<input
type="text"
value={newProfileBaseUrl}
onChange={(e) => setNewProfileBaseUrl(e.target.value)}
placeholder="Base URL"
spellCheck={false}
className={inputClass}
/>
<input
type="password"
value={newProfileKey}
onChange={(e) => setNewProfileKey(e.target.value)}
placeholder="sk-..."
spellCheck={false}
className={inputClass}
/>
<Button
variant="outline"
size="sm"
onClick={addProfile}
disabled={!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey}
>
<Plus className="w-3.5 h-3.5 me-1.5" />
Add
</Button>
</div>
{profileError && <p className="text-xs text-destructive">{profileError}</p>}
</div>
</SettingItem>
<SettingItem
label="I understand this leaves the organisation"
description="Your question and any retrieved mail excerpts are sent to the provider you pick below, outside this organisation."
>
<ToggleSwitch
checked={settings.publicConsentAccepted}
onChange={(v) => update('publicConsentAccepted', v)}
/>
</SettingItem>
</SettingsSection>
)}
{settings.provider && (
<SettingsSection title="Try it" description="Ask a question against your synced mail.">
<div className="flex flex-col gap-3">
{settings.provider === 'public' && (settings.publicProfiles.length > 0 || policy.publicPresets.length > 0) && (
<SettingItem label="Answer with">
<Select
value={settings.activeProfileId ?? ''}
onChange={(v) => update('activeProfileId', v)}
options={[
...policy.publicPresets.map((p) => ({ value: presetActiveId(p.id), label: `${p.name} (org)` })),
...settings.publicProfiles.map((p) => ({ value: p.id, label: p.name })),
]}
/>
</SettingItem>
)}
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="What did legal say about the Meier contract deadline?"
rows={3}
className="px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 resize-y"
/>
<Button onClick={runAsk} disabled={!canAsk || asking} className="self-start">
{asking && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
Ask
</Button>
{seatNotice && (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 p-3">
<CheckCircle className="w-4 h-4 mt-0.5 text-green-600 dark:text-green-500 shrink-0" />
<p className="text-sm text-muted-foreground">{seatNotice}</p>
</div>
)}
{askError && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{askError}</p>
</div>
)}
{askResult && (
<div className="flex flex-col gap-2 rounded-lg border border-border p-4">
{askResult.retrievalState === 'no-index' && (
<p className="text-xs text-muted-foreground italic">
No local mail index available in this session answered without your mail. The index is
desktop-only; build it under Settings About &amp; Data.
</p>
)}
{askResult.retrievalState === 'no-match' && (
<p className="text-xs text-muted-foreground italic">
Your mail index is available, but nothing in it matched this question answered without
your mail. It matches on keywords, so questions about <em>content</em> (&ldquo;what did
Anna say about the invoice?&rdquo;) work better than ones about recency
(&ldquo;the last mail&rdquo;).
</p>
)}
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
{askResult.sources.length > 0 && (
<div className="flex flex-col gap-0.5 border-t border-border pt-2 mt-1">
<span className="text-xs font-medium text-muted-foreground">Sources</span>
{askResult.sources.map((s, i) => (
<span key={s.id} className="text-xs text-muted-foreground truncate">
[{i + 1}] {s.subject}
</span>
))}
</div>
)}
</div>
)}
</div>
</SettingsSection>
)}
</div>
);
}