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:
saravanakumardb1 2026-02-19 23:17:07 -08:00
parent 7f042975de
commit 6f6baf99c8
4 changed files with 142 additions and 18 deletions

View File

@ -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({

View File

@ -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);

View File

@ -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 {

View File

@ -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">AZ</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"