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.
113 lines
4.4 KiB
TypeScript
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');
|
|
}
|