Files
SRCmail/lib/ai/local-discovery.ts
T
Bernd Rodler 2dc224e882 feat(ai): local LLM auto-discovery — find a running Ollama, suggest connecting
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.
2026-08-06 17:41:49 +02:00

113 lines
4.4 KiB
TypeScript

// 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<LocalDiscoveryResult | null> {
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<LocalDiscoveryResult | null> {
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');
}