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."
|
||||
|
||||
Reference in New Issue
Block a user