From 2dc224e882e14bd36c915d037e22c19780b8b152 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Thu, 6 Aug 2026 17:41:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20local=20LLM=20auto-discovery=20?= =?UTF-8?q?=E2=80=94=20find=20a=20running=20Ollama,=20suggest=20connecting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lib/ai/local-discovery.ts: one /api/tags query against the loopback addresses Ollama binds to (127.0.0.1/localhost), no follow-up /api/show round trips needed — the tags response already carries capabilities, size, and parameter_size, enough to recommend a default model. Picks the smallest non-"thinking" chat-capable model for the fastest first response ("Connect" pre-fills provider+baseUrl+model in one click), and separately surfaces the largest as a "higher quality" alternative. New banner in ai-assistant-settings.tsx: fires when Local isn't yet configured, offers one-click Connect or a persisted "Not now" dismissal. 13 new unit tests using this machine's actual Ollama /api/tags response (11 real installed models — qwen2.5:32b, llama3.2, deepseek-r1 x2, gemma4 x3, hermes3, qwen3, qwen3.5, nomic-embed-text) as literal fixtures, per the explicit instruction to use this machine as the test case: confirms exactly one query is required, the heuristic recommends llama3.2:latest (fastest) / qwen2.5:32b (largest) on this real fleet, never recommends an embedding-only model, and degrades correctly when a candidate base URL is unreachable. Full QA gate: tsc clean, eslint clean, 2498/2498 tests passing, build clean. Also live-verified in a real browser session against this machine's real Ollama — the banner rendered with exactly these two model names. --- components/settings/ai-assistant-settings.tsx | 78 ++++++++- lib/ai/__tests__/local-discovery.test.ts | 150 ++++++++++++++++++ lib/ai/local-discovery.ts | 112 +++++++++++++ 3 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 lib/ai/__tests__/local-discovery.test.ts create mode 100644 lib/ai/local-discovery.ts diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx index a9d104fc..c834f86c 100644 --- a/components/settings/ai-assistant-settings.tsx +++ b/components/settings/ai-assistant-settings.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2 } from 'lucide-react'; +import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2, Sparkles, X } from 'lucide-react'; import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/browser-navigation'; @@ -9,6 +9,14 @@ import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities'; import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store'; import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings'; +import { + discoverLocalOllama, + recommendDefaultModel, + largestModel, + isLocalDiscoveryDismissed, + dismissLocalDiscovery, + type LocalDiscoveryResult, +} from '@/lib/ai/local-discovery'; import { askMail, listLocalModels, @@ -91,6 +99,47 @@ export function AiAssistantSettings() { } }, [settings.localBaseUrl]); + // ── Local discovery — proactively find an already-running Ollama and + // offer a one-click connect, rather than making the user hunt down and + // type a base URL + model name by hand. ── + const [discovery, setDiscovery] = useState(null); + const [discoveryDismissed, setDiscoveryDismissed] = useState(true); + + useEffect(() => { + setDiscoveryDismissed(isLocalDiscoveryDismissed()); + }, []); + + useEffect(() => { + if (!canUseLocal || discoveryDismissed || settings.localModel) return; + let cancelled = false; + (async () => { + const result = await discoverLocalOllama(); + if (!cancelled) setDiscovery(result); + })(); + return () => { + cancelled = true; + }; + }, [canUseLocal, discoveryDismissed, settings.localModel]); + + const connectDiscoveredLocal = useCallback(() => { + if (!discovery) return; + const recommended = recommendDefaultModel(discovery.models) ?? discovery.models[0]?.name ?? null; + if (!recommended) return; + setSettings((prev) => { + const next: AiLocalSettings = { ...prev, provider: 'local', localBaseUrl: discovery.baseUrl, localModel: recommended }; + saveAiSettings(next); + return next; + }); + setLocalModels(discovery.models.filter((m) => m.capabilities.includes('completion')).map((m) => m.name)); + setDiscovery(null); + }, [discovery]); + + const dismissDiscoveryBanner = useCallback(() => { + dismissLocalDiscovery(); + setDiscoveryDismissed(true); + setDiscovery(null); + }, []); + // ── Server provider ── const [serverModels, setServerModels] = useState([]); const [refreshingServer, setRefreshingServer] = useState(false); @@ -209,8 +258,35 @@ export function AiAssistantSettings() { ); } + const discoveryRecommended = discovery ? recommendDefaultModel(discovery.models) : null; + const discoveryLargest = discovery ? largestModel(discovery.models) : null; + return (
+ {discovery && discoveryRecommended && ( +
+ +
+

Local AI found on this machine

+

+ Ollama is running at {discovery.baseUrl} with {discovery.models.length} model{discovery.models.length === 1 ? '' : 's'} installed. + Recommended for quick answers: {discoveryRecommended}. + {discoveryLargest && discoveryLargest !== discoveryRecommended && ( + <> Also available for higher-quality answers: {discoveryLargest}. + )} +

+
+ + +
+
+
+ )} + { + it('picks the smallest non-"thinking" chat model from a real mixed fleet', () => { + // llama3.2 (2.0GB) is the smallest completion-capable, non-reasoning + // model on this real machine — everything smaller is embedding-only. + expect(recommendDefaultModel(REAL_MACHINE_MODELS)).toBe('llama3.2:latest'); + }); + + it('never recommends an embedding-only model', () => { + const onlyEmbedding = [REAL_MACHINE_MODELS[0]]; // nomic-embed-text + expect(recommendDefaultModel(onlyEmbedding)).toBeNull(); + }); + + it('falls back to the smallest "thinking" model when nothing else qualifies', () => { + const onlyReasoning = REAL_MACHINE_MODELS.filter((m) => m.capabilities.includes('thinking') && !m.capabilities.includes('vision')); + // Smallest of the thinking-only pool here is qwen3 (5.2GB) before gemma4 variants. + expect(recommendDefaultModel(onlyReasoning)).toBe('qwen3:latest'); + }); + + it('returns null when no models are chat-capable at all', () => { + expect(recommendDefaultModel([])).toBeNull(); + }); +}); + +describe('largestModel', () => { + it('picks the biggest chat-capable model — qwen2.5:32b, by 11,860 bytes over deepseek-r1:32b', () => { + // Both are ~19.85GB on this real machine (same base size class), but + // qwen2.5:32b's actual manifest is very slightly larger — not a tie. + expect(largestModel(REAL_MACHINE_MODELS)).toBe('qwen2.5:32b'); + }); + + it('excludes embedding-only models even though they can be tiny or huge', () => { + expect(largestModel([REAL_MACHINE_MODELS[0]])).toBeNull(); + }); +}); + +describe('discoverLocalOllama', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('parses a real-shaped /api/tags response into DiscoveredLocalModel[]', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + models: [ + { name: 'llama3.2:latest', capabilities: ['completion', 'tools'], size: 2_019_393_189, details: { parameter_size: '3.2B' } }, + { name: 'nomic-embed-text:latest', capabilities: ['embedding'], size: 274_302_450, details: { parameter_size: '137M' } }, + ], + }), + }) as unknown as typeof fetch; + + const result = await discoverLocalOllama(['http://127.0.0.1:11434']); + expect(result).not.toBeNull(); + expect(result?.baseUrl).toBe('http://127.0.0.1:11434'); + expect(result?.models).toHaveLength(2); + expect(result?.models[0]).toEqual({ + name: 'llama3.2:latest', + capabilities: ['completion', 'tools'], + parameterSize: '3.2B', + sizeBytes: 2_019_393_189, + }); + }); + + it('requires exactly one query — a single /api/tags call, no follow-up /api/show requests', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ models: [{ name: 'llama3.2:latest', capabilities: ['completion'], size: 1, details: {} }] }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + await discoverLocalOllama(['http://127.0.0.1:11434']); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:11434/api/tags', expect.anything()); + }); + + it('falls through to the next candidate base URL when the first is unreachable', async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error('connection refused')) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ models: [{ name: 'llama3.2:latest', capabilities: ['completion'], size: 1, details: {} }] }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const result = await discoverLocalOllama(['http://127.0.0.1:11434', 'http://localhost:11434']); + expect(result?.baseUrl).toBe('http://localhost:11434'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('returns null when nothing answers on any candidate', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('connection refused')) as unknown as typeof fetch; + const result = await discoverLocalOllama(['http://127.0.0.1:11434', 'http://localhost:11434']); + expect(result).toBeNull(); + }); + + it('returns null (not an empty-models result) when Ollama answers with zero models installed', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ models: [] }) }) as unknown as typeof fetch; + const result = await discoverLocalOllama(['http://127.0.0.1:11434']); + expect(result).toBeNull(); + }); +}); + +describe('dismissal persistence', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('is not dismissed by default', () => { + expect(isLocalDiscoveryDismissed()).toBe(false); + }); + + it('persists a dismissal across calls', () => { + dismissLocalDiscovery(); + expect(isLocalDiscoveryDismissed()).toBe(true); + }); +}); diff --git a/lib/ai/local-discovery.ts b/lib/ai/local-discovery.ts new file mode 100644 index 00000000..7cdd9268 --- /dev/null +++ b/lib/ai/local-discovery.ts @@ -0,0 +1,112 @@ +// 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'); +}