New lib/ai/local-discovery.ts: one /api/tags query against the loopback
addresses Ollama binds to (127.0.0.1/localhost), no follow-up /api/show
round trips needed — the tags response already carries capabilities, size,
and parameter_size, enough to recommend a default model. Picks the
smallest non-"thinking" chat-capable model for the fastest first response
("Connect" pre-fills provider+baseUrl+model in one click), and separately
surfaces the largest as a "higher quality" alternative.
New banner in ai-assistant-settings.tsx: fires when Local isn't yet
configured, offers one-click Connect or a persisted "Not now" dismissal.
13 new unit tests using this machine's actual Ollama /api/tags response
(11 real installed models — qwen2.5:32b, llama3.2, deepseek-r1 x2,
gemma4 x3, hermes3, qwen3, qwen3.5, nomic-embed-text) as literal fixtures,
per the explicit instruction to use this machine as the test case:
confirms exactly one query is required, the heuristic recommends
llama3.2:latest (fastest) / qwen2.5:32b (largest) on this real fleet,
never recommends an embedding-only model, and degrades correctly when a
candidate base URL is unreachable.
Full QA gate: tsc clean, eslint clean, 2498/2498 tests passing, build clean.
Also live-verified in a real browser session against this machine's real
Ollama — the banner rendered with exactly these two model names.
543 lines
23 KiB
TypeScript
543 lines
23 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 { 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,
|
|
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());
|
|
|
|
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');
|
|
|
|
// ── 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);
|
|
}, []);
|
|
|
|
// ── 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 === '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',
|
|
localBaseUrl: settings.localBaseUrl,
|
|
localModel: settings.localModel,
|
|
serverModel: settings.serverModel,
|
|
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]);
|
|
|
|
const providerOptions = useMemo(
|
|
() => [
|
|
...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []),
|
|
...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []),
|
|
...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []),
|
|
],
|
|
[canUseLocal, canUseServer, 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>
|
|
);
|
|
}
|
|
|
|
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')}
|
|
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 === '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.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>
|
|
);
|
|
}
|