Files
SRCmail/components/settings/ai-assistant-settings.tsx
T
Bernd Rodler 5d7ae230ce feat(ai): real local Ollama chat + BYOK public provider
Decisions 2026-08-05 evening (reprioritizing docs/AI-ASSISTANT-CONCEPT.md's
original P1/P2 server-first sequencing to local-first, since a real Ollama
instance already runs on this Mac with a full model set):

- `local` ships free, no entitlement check — always available wherever
  supportsLocalLlm() is true.
- `public` (BYOK) is available too, explicitly unmonitored for now — no
  seats/metering/consent backend. This reverses the concept doc's decision
  #1 (server-side-only key custody): the client holds its own key, matching
  vncmail-native's existing pattern.
- `server` (VNC-hosted) stays unwired client-side; that infra is "this
  MacBook tonight, the dev k8s cluster tomorrow."

New:
- lib/ai/local-client.ts: listLocalModels/testLocalConnection/chatLocal/
  chatPublic, ported near-verbatim from vncmail-native's proven
  src/api/ai.ts. Direct browser-side fetch, not proxied through this app's
  own server — a server-side proxy would reach the *server's* loopback, not
  the user's own laptop, which defeats the point of "local" once this app
  is hosted remotely.
- lib/ai/key-store.ts: client-held BYOK storage (localStorage — this repo's
  existing convention for client state, no OS keychain reachable from a
  browser tab).
- lib/ai/local-settings.ts: isolated persistence for provider/model/base-URL
  choices. Deliberately NOT folded into stores/settings-store.ts, which has
  a hand-maintained export/import enumeration this prototype-scope state
  doesn't belong in yet.
- Retrieval reuses this app's own already-built app/api/offline/search
  (encrypted SQLite/FTS5 mail index) as context when available, and
  degrades to unaugmented chat — not an error — when it 404s/503s (no index
  in this session, e.g. plain browser rather than Electron).

