Three things, all from running the real thing rather than trusting a status code.
1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
A locally-running `opencode serve` — the same runtime Paperclip drives as
an adapter. Its appeal over a BYOK profile is precisely what was broken
before: opencode owns provider auth itself, so there is NO api key for
this app to hold, and it reports a REAL model list (25 on this machine)
instead of asking the user to type an exact provider-specific model id
from memory. Typing "Sonnet 5" into a free-text box and getting a bare
"Provider returned 401" is the failure this removes.
IMPORTANT trap, documented in the module header and pinned by a test:
opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
both answer 200 — because a web-UI catch-all serves index.html for ANY
unknown path. I built the first version against that assumed compatibility
on the strength of two 200s and had to throw it away once I read a body.
Every probe now validates the parsed shape and content-type, never the
status alone. The real API is GET /api/model + POST /session +
POST /session/{id}/message, and the reply's `reasoning` parts are stripped
so a model's private chain of thought can never surface as the answer.
Proxied through our own backend (like the `server` class) because the
desktop renderer's origin is a random port that changes every launch;
same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
no keys, nothing leaves the device" is the whole point of this class.
2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
omitted `?slot=`, so search resolved to whichever account the multi-slot
resolver found first. Single-account installs never noticed; a real
multi-account/shared-mailbox setup reads an empty store every time. Both
call sites now pass the active slot.
3. "No local mail index available in this session" was shown even when the
index existed and simply matched nothing — actively misleading, and it
masked the missing-SESSION_SECRET bug for hours. AskResult now carries
retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
get different words: build the index, versus rephrase (with the honest
caveat that keyword search answers content questions better than recency
ones like "the last mail").
Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.
618 lines
27 KiB
TypeScript
618 lines
27 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,
|
|
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);
|
|
|
|
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>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|