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:
Bernd Rodler
2026-08-05 22:41:22 +02:00
parent 2a35019b21
commit 5d7ae230ce
5 changed files with 565 additions and 44 deletions
+26
View File
@@ -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);
}
+187
View File
@@ -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,
};
}
+43
View File
@@ -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
View File
@@ -1,9 +1,20 @@
// Shared client/server contract for the AI Assistant feature.
// 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
// migration later (decision #4 — entitlement from day one, cheap now). No
// provider class is implemented behind it yet; see the doc's phase table.
// This file defines the schema so it never needs a breaking migration later
// (decision #4 — entitlement from day one, cheap now).
//
// 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';
@@ -25,10 +36,10 @@ export interface AiPolicy {
}
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
licensed: false,
licensed: true,
subject: 'tenant',
tier: 'base',
classes: [],
classes: ['local', 'public'],
expiresAt: null,
graceUntil: null,
};