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:
@@ -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<LocalDiscoveryResult | null>(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<string[]>([]);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{discovery && discoveryRecommended && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||
<Sparkles className="w-5 h-5 mt-0.5 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">Local AI found on this machine</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ollama is running at {discovery.baseUrl} with {discovery.models.length} model{discovery.models.length === 1 ? '' : 's'} installed.
|
||||
Recommended for quick answers: <span className="font-medium text-foreground">{discoveryRecommended}</span>.
|
||||
{discoveryLargest && discoveryLargest !== discoveryRecommended && (
|
||||
<> Also available for higher-quality answers: <span className="font-medium text-foreground">{discoveryLargest}</span>.</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={connectDiscoveredLocal}>
|
||||
<Sparkles className="w-3.5 h-3.5 me-1.5" /> Connect
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={dismissDiscoveryBanner}>
|
||||
<X className="w-3.5 h-3.5 me-1.5" /> Not now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
title="AI Assistant"
|
||||
description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; server is centrally hosted and licensed per seat; public sends your question to a provider you choose, using your own API key."
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
discoverLocalOllama,
|
||||
recommendDefaultModel,
|
||||
largestModel,
|
||||
isLocalDiscoveryDismissed,
|
||||
dismissLocalDiscovery,
|
||||
type DiscoveredLocalModel,
|
||||
} from '../local-discovery';
|
||||
|
||||
/**
|
||||
* Real model list from this machine's Ollama (`curl 127.0.0.1:11434/api/tags`,
|
||||
* 2026-08-06) — used as the test fixture rather than invented data, per the
|
||||
* explicit instruction to use the real local runtime as the test case for
|
||||
* "which queries are required and how to add most of the modules
|
||||
* automatically". Sizes/params/capabilities are copied verbatim.
|
||||
*/
|
||||
const REAL_MACHINE_MODELS: DiscoveredLocalModel[] = [
|
||||
{ name: 'nomic-embed-text:latest', capabilities: ['embedding'], parameterSize: '137M', sizeBytes: 274_302_450 },
|
||||
{ name: 'qwen2.5:32b', capabilities: ['completion', 'tools'], parameterSize: '32.8B', sizeBytes: 19_851_349_669 },
|
||||
{ name: 'gemma4:12b-mlx', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '', sizeBytes: 9_977_519_169 },
|
||||
{ name: 'gemma4:latest', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.0B', sizeBytes: 9_608_350_718 },
|
||||
{ name: 'deepseek-r1:32b', capabilities: ['completion', 'thinking'], parameterSize: '32.8B', sizeBytes: 19_851_337_809 },
|
||||
{ name: 'deepseek-r1:14b', capabilities: ['completion', 'thinking'], parameterSize: '14.8B', sizeBytes: 8_988_112_209 },
|
||||
{ name: 'llama3.2:latest', capabilities: ['completion', 'tools'], parameterSize: '3.2B', sizeBytes: 2_019_393_189 },
|
||||
{ name: 'hermes3:8b', capabilities: ['completion', 'tools'], parameterSize: '8B', sizeBytes: 4_661_227_000 },
|
||||
{ name: 'qwen3:latest', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.2B', sizeBytes: 5_200_000_000 },
|
||||
{ name: 'gemma4:e4b', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.0B', sizeBytes: 9_600_000_000 },
|
||||
{ name: 'qwen3.5:latest', capabilities: ['vision', 'completion', 'tools', 'thinking'], parameterSize: '9.7B', sizeBytes: 6_600_000_000 },
|
||||
];
|
||||
|
||||
describe('recommendDefaultModel', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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