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() {
|
||||
try {
|
||||
const [modelsRes, psRes] = await Promise.allSettled([
|
||||
const [modelsRes, psRes, versionRes] = await Promise.allSettled([
|
||||
fetchOllama('/api/tags'),
|
||||
fetchOllama('/api/ps'),
|
||||
fetchOllama('/api/version'),
|
||||
]);
|
||||
|
||||
const models = modelsRes.status === 'fulfilled' ? (modelsRes.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);
|
||||
|
||||
@ -47,6 +49,7 @@ export async function GET() {
|
||||
totalModels: models.length,
|
||||
totalSize,
|
||||
runningCount: running.length,
|
||||
version,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
|
||||
@ -32,6 +32,39 @@ export function checkMemoryFit(
|
||||
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 {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
|
||||
@ -26,6 +26,7 @@ export interface OllamaData {
|
||||
totalModels: number;
|
||||
totalSize: number;
|
||||
runningCount: number;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface WhisperModel {
|
||||
|
||||
@ -44,7 +44,13 @@ import type {
|
||||
PullProgress,
|
||||
StreamMetrics,
|
||||
} 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 { ProgressBar } from './components/ProgressBar';
|
||||
import { Sparkline } from './components/Sparkline';
|
||||
@ -98,6 +104,9 @@ export default function Dashboard() {
|
||||
const [modelMetadata, setModelMetadata] = useState<Record<string, { contextLength?: number }>>(
|
||||
{}
|
||||
);
|
||||
const [modelSort, setModelSort] = useState<'name' | 'size' | 'params' | 'running' | 'modified'>(
|
||||
'name'
|
||||
);
|
||||
const responseRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const compareAbortRef = useRef<AbortController | null>(null);
|
||||
@ -153,6 +162,8 @@ export default function Dashboard() {
|
||||
/* ignore */
|
||||
}
|
||||
setAutoLoadModel(localStorage.getItem('llm-auto-load-model'));
|
||||
const savedSort = localStorage.getItem('llm-model-sort');
|
||||
if (savedSort) setModelSort(savedSort as typeof modelSort);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -723,6 +734,14 @@ export default function Dashboard() {
|
||||
<span className="text-lg font-bold">
|
||||
{ollama?.status === 'online' ? 'Online' : 'Offline'}
|
||||
</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>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{ollama?.url}
|
||||
@ -833,27 +852,50 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Search (F2) */}
|
||||
{/* Model Search (F2) + Sort (N9) */}
|
||||
{ollama?.status === 'online' && ollama.models.length > 3 && (
|
||||
<div className="relative mb-3">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
/>
|
||||
<input
|
||||
data-model-search
|
||||
type="text"
|
||||
value={modelSearch}
|
||||
onChange={e => setModelSearch(e.target.value)}
|
||||
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"
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
/>
|
||||
<input
|
||||
data-model-search
|
||||
type="text"
|
||||
value={modelSearch}
|
||||
onChange={e => setModelSearch(e.target.value)}
|
||||
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={{
|
||||
background: 'var(--surface-muted)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
color: 'var(--text-primary)',
|
||||
caretColor: 'var(--accent-primary)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
/>
|
||||
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>
|
||||
)}
|
||||
|
||||
@ -949,10 +991,31 @@ export default function Dashboard() {
|
||||
m.details?.family?.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 => {
|
||||
const running = isRunning(model.name);
|
||||
const expanded = expandedModel === model.name;
|
||||
const estRam = estimateRam(model.size, model.details?.quantization_level);
|
||||
const badges = getModelBadges(model.name, model.details?.family);
|
||||
const fitStatus = system
|
||||
? checkMemoryFit(estRam, system.memory.free, system.memory.cached)
|
||||
: null;
|
||||
@ -997,6 +1060,30 @@ export default function Dashboard() {
|
||||
LOADED
|
||||
</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
|
||||
className="flex items-center gap-3 text-xs mt-0.5 flex-wrap"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user