From 5d7ae230ceffc2686d01c40538414c4c5d5071ba Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Wed, 5 Aug 2026 22:41:22 +0200 Subject: [PATCH] feat(ai): real local Ollama chat + BYOK public provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/settings/ai-assistant-settings.tsx | 332 ++++++++++++++++-- lib/ai/key-store.ts | 26 ++ lib/ai/local-client.ts | 187 ++++++++++ lib/ai/local-settings.ts | 43 +++ lib/ai/types.ts | 21 +- 5 files changed, 565 insertions(+), 44 deletions(-) create mode 100644 lib/ai/key-store.ts create mode 100644 lib/ai/local-client.ts create mode 100644 lib/ai/local-settings.ts diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx index 324aa083..7bfccf18 100644 --- a/components/settings/ai-assistant-settings.tsx +++ b/components/settings/ai-assistant-settings.tsx @@ -1,32 +1,41 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { Sparkles, Loader2 } from 'lucide-react'; -import { SettingsSection, SettingItem } from './settings-section'; +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]'; /** - * P0 scope only (docs/AI-ASSISTANT-CONCEPT.md §12): proves capability - * gating and the policy-fetch round trip. No provider is called from here — - * that's P1 (server class) onward. Once entitlement is real (P2), this pane - * grows the Model/Scope/Index sections from §4. + * 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(DEFAULT_AI_POLICY); - const [loading, setLoading] = useState(true); + const [policyLoading, setPolicyLoading] = useState(true); + const [settings, setSettings] = useState(() => loadAiSettings()); useEffect(() => { let cancelled = false; (async () => { try { const res = await apiFetch('/api/ai/policy'); - if (res.ok && !cancelled) { - setPolicy(await res.json()); - } + if (res.ok && !cancelled) setPolicy(await res.json()); } finally { - if (!cancelled) setLoading(false); + if (!cancelled) setPolicyLoading(false); } })(); return () => { @@ -34,43 +43,288 @@ export function AiAssistantSettings() { }; }, []); + const update = useCallback((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([]); + const [refreshing, setRefreshing] = useState(false); + const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle'); + const [testError, setTestError] = useState(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(null); + const [askError, setAskError] = useState(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 ( +
+ Loading… +
+ ); + } + return (
- - {loading ? ( - - Checking availability… - - ) : policy.entitlement.licensed ? ( - - Licensed ({policy.entitlement.tier}) — no model provider is configured yet. - + + {providerOptions.length > 0 ? ( + update('provider', v as 'local' | 'public')} + options={providerOptions} + /> ) : ( - Not yet licensed for this account. + No provider class available. )} - -
- -

- Local, VNC-hosted, and bring-your-own-key providers are planned (see the AI Assistant - concept doc). {supportsLocalLlm() - ? localLlmNeedsCorsSetup() - ? 'A local runtime will need its CORS setting adjusted to allow this browser origin.' - : 'This desktop app can reach a local runtime with no extra setup.' - : null} -

-
-
+ {settings.provider === 'local' && canUseLocal && ( + + + update('localBaseUrl', e.target.value)} + spellCheck={false} + className={inputClass} + /> + + +
+ {localModels.length > 0 ? ( + update('publicBaseUrl', e.target.value)} + spellCheck={false} + className={inputClass} + /> + + + update('publicModel', e.target.value)} + placeholder="e.g. anthropic/claude-sonnet-4.5" + spellCheck={false} + className={inputClass} + /> + + +
+ setApiKeyInput(e.target.value)} + placeholder={hasSavedKey ? '•••• saved' : 'sk-...'} + spellCheck={false} + className={inputClass} + /> + +
+
+ + update('publicConsentAccepted', v)} + /> + + + )} + + {settings.provider && ( + +
+