// Local-LLM auto-discovery: probes the loopback addresses a local Ollama // normally binds to, and — if one answers — recommends a model to connect // with, so a user with Ollama already running never has to type a base URL // or a model name by hand. // // One network call is enough: Ollama's own `/api/tags` already reports // per-model `capabilities` (completion/embedding/tools/thinking/vision), // `size`, and `details.parameter_size` — everything the recommendation // heuristic below needs, with no follow-up `/api/show` round trips. export interface DiscoveredLocalModel { name: string; capabilities: string[]; parameterSize: string; sizeBytes: number; } export interface LocalDiscoveryResult { baseUrl: string; models: DiscoveredLocalModel[]; } interface OllamaTagsResponse { models?: Array<{ name: string; capabilities?: string[]; size?: number; details?: { parameter_size?: string }; }>; } // Ollama's own default bind address, plus the hostname form — some setups // (notably OLLAMA_ORIGINS-restricted CORS allowlists keyed by hostname // rather than IP) answer one but not the other. const DEFAULT_PROBE_URLS = ['http://127.0.0.1:11434', 'http://localhost:11434']; const PROBE_TIMEOUT_MS = 1200; async function probeOne(baseUrl: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); try { const res = await fetch(`${baseUrl}/api/tags`, { signal: controller.signal }); if (!res.ok) return null; const body = (await res.json()) as OllamaTagsResponse; const models = (body.models ?? []) .filter((m) => typeof m.name === 'string' && m.name) .map((m) => ({ name: m.name, capabilities: m.capabilities ?? [], parameterSize: m.details?.parameter_size ?? '', sizeBytes: m.size ?? 0, })); return models.length > 0 ? { baseUrl, models } : null; } catch { return null; } finally { clearTimeout(timer); } } /** Tries each candidate in turn (not in parallel — the common case is the * first one answering, and probing sequentially avoids a burst of * simultaneous loopback connection attempts for no benefit). */ export async function discoverLocalOllama( candidateBaseUrls: readonly string[] = DEFAULT_PROBE_URLS, ): Promise { for (const baseUrl of candidateBaseUrls) { const result = await probeOne(baseUrl); if (result) return result; } return null; } /** * Picks one sensible default out of whatever's installed, so "Connect" * needs no follow-up decision. Chat-capable models only (never an * embedding-only model like nomic-embed-text). Among those, prefers * non-"thinking" models — a reasoning model's chain-of-thought preamble * reads as a broken first response in a guided setup, however good the * final answer is — and then the smallest by download size, on the theory * that the fastest first reply makes the best first impression; a user who * wants the largest/most capable model for real work can still pick it from * the full list this only pre-selects. */ export function recommendDefaultModel(models: readonly DiscoveredLocalModel[]): string | null { const chatCapable = models.filter((m) => m.capabilities.includes('completion')); if (chatCapable.length === 0) return null; const nonReasoning = chatCapable.filter((m) => !m.capabilities.includes('thinking')); const pool = nonReasoning.length > 0 ? nonReasoning : chatCapable; return [...pool].sort((a, b) => a.sizeBytes - b.sizeBytes)[0].name; } /** The largest chat-capable model, for the "most capable" callout next to * the speed-optimized recommendation above — skipped in the UI when it's * the same model `recommendDefaultModel` already picked. */ export function largestModel(models: readonly DiscoveredLocalModel[]): string | null { const chatCapable = models.filter((m) => m.capabilities.includes('completion')); if (chatCapable.length === 0) return null; return [...chatCapable].sort((a, b) => b.sizeBytes - a.sizeBytes)[0].name; } const DISMISSED_KEY = 'vncmail:ai:local-discovery-dismissed'; export function isLocalDiscoveryDismissed(): boolean { if (typeof window === 'undefined') return true; return window.localStorage.getItem(DISMISSED_KEY) === 'true'; } export function dismissLocalDiscovery(): void { if (typeof window === 'undefined') return; window.localStorage.setItem(DISMISSED_KEY, 'true'); }