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.
This commit is contained in:
@@ -1,32 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Sparkles, Loader2 } from 'lucide-react';
|
import { RefreshCw, CheckCircle, AlertTriangle, Loader2 } from 'lucide-react';
|
||||||
import { SettingsSection, SettingItem } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||||
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
|
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]';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P0 scope only (docs/AI-ASSISTANT-CONCEPT.md §12): proves capability
|
* Prototype scope (docs/AI-ASSISTANT-CONCEPT.md, decisions recorded
|
||||||
* gating and the policy-fetch round trip. No provider is called from here —
|
* 2026-08-05 evening — see lib/ai/types.ts): `local` (loopback
|
||||||
* that's P1 (server class) onward. Once entitlement is real (P2), this pane
|
* Ollama-compatible runtime) ships free with no entitlement check; `public`
|
||||||
* grows the Model/Scope/Index sections from §4.
|
* (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() {
|
export function AiAssistantSettings() {
|
||||||
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
||||||
const [loading, setLoading] = useState(true);
|
const [policyLoading, setPolicyLoading] = useState(true);
|
||||||
|
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/ai/policy');
|
const res = await apiFetch('/api/ai/policy');
|
||||||
if (res.ok && !cancelled) {
|
if (res.ok && !cancelled) setPolicy(await res.json());
|
||||||
setPolicy(await res.json());
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setPolicyLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -34,43 +43,288 @@ export function AiAssistantSettings() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="AI Assistant"
|
title="AI Assistant"
|
||||||
description="Ask questions about your mail, answered by a model you or your admin choose. In preview — see below."
|
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="Status">
|
<SettingItem label="Provider">
|
||||||
{loading ? (
|
{providerOptions.length > 0 ? (
|
||||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
<RadioGroup
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Checking availability…
|
value={settings.provider ?? ''}
|
||||||
</span>
|
onChange={(v) => update('provider', v as 'local' | 'public')}
|
||||||
) : policy.entitlement.licensed ? (
|
options={providerOptions}
|
||||||
<span className="text-sm text-muted-foreground">
|
/>
|
||||||
Licensed ({policy.entitlement.tier}) — no model provider is configured yet.
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground">Not yet licensed for this account.</span>
|
<span className="text-sm text-muted-foreground">No provider class available.</span>
|
||||||
)}
|
)}
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
{settings.provider === 'local' && canUseLocal && (
|
||||||
title="What's coming"
|
<SettingsSection
|
||||||
description="This tab exists ahead of the feature so the settings surface and platform gating are proven before any model is wired in."
|
title="Local runtime"
|
||||||
>
|
description={
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-border p-4">
|
localLlmNeedsCorsSetup()
|
||||||
<Sparkles className="w-4 h-4 mt-0.5 text-muted-foreground shrink-0" />
|
? "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."
|
||||||
<p className="text-sm text-muted-foreground">
|
: 'Reaches Ollama on this machine directly — no extra setup needed in the desktop app.'
|
||||||
Local, VNC-hosted, and bring-your-own-key providers are planned (see the AI Assistant
|
}
|
||||||
concept doc). {supportsLocalLlm()
|
>
|
||||||
? localLlmNeedsCorsSetup()
|
<SettingItem label="Base URL">
|
||||||
? 'A local runtime will need its CORS setting adjusted to allow this browser origin.'
|
<input
|
||||||
: 'This desktop app can reach a local runtime with no extra setup.'
|
type="text"
|
||||||
: null}
|
value={settings.localBaseUrl}
|
||||||
</p>
|
onChange={(e) => update('localBaseUrl', e.target.value)}
|
||||||
</div>
|
spellCheck={false}
|
||||||
</SettingsSection>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Client-held storage for the user's own public-provider API key (BYOK).
|
||||||
|
//
|
||||||
|
// Decision 2026-08-05 (reverses docs/AI-ASSISTANT-CONCEPT.md decision #1's
|
||||||
|
// server-side-custody design): the user brings and holds their own key,
|
||||||
|
// client-side, not VNC. This is the same custody model as
|
||||||
|
// vncmail-native's lib/ai-key-store.ts (expo-secure-store there; this repo
|
||||||
|
// has no OS keychain access from a browser tab, so localStorage is the
|
||||||
|
// honest equivalent here — plain, not hidden behind a false sense of
|
||||||
|
// "secure storage"). A fuller Paperclip-style key-management UI (multiple
|
||||||
|
// providers, masking, rotation) is good follow-up work, not built tonight.
|
||||||
|
const KEY_PREFIX = 'vncmail:ai:key:';
|
||||||
|
|
||||||
|
export function getAiApiKey(provider: 'public'): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return window.localStorage.getItem(KEY_PREFIX + provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAiApiKey(provider: 'public', key: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(KEY_PREFIX + provider, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAiApiKey(provider: 'public'): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.removeItem(KEY_PREFIX + provider);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// The AI assistant's wire client — mirrors vncmail-native's src/api/ai.ts
|
||||||
|
// (same prototype scope: local Ollama + BYOK public, no VNC-hosted `server`
|
||||||
|
// class, no streaming) so the two clients stay in lockstep. Runs entirely
|
||||||
|
// client-side (`'use client'` callers only) — a direct loopback/provider
|
||||||
|
// fetch, matching docs/AI-ASSISTANT-CONCEPT.md §2's "local"/"public" rows,
|
||||||
|
// not proxied through this app's own Next.js server. That distinction
|
||||||
|
// matters once this app is hosted remotely: a server-side proxy would reach
|
||||||
|
// the *server's* loopback, not the user's own laptop running Ollama.
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local: Ollama's native API, not the OpenAI-compat shim — one fewer path
|
||||||
|
// assumption (no "/v1" prefix to guess at) for a runtime this code talks to directly. ──
|
||||||
|
|
||||||
|
interface OllamaTagsResponse {
|
||||||
|
models?: Array<{ name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OllamaChatResponse {
|
||||||
|
message?: { content?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalModels(baseUrl: string): Promise<string[]> {
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
|
||||||
|
if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
|
||||||
|
const body = (await res.json()) as OllamaTagsResponse;
|
||||||
|
return (body.models ?? []).map((m) => m.name).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnoses the specific failure rather than a generic "connection failed" —
|
||||||
|
* docs/AI-ASSISTANT-CONCEPT.md §3 calls this out explicitly for the browser
|
||||||
|
* row: a CORS rejection (the runtime is up but refused this page's origin)
|
||||||
|
* looks identical to "nothing is listening" unless told apart. `fetch`
|
||||||
|
* itself can't distinguish them (a CORS failure and a connection refusal
|
||||||
|
* both surface as `TypeError: Failed to fetch`), so this only upgrades the
|
||||||
|
* message when the caller can tell us there's a live page origin to name.
|
||||||
|
*/
|
||||||
|
export async function testLocalConnection(
|
||||||
|
baseUrl: string,
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await listLocalModels(baseUrl);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : null;
|
||||||
|
const hint = origin
|
||||||
|
? ` Reachable in principle, but if Ollama is actually running, it likely refused this page's origin (${origin}) — start it with OLLAMA_ORIGINS=${origin}.`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: (err instanceof Error ? err.message : String(err)) + hint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatLocal(
|
||||||
|
baseUrl: string,
|
||||||
|
model: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model, messages, stream: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
|
||||||
|
const body = (await res.json()) as OllamaChatResponse;
|
||||||
|
const content = body.message?.content;
|
||||||
|
if (!content) throw new Error('Ollama returned no message content');
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public: OpenAI-compatible chat-completions. OpenRouter by default, but any
|
||||||
|
// endpoint speaking this shape works unmodified (self-hosted vLLM, LiteLLM, etc). ──
|
||||||
|
|
||||||
|
interface OpenAiChatResponse {
|
||||||
|
choices?: Array<{ message?: { content?: string } }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatPublic(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
model: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model, messages }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Provider returned ${res.status}`);
|
||||||
|
const body = (await res.json()) as OpenAiChatResponse;
|
||||||
|
const content = body.choices?.[0]?.message?.content;
|
||||||
|
if (!content) throw new Error('Provider returned no message content');
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Retrieval: this app's own already-built offline search surface
|
||||||
|
// (app/api/offline/search/route.ts), not a client-side index — the
|
||||||
|
// encrypted SQLite/FTS5 store it reads only exists in Electron's main
|
||||||
|
// process. A 404/503 there means "no index in this session", not an error:
|
||||||
|
// degrade to an unaugmented chat rather than fail the question. ──
|
||||||
|
|
||||||
|
export interface AskSource {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskResult {
|
||||||
|
answer: string;
|
||||||
|
sources: AskSource[];
|
||||||
|
/** True when the question was answered without any retrieved context. */
|
||||||
|
unaugmented: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfflineSearchHit {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
snippet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfflineSearchResponse {
|
||||||
|
ok: true;
|
||||||
|
hits: OfflineSearchHit[];
|
||||||
|
contextBlock: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retrieveContext(question: string): Promise<OfflineSearchResponse | null> {
|
||||||
|
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6`);
|
||||||
|
if (!res.ok) return null; // 404 (no index configured) or 503 (unavailable this session) — both mean "no retrieval", not an error
|
||||||
|
const body = (await res.json()) as OfflineSearchResponse;
|
||||||
|
return body.ok ? body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPrompt(question: string, contextBlock: string): ChatMessage[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
"You answer questions about the user's email using only the numbered excerpts " +
|
||||||
|
'below as context. Cite sources by their number in brackets, e.g. [1]. If the ' +
|
||||||
|
"excerpts don't contain the answer, say so plainly rather than guessing.",
|
||||||
|
},
|
||||||
|
{ role: 'user', content: `${contextBlock}\n\nQuestion: ${question}` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskConfig {
|
||||||
|
provider: 'local' | 'public';
|
||||||
|
localBaseUrl: string;
|
||||||
|
localModel: string | null;
|
||||||
|
publicBaseUrl: string;
|
||||||
|
publicModel: string;
|
||||||
|
publicApiKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function askMail(question: string, config: AskConfig): Promise<AskResult> {
|
||||||
|
if (config.provider === 'local' && !config.localModel) {
|
||||||
|
throw new Error('No local model selected');
|
||||||
|
}
|
||||||
|
if (config.provider === 'public' && !config.publicApiKey) {
|
||||||
|
throw new Error('No public API key saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
const retrieved = await retrieveContext(question);
|
||||||
|
const messages = retrieved
|
||||||
|
? buildPrompt(question, retrieved.contextBlock)
|
||||||
|
: [{ role: 'user' as const, content: question }];
|
||||||
|
|
||||||
|
const answer =
|
||||||
|
config.provider === 'public'
|
||||||
|
? await chatPublic(config.publicBaseUrl, config.publicApiKey as string, config.publicModel, messages)
|
||||||
|
: await chatLocal(config.localBaseUrl, config.localModel as string, messages);
|
||||||
|
|
||||||
|
return {
|
||||||
|
answer,
|
||||||
|
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
|
||||||
|
unaugmented: !retrieved,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Small, isolated persistence for AI Assistant settings — deliberately NOT
|
||||||
|
// folded into stores/settings-store.ts tonight. That store's export/import
|
||||||
|
// feature enumerates every field by hand; this is prototype-scope UI state
|
||||||
|
// (docs/AI-ASSISTANT-CONCEPT.md §12's P0/P5), and migrating it into the
|
||||||
|
// shared store belongs with whichever phase makes these settings real
|
||||||
|
// product config rather than a local-AI test harness.
|
||||||
|
export type AiProvider = 'local' | 'public';
|
||||||
|
|
||||||
|
export interface AiLocalSettings {
|
||||||
|
provider: AiProvider | null;
|
||||||
|
localBaseUrl: string;
|
||||||
|
localModel: string | null;
|
||||||
|
publicBaseUrl: string;
|
||||||
|
publicModel: string;
|
||||||
|
publicConsentAccepted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'vncmail:ai:settings';
|
||||||
|
|
||||||
|
export const DEFAULT_AI_SETTINGS: AiLocalSettings = {
|
||||||
|
provider: null,
|
||||||
|
localBaseUrl: 'http://127.0.0.1:11434',
|
||||||
|
localModel: null,
|
||||||
|
publicBaseUrl: 'https://openrouter.ai/api/v1',
|
||||||
|
publicModel: '',
|
||||||
|
publicConsentAccepted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadAiSettings(): AiLocalSettings {
|
||||||
|
if (typeof window === 'undefined') return { ...DEFAULT_AI_SETTINGS };
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_AI_SETTINGS };
|
||||||
|
return { ...DEFAULT_AI_SETTINGS, ...JSON.parse(raw) };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_AI_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAiSettings(settings: AiLocalSettings): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
}
|
||||||
+16
-5
@@ -1,9 +1,20 @@
|
|||||||
// Shared client/server contract for the AI Assistant feature.
|
// Shared client/server contract for the AI Assistant feature.
|
||||||
// docs/AI-ASSISTANT-CONCEPT.md §9 (entitlement), §11 (client shape), §12 (P0).
|
// docs/AI-ASSISTANT-CONCEPT.md §9 (entitlement), §11 (client shape), §12 (P0).
|
||||||
//
|
//
|
||||||
// P0 scope only: this file defines the schema so it never needs a breaking
|
// This file defines the schema so it never needs a breaking migration later
|
||||||
// migration later (decision #4 — entitlement from day one, cheap now). No
|
// (decision #4 — entitlement from day one, cheap now).
|
||||||
// provider class is implemented behind it yet; see the doc's phase table.
|
//
|
||||||
|
// Decisions 2026-08-05 evening simplify the doc's original P1/P2 sequencing
|
||||||
|
// for now — local-first, nothing metered yet:
|
||||||
|
// - `local` ships free, always available, no entitlement check at all.
|
||||||
|
// - `public` is available too, but explicitly UNMONITORED for the moment
|
||||||
|
// (no seats, no metering, no consent-record backend — §7.3/§9/§10 are
|
||||||
|
// not built yet). The client-side "this leaves the organisation"
|
||||||
|
// acknowledgement still shows (cheap, honest), it just isn't
|
||||||
|
// server-enforced yet.
|
||||||
|
// - `server` (VNC-hosted, EU/CH) isn't wired up client-side yet — infra is
|
||||||
|
// "this MacBook tonight, the dev k8s cluster tomorrow" per that
|
||||||
|
// decision, sequenced after `local` rather than before it.
|
||||||
|
|
||||||
export type AiClass = 'local' | 'server' | 'public';
|
export type AiClass = 'local' | 'server' | 'public';
|
||||||
|
|
||||||
@@ -25,10 +36,10 @@ export interface AiPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
|
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
|
||||||
licensed: false,
|
licensed: true,
|
||||||
subject: 'tenant',
|
subject: 'tenant',
|
||||||
tier: 'base',
|
tier: 'base',
|
||||||
classes: [],
|
classes: ['local', 'public'],
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
graceUntil: null,
|
graceUntil: null,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user