Rewrote components/settings/ai-assistant-settings.tsx: provider picker,
local runtime config (base URL, model list/refresh, test connection with a
CORS-aware diagnostic per the concept doc's own note on the browser row),
public BYOK config (base URL, model, key, client-side consent toggle), and
a working Ask box.

Verified: typecheck clean, lint clean, translations pass, production build
succeeds. Live-tested against the real Ollama on this machine (confirmed
running: qwen2.5:32b, gemma4, deepseek-r1, llama3.2, hermes3, qwen3) via a
local server + demo-mode session — admin flag round-trips correctly, the
pane renders both provider options, and the CORS-diagnostic path fires
correctly on a real (if here environment-sandboxed, not Ollama-side)
connection failure. Full success end-to-end still wants a real, unsandboxed
browser tab against this Mac's loopback to close out.
2026-08-05 22:41:22 +02:00

331 lines
13 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { RefreshCw, CheckCircle, AlertTriangle, Loader2 } from 'lucide-react';
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/browser-navigation';
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
import { getAiApiKey, setAiApiKey } from '@/lib/ai/key-store';
import { loadAiSettings, saveAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings';
import { askMail, listLocalModels, testLocalConnection, type AskResult } from '@/lib/ai/local-client';
const inputClass =
'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 flex-1 min-w-[220px]';
/**
* Prototype scope (docs/AI-ASSISTANT-CONCEPT.md, decisions recorded
* 2026-08-05 evening — see lib/ai/types.ts): `local` (loopback
* Ollama-compatible runtime) ships free with no entitlement check; `public`
* (BYOK, OpenAI-compatible) is available but explicitly unmonitored for now
* — no seats, no metering, no server-recorded consent yet. `server`
* (VNC-hosted) isn't wired up here; that infra is landing on the dev k8s
* cluster separately.
*/
export function AiAssistantSettings() {
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
const [policyLoading, setPolicyLoading] = useState(true);
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/api/ai/policy');
if (res.ok && !cancelled) setPolicy(await res.json());
} finally {
if (!cancelled) setPolicyLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const update = useCallback(<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 canUsePublic = policy.entitlement.classes.includes('public');
// ── Local provider ──
const [localModels, setLocalModels] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [testError, setTestError] = useState<string | null>(null);
const refreshModels = useCallback(async () => {
setRefreshing(true);
try {
const models = await listLocalModels(settings.localBaseUrl);
setLocalModels(models);
if (!settings.localModel && models[0]) update('localModel', models[0]);
} catch {
setLocalModels([]);
} finally {
setRefreshing(false);
}
}, [settings.localBaseUrl, settings.localModel, update]);
const runTestConnection = useCallback(async () => {
setTestStatus('testing');
setTestError(null);
const result = await testLocalConnection(settings.localBaseUrl);
if (result.ok) {
setTestStatus('ok');
} else {
setTestStatus('error');
setTestError(result.error ?? 'Connection failed');
}
}, [settings.localBaseUrl]);
// ── Public provider ──
const [hasSavedKey, setHasSavedKey] = useState(false);
const [apiKeyInput, setApiKeyInput] = useState('');
useEffect(() => {
setHasSavedKey(!!getAiApiKey('public'));
}, []);
const saveKey = useCallback(() => {
if (!apiKeyInput) return;
setAiApiKey('public', apiKeyInput);
setHasSavedKey(true);
setApiKeyInput('');
}, [apiKeyInput]);
// ── Ask ──
const [question, setQuestion] = useState('');
const [asking, setAsking] = useState(false);
const [askResult, setAskResult] = useState<AskResult | null>(null);
const [askError, setAskError] = useState<string | null>(null);
const canAsk =
question.trim().length > 0 &&
(settings.provider === 'local'
? canUseLocal && !!settings.localModel
: settings.provider === 'public'
? canUsePublic && !!settings.publicModel && settings.publicConsentAccepted && hasSavedKey
: false);
const runAsk = useCallback(async () => {
setAsking(true);
setAskError(null);
setAskResult(null);
try {
const result = await askMail(question.trim(), {
provider: settings.provider as 'local' | 'public',
localBaseUrl: settings.localBaseUrl,
localModel: settings.localModel,
publicBaseUrl: settings.publicBaseUrl,
publicModel: settings.publicModel,
publicApiKey: getAiApiKey('public'),
});
setAskResult(result);
} catch (err) {
setAskError(err instanceof Error ? err.message : String(err));
} finally {
setAsking(false);
}
}, [question, settings]);
const providerOptions = useMemo(
() => [
...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []),
...(canUsePublic ? [{ value: 'public', label: 'Public (your API key)' }] : []),
],
[canUseLocal, canUsePublic],
);
if (policyLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Loading
</div>
);
}
return (
<div className="space-y-6">
<SettingsSection
title="AI Assistant"
description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; public sends your question (and any retrieved excerpts) 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' | 'public')}
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={refreshModels} disabled={refreshing}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshing ? '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 === 'public' && canUsePublic && (
<SettingsSection
title="Public provider"
description="Any OpenAI-compatible endpoint. Defaults to OpenRouter. Your key is stored only in this browser, never sent anywhere but the provider below — and, for now, use of this class is not monitored or metered by VNC."
>
<SettingItem label="Base URL">
<input
type="text"
value={settings.publicBaseUrl}
onChange={(e) => update('publicBaseUrl', e.target.value)}
spellCheck={false}
className={inputClass}
/>
</SettingItem>
<SettingItem label="Model">
<input
type="text"
value={settings.publicModel}
onChange={(e) => update('publicModel', e.target.value)}
placeholder="e.g. anthropic/claude-sonnet-4.5"
spellCheck={false}
className={inputClass}
/>
</SettingItem>
<SettingItem
label="API key"
description={hasSavedKey ? 'A key is saved in this browser. Enter a new one to replace it.' : 'Stored in this browser only.'}
>
<div className="flex items-center gap-2 flex-wrap">
<input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder={hasSavedKey ? '•••• saved' : 'sk-...'}
spellCheck={false}
className={inputClass}
/>
<Button variant="outline" size="sm" onClick={saveKey} disabled={!apiKeyInput}>
Save
</Button>
</div>
</SettingItem>
<SettingItem
label="I understand this leaves the organisation"
description="Your question and any retrieved mail excerpts are sent to the provider above, 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">
<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>
{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.unaugmented && (
<p className="text-xs text-muted-foreground italic">
No local mail index available in this session answered without retrieval context.
</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>
);
}