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:
parent
040013e495
commit
7f042975de
105
__LOCAL_LLMs/dashboard/src/app/components/RamBudgetBar.tsx
Normal file
105
__LOCAL_LLMs/dashboard/src/app/components/RamBudgetBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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]"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user