feat(local-llm): Phase 2 — rich metadata + persistence (N4-N5, BN3-BN4)

N4: RamBudgetBar component — stacked horizontal bar showing OS+Apps,
    loaded models (by name with color), and free memory segments
N5: Context window size — extract context_length from /api/show
    model_info, cache in modelMetadata state, display on card
BN3: Persist chat messages to localStorage (llm-chat-{model}),
     restore on modal re-open, capped at 50 messages
BN4: Logs panel refresh button — RefreshCw icon next to toggle
This commit is contained in:
saravanakumardb1 2026-02-19 23:13:22 -08:00
parent 040013e495
commit 7f042975de
2 changed files with 181 additions and 13 deletions

View File

@ -0,0 +1,105 @@
'use client';
import { formatBytes } from '../lib/format';
interface RunningModelSegment {
name: string;
size_vram: number;
}
interface RamBudgetBarProps {
totalRam: number;
appMemory: number;
runningModels: RunningModelSegment[];
freeRam: number;
}
const MODEL_COLORS = [
'var(--success)',
'var(--accent-secondary)',
'var(--accent-primary)',
'var(--purple)',
'var(--warning)',
];
export function RamBudgetBar({ totalRam, appMemory, runningModels, freeRam }: RamBudgetBarProps) {
if (totalRam <= 0) return null;
const modelTotal = runningModels.reduce((sum, m) => sum + m.size_vram, 0);
const osApps = Math.max(0, totalRam - modelTotal - freeRam);
const pct = (bytes: number) => Math.max(0.5, (bytes / totalRam) * 100);
return (
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-medium" style={{ color: 'var(--text-secondary)' }}>
Memory Budget
</span>
<span className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>
{formatBytes(totalRam)} unified
</span>
</div>
<div
className="flex w-full h-5 rounded-md overflow-hidden"
style={{ background: 'var(--surface-muted)' }}
>
{/* OS + Apps */}
<div
className="h-full flex items-center justify-center text-[9px] font-medium shrink-0 overflow-hidden"
style={{
width: `${pct(osApps)}%`,
background: 'var(--text-tertiary)',
color: 'var(--bg-canvas)',
opacity: 0.5,
}}
title={`OS + Apps: ${formatBytes(osApps)}`}
>
{pct(osApps) > 8 ? 'OS' : ''}
</div>
{/* Loaded models */}
{runningModels.map((m, i) => (
<div
key={m.name}
className="h-full flex items-center justify-center text-[9px] font-mono font-medium shrink-0 overflow-hidden"
style={{
width: `${pct(m.size_vram)}%`,
background: MODEL_COLORS[i % MODEL_COLORS.length],
color: 'var(--bg-canvas)',
opacity: 0.85,
}}
title={`${m.name}: ${formatBytes(m.size_vram)}`}
>
{pct(m.size_vram) > 10 ? m.name.split(':')[0] : ''}
</div>
))}
{/* Free */}
<div
className="h-full flex-1 flex items-center justify-center text-[9px] font-medium overflow-hidden"
style={{
color: 'var(--text-tertiary)',
opacity: 0.6,
}}
title={`Free: ${formatBytes(freeRam)}`}
>
{pct(freeRam) > 8 ? `${formatBytes(freeRam)} free` : ''}
</div>
</div>
{runningModels.length > 0 && (
<div className="flex items-center gap-3 mt-1 flex-wrap">
{runningModels.map((m, i) => (
<span
key={m.name}
className="flex items-center gap-1 text-[10px]"
style={{ color: 'var(--text-tertiary)' }}
>
<span
className="w-2 h-2 rounded-sm inline-block"
style={{ background: MODEL_COLORS[i % MODEL_COLORS.length], opacity: 0.85 }}
/>
{m.name.split(':')[0]}: {formatBytes(m.size_vram)}
</span>
))}
</div>
)}
</div>
);
}

View File

