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.
This commit is contained in:
@@ -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<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');
|
||||
}
|
||||
Reference in New Issue
Block a user