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.
188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
// 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,
|
|
};
|
|
}
|