@ -48,6 +48,7 @@ import { formatBytes, formatUptime, estimateRam, checkMemoryFit } from './lib/fo
import { StatusDot } from './components/StatusDot';
import { ProgressBar } from './components/ProgressBar';
import { Sparkline } from './components/Sparkline';
import { RamBudgetBar } from './components/RamBudgetBar';
export default function Dashboard() {
const [ollama, setOllama] = useState<OllamaData | null>(null);
@ -94,6 +95,9 @@ export default function Dashboard() {
const [whisperTestLoading, setWhisperTestLoading] = useState(false);
const [compareModel, setCompareModel] = useState<string | null>(null);
const [compareResponse, setCompareResponse] = useState('');
const [modelMetadata, setModelMetadata] = useState<Record<string, { contextLength?: number }>>(
{}
);
const responseRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const compareAbortRef = useRef<AbortController | null>(null);
@ -155,6 +159,26 @@ export default function Dashboard() {
fetchAll();
}, [fetchAll]);
// BN3: Save chat messages to localStorage when they change
useEffect(() => {
if (promptModel && chatMode && chatMessages.length > 0) {
localStorage.setItem(`llm-chat-${promptModel}`, JSON.stringify(chatMessages.slice(-50)));
}
}, [chatMessages, promptModel, chatMode]);
// BN3: Restore chat messages when prompt modal opens
useEffect(() => {
if (promptModel && chatMode) {
try {
const saved = localStorage.getItem(`llm-chat-${promptModel}`);
if (saved) setChatMessages(JSON.parse(saved));
} catch {
/* ignore */
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [promptModel, chatMode]);
// F16: Auto-load preferred model when Ollama is online but nothing loaded
useEffect(() => {
if (!autoLoadModel || !ollama || ollama.status !== 'online') return;
@ -289,7 +313,7 @@ export default function Dashboard() {
else localStorage.removeItem('llm-auto-load-model');
};
// Fetch modelfile for expanded model (F9)
// Fetch modelfile + metadata for expanded model (F9 + N5)
const fetchModelfile = async (modelName: string) => {
if (modelfileData[modelName]) return;
try {
@ -302,6 +326,14 @@ export default function Dashboard() {
if (data.modelfile) {
setModelfileData(prev => ({ ...prev, [modelName]: data.modelfile }));
}
// N5: Extract context_length from model_info
if (data.model_info) {
const ctxKey = Object.keys(data.model_info).find(k => k.endsWith('.context_length'));
const contextLength = ctxKey ? data.model_info[ctxKey] : undefined;
if (contextLength) {
setModelMetadata(prev => ({ ...prev, [modelName]: { contextLength } }));
}
}
} catch {
/* ignore */
}
@ -900,6 +932,15 @@ export default function Dashboard() {
</div>
) : (
<div className="space-y-3">
{/* N4: RAM Budget Bar */}
{system && ollama.running.length > 0 && (
<RamBudgetBar
totalRam={system.memory.total}
appMemory={system.memory.appMemory}
runningModels={ollama.running}
freeRam={system.memory.free}
/>
)}
{ollama.models
.filter(
m =>
@ -971,6 +1012,14 @@ export default function Dashboard() {
<span title="Estimated RAM when loaded (Apple Silicon unified memory)">
~{formatBytes(estRam)} RAM
</span>
{(() => {
const ctx = modelMetadata[model.name]?.contextLength;
return ctx ? (
<span title="Context window size">
{ctx >= 1024 ? `${Math.round(ctx / 1024)}k` : ctx} ctx
</span>
) : null;
})()}
</div>
</div>
</div>
@ -1517,18 +1566,32 @@ export default function Dashboard() {
{/* Ollama Logs Viewer (F8) */}
<div className="mt-6">
<button
onClick={() => {
setShowLogs(!showLogs);
if (!showLogs) fetchLogs();
}}
className="flex items-center gap-2 text-sm font-medium mb-3 transition-colors"
style={{ color: 'var(--text-tertiary)' }}
>
<Terminal className="w-4 h-4" />
{showLogs ? 'Hide' : 'Show'} Ollama Logs
<ChevronDown className={`w-3 h-3 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
</button>
<div className="flex items-center gap-2 mb-3">
<button
onClick={() => {
setShowLogs(!showLogs);
if (!showLogs) fetchLogs();
}}
className="flex items-center gap-2 text-sm font-medium transition-colors"
style={{ color: 'var(--text-tertiary)' }}
>
<Terminal className="w-4 h-4" />
{showLogs ? 'Hide' : 'Show'} Ollama Logs
<ChevronDown
className={`w-3 h-3 transition-transform ${showLogs ? 'rotate-180' : ''}`}
/>
</button>
{showLogs && (
<button
onClick={fetchLogs}
className="p-1 rounded transition-colors"
style={{ color: 'var(--text-tertiary)' }}
title="Refresh logs"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
{showLogs && (
<div
className="card p-4 max-h-60 overflow-y-auto font-mono text-[11px]"