Before this, the OpenCode class could only use providers already authenticated
via its own CLI (opencode auth login) — this app could pick a MODEL, never add
a PROVIDER. That is the one thing standing between "OpenCode integration" and
the actual ask: any LLM it supports, added from here.
New GET/PUT/DELETE /api/ai/opencode/providers, backed by GET /provider (every
provider OpenCode knows — 180 on a real run) and GET /provider/auth (which
auth method each accepts). New "Manage providers" panel in the OpenCode
settings section: search, add a key, remove one.
Scoped to API-key auth only, deliberately — recorded in lib/ai/opencode.ts's
module comment. `PUT /auth/{id}` with `{type:'api', key}` is one HTTP call
with a schema-verified shape. OAuth entries in /provider/auth need a browser
redirect + callback this app has no page for, and some carry interactive
prompts beyond a single form (GitHub Copilot's deployment-type picker) — real
scope for later, not something to half-build. OAuth-only providers are still
LISTED, just marked "Browser sign-in only" rather than hidden, so the picker
stays honest about what it can't do here.
A real finding from testing this against opencode's actual behaviour rather
than trusting a 200: NOT EVERY PROVIDER BECOMES CONNECTED FROM A BARE API KEY.
Snowflake Cortex needs SNOWFLAKE_ACCOUNT alongside its token; a single key
field silently leaves it stored-but-unconnected with no error from the PUT
itself. Worse, the provider's own `env` array length does not predict this —
Azure also needs two env vars and DOES connect from one key. There is no
reliable way to know in advance, so the route now VERIFIES by re-listing
providers after the write and reports plainly when a key was accepted but the
provider still isn't connected, rather than reporting the PUT's own success.
Verified live end-to-end, twice: once confirming a simple single-field
provider connects and can be removed cleanly, once confirming the honest
"stored but not connected" case is real and detected, not theoretical.
Cleaned up every throwaway credential from this machine's real opencode
config afterwards (checked auth.json directly, not just this app's view of it).
17 new/updated unit tests. Gate: tsc clean, eslint clean, 2527/2527 tests, build clean.
777 lines
35 KiB
TypeScript
777 lines
35 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, 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,
|
|
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) setPolicy(await res.json());
|
|
} 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 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 (
|
|
<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."
|
|
>
|
|
{settings.publicProfiles.length > 0 && (
|
|
<SettingItem label="Saved profiles">
|
|
<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 a provider">
|
|
<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 && (
|
|
<SettingItem label="Answer with">
|
|
<Select
|
|
value={settings.activeProfileId ?? ''}
|
|
onChange={(v) => update('activeProfileId', v)}
|
|
options={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 & 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> (“what did
|
|
Anna say about the invoice?”) work better than ones about recency
|
|
(“the last mail”).
|
|
</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>
|
|
);
|
|
}
|