// 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 { 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 { 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 { 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 { 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 { 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, }; }