feat(local-llm): Phase 3 — model intelligence badges + sort + version (N6-N10)
N6: <think> warning badge for DeepSeek R1 and distilled variants N7: Vision model indicator for llava, bakllava, moondream, qwen-vl, etc. N8: Architecture/family badge as pill on every model card N9: Sort dropdown (A-Z, size, params, running, recent) with localStorage persist N10: Ollama server version fetched from /api/version, shown in stats card
This commit is contained in:
parent
7f042975de
commit
6f6baf99c8
@ -29,13 +29,15 @@ async function fetchOllama(path: string, options?: RequestInit) {
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const [modelsRes, psRes] = await Promise.allSettled([
|
const [modelsRes, psRes, versionRes] = await Promise.allSettled([
|
||||||
fetchOllama('/api/tags'),
|
fetchOllama('/api/tags'),
|
||||||
fetchOllama('/api/ps'),
|
fetchOllama('/api/ps'),
|
||||||
|
fetchOllama('/api/version'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const models = modelsRes.status === 'fulfilled' ? (modelsRes.value.models ?? []) : [];
|
const models = modelsRes.status === 'fulfilled' ? (modelsRes.value.models ?? []) : [];
|
||||||
const running = psRes.status === 'fulfilled' ? (psRes.value.models ?? []) : [];
|
const running = psRes.status === 'fulfilled' ? (psRes.value.models ?? []) : [];
|
||||||
|
const version = versionRes.status === 'fulfilled' ? (versionRes.value.version ?? null) : null;
|
||||||
|
|
||||||
const totalSize = models.reduce((acc: number, m: { size: number }) => acc + (m.size || 0), 0);
|
const totalSize = models.reduce((acc: number, m: { size: number }) => acc + (m.size || 0), 0);
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ export async function GET() {
|
|||||||
totalModels: models.length,
|
totalModels: models.length,
|
||||||
totalSize,
|
totalSize,
|
||||||
runningCount: running.length,
|
runningCount: running.length,
|
||||||
|
version,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@ -32,6 +32,39 @@ export function checkMemoryFit(
|
|||||||
return 'no';
|
return 'no';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// N6/N7: Detect model capabilities from name and family
|
||||||
|
export interface ModelBadge {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
tooltip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelBadges(name: string, family?: string): ModelBadge[] {
|
||||||
|
const badges: ModelBadge[] = [];
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
const f = (family || '').toLowerCase();
|
||||||
|
|
||||||
|
// N6: DeepSeek R1 reasoning models emit <think> traces
|
||||||
|
if (n.includes('deepseek-r1') || n.includes('deepseek-r1-distill') || f.includes('deepseek')) {
|
||||||
|
badges.push({
|
||||||
|
label: '<think>',
|
||||||
|
color: 'var(--warning)',
|
||||||
|
tooltip: 'Emits <think> reasoning traces — strip before JSON.parse',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// N7: Vision/multimodal models
|
||||||
|
if (/llava|bakllava|moondream|qwen.*vl|minicpm-v|llama.*vision/i.test(n)) {
|
||||||
|
badges.push({
|
||||||
|
label: '👁 Vision',
|
||||||
|
color: 'var(--accent-secondary)',
|
||||||
|
tooltip: 'Vision model — supports image input',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatUptime(seconds: number): string {
|
export function formatUptime(seconds: number): string {
|
||||||
const d = Math.floor(seconds / 86400);
|
const d = Math.floor(seconds / 86400);
|
||||||
const h = Math.floor((seconds % 86400) / 3600);
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export interface OllamaData {
|
|||||||
totalModels: number;
|
totalModels: number;
|
||||||
totalSize: number;
|
totalSize: number;
|
||||||
runningCount: number;
|
runningCount: number;
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhisperModel {
|
export interface WhisperModel {
|
||||||
|
|||||||
@ -44,7 +44,13 @@ import type {
|
|||||||
PullProgress,
|
PullProgress,
|
||||||
StreamMetrics,
|
StreamMetrics,
|
||||||
} from './lib/types';
|
} from './lib/types';
|
||||||
import { formatBytes, formatUptime, estimateRam, checkMemoryFit } from './lib/format';
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatUptime,
|
||||||
|
estimateRam,
|
||||||
|
checkMemoryFit,
|
||||||
|
getModelBadges,
|
||||||
|
} from './lib/format';
|
||||||
import { StatusDot } from './components/StatusDot';
|
import { StatusDot } from './components/StatusDot';
|
||||||
import { ProgressBar } from './components/ProgressBar';
|
import { ProgressBar } from './components/ProgressBar';
|
||||||
import { Sparkline } from './components/Sparkline';
|
import { Sparkline } from './components/Sparkline';
|
||||||
@ -98,6 +104,9 @@ export default function Dashboard() {
|
|||||||
const [modelMetadata, setModelMetadata] = useState<Record<string, { contextLength?: number }>>(
|
const [modelMetadata, setModelMetadata] = useState<Record<string, { contextLength?: number }>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
const [modelSort, setModelSort] = useState<'name' | 'size' | 'params' | 'running' | 'modified'>(
|
||||||
|
'name'
|
||||||
|
);
|
||||||
const responseRef = useRef<HTMLDivElement>(null);
|
const responseRef = useRef<HTMLDivElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const compareAbortRef = useRef<AbortController | null>(null);
|
const compareAbortRef = useRef<AbortController | null>(null);
|
||||||
@ -153,6 +162,8 @@ export default function Dashboard() {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
setAutoLoadModel(localStorage.getItem('llm-auto-load-model'));
|
setAutoLoadModel(localStorage.getItem('llm-auto-load-model'));
|
||||||
|
const savedSort = localStorage.getItem('llm-model-sort');
|
||||||
|
if (savedSort) setModelSort(savedSort as typeof modelSort);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -723,6 +734,14 @@ export default function Dashboard() {
|
|||||||
<span className="text-lg font-bold">
|
<span className="text-lg font-bold">
|
||||||
{ollama?.status === 'online' ? 'Online' : 'Offline'}
|
{ollama?.status === 'online' ? 'Online' : 'Offline'}
|
||||||
</span>
|
</span>
|
||||||
|
{ollama?.version && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded font-mono"
|
||||||
|
style={{ background: 'var(--surface-muted)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
v{ollama.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{ollama?.url}
|
{ollama?.url}
|
||||||
@ -833,27 +852,50 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Search (F2) */}
|
{/* Model Search (F2) + Sort (N9) */}
|
||||||
{ollama?.status === 'online' && ollama.models.length > 3 && (
|
{ollama?.status === 'online' && ollama.models.length > 3 && (
|
||||||
<div className="relative mb-3">
|
<div className="flex gap-2 mb-3">
|
||||||
<Search
|
<div className="relative flex-1">
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
<Search
|
||||||
style={{ color: 'var(--text-tertiary)' }}
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||||
/>
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
<input
|
/>
|
||||||
data-model-search
|
<input
|
||||||
type="text"
|
data-model-search
|
||||||
value={modelSearch}
|
type="text"
|
||||||
onChange={e => setModelSearch(e.target.value)}
|
value={modelSearch}
|
||||||
placeholder="Filter models... (press / to focus)"
|
onChange={e => setModelSearch(e.target.value)}
|
||||||
className="w-full pl-9 pr-3 py-2 rounded-lg text-sm font-mono focus:outline-none focus:ring-2"
|
placeholder="Filter models... (press / to focus)"
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-lg text-sm font-mono focus:outline-none focus:ring-2"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-muted)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
caretColor: 'var(--accent-primary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={modelSort}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value as typeof modelSort;
|
||||||
|
setModelSort(v);
|
||||||
|
localStorage.setItem('llm-model-sort', v);
|
||||||
|
}}
|
||||||
|
className="px-2 py-2 rounded-lg text-xs font-medium focus:outline-none focus:ring-2"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--surface-muted)',
|
background: 'var(--surface-muted)',
|
||||||
border: '1px solid var(--border-subtle)',
|
border: '1px solid var(--border-subtle)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-secondary)',
|
||||||
caretColor: 'var(--accent-primary)',
|
|
||||||
}}
|
}}
|
||||||
/>
|
title="Sort models"
|
||||||
|
>
|
||||||
|
<option value="name">A–Z</option>
|
||||||
|
<option value="size">Size</option>
|
||||||
|
<option value="params">Params</option>
|
||||||
|
<option value="running">Running</option>
|
||||||
|
<option value="modified">Recent</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -949,10 +991,31 @@ export default function Dashboard() {
|
|||||||
m.details?.family?.toLowerCase().includes(modelSearch.toLowerCase()) ||
|
m.details?.family?.toLowerCase().includes(modelSearch.toLowerCase()) ||
|
||||||
m.details?.quantization_level?.toLowerCase().includes(modelSearch.toLowerCase())
|
m.details?.quantization_level?.toLowerCase().includes(modelSearch.toLowerCase())
|
||||||
)
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
switch (modelSort) {
|
||||||
|
case 'size':
|
||||||
|
return b.size - a.size;
|
||||||
|
case 'params': {
|
||||||
|
const pa = parseFloat(a.details?.parameter_size || '0');
|
||||||
|
const pb = parseFloat(b.details?.parameter_size || '0');
|
||||||
|
return pb - pa;
|
||||||
|
}
|
||||||
|
case 'running': {
|
||||||
|
const ra = isRunning(a.name) ? 0 : 1;
|
||||||
|
const rb = isRunning(b.name) ? 0 : 1;
|
||||||
|
return ra !== rb ? ra - rb : a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
case 'modified':
|
||||||
|
return new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime();
|
||||||
|
default:
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
})
|
||||||
.map(model => {
|
.map(model => {
|
||||||
const running = isRunning(model.name);
|
const running = isRunning(model.name);
|
||||||
const expanded = expandedModel === model.name;
|
const expanded = expandedModel === model.name;
|
||||||
const estRam = estimateRam(model.size, model.details?.quantization_level);
|
const estRam = estimateRam(model.size, model.details?.quantization_level);
|
||||||
|
const badges = getModelBadges(model.name, model.details?.family);
|
||||||
const fitStatus = system
|
const fitStatus = system
|
||||||
? checkMemoryFit(estRam, system.memory.free, system.memory.cached)
|
? checkMemoryFit(estRam, system.memory.free, system.memory.cached)
|
||||||
: null;
|
: null;
|
||||||
@ -997,6 +1060,30 @@ export default function Dashboard() {
|
|||||||
LOADED
|
LOADED
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{badges.map(b => (
|
||||||
|
<span
|
||||||
|
key={b.label}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||||
|
style={{
|
||||||
|
background: `color-mix(in srgb, ${b.color} 15%, transparent)`,
|
||||||
|
color: b.color,
|
||||||
|
}}
|
||||||
|
title={b.tooltip}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{model.details?.family && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded font-mono"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-muted)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{model.details.family}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 text-xs mt-0.5 flex-wrap"
|
className="flex items-center gap-3 text-xs mt-0.5 flex-wrap"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user