feat(local-llm): Phase 6 — data persistence + export (F29-F31)

F29: Export/import settings — gear icon in header opens settings popover,
     export downloads all llm-* localStorage as JSON, import validates
     and merges, both with toast feedback
F30: Inference history log — saves prompt/response/model/metrics to
     llm-inference-log (capped 100 FIFO), searchable panel with replay
     button, count badge in header toggle
F31: Factory reset — confirm dialog clears all llm-* localStorage keys,
     resets all component state to defaults
This commit is contained in:
saravanakumardb1 2026-02-19 23:29:40 -08:00
parent 44ad8a6301
commit 07d391101a

View File

@ -35,6 +35,7 @@ import {
Tag,
Star,
MessageSquare,
Settings,
} from 'lucide-react';
import type {
OllamaData,
@ -116,6 +117,18 @@ export default function Dashboard() {
>({});
const [countdownTick, setCountdownTick] = useState(0);
const [visionImages, setVisionImages] = useState<string[]>([]);
const [showSettings, setShowSettings] = useState(false);
const [inferenceLog, setInferenceLog] = useState<
Array<{
model: string;
prompt: string;
response: string;
tokPerSec?: number;
timestamp: number;
}>
>([]);
const [showInferenceLog, setShowInferenceLog] = useState(false);
const [inferenceSearch, setInferenceSearch] = useState('');
const responseRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const compareAbortRef = useRef<AbortController | null>(null);
@ -179,6 +192,12 @@ export default function Dashboard() {
} catch {
/* ignore */
}
try {
const savedLog = localStorage.getItem('llm-inference-log');
if (savedLog) setInferenceLog(JSON.parse(savedLog));
} catch {
/* ignore */
}
}, []);
useEffect(() => {
@ -575,6 +594,21 @@ export default function Dashboard() {
}
}
if (!fullResponse) setPromptResponse('(empty response)');
// F30: Save to inference log
if (promptModel && fullResponse) {
setInferenceLog(prev => {
const entry = {
model: promptModel,
prompt: promptText.trim(),
response: fullResponse,
tokPerSec: streamMetrics?.tokensPerSec,
timestamp: Date.now(),
};
const updated = [entry, ...prev].slice(0, 100);
localStorage.setItem('llm-inference-log', JSON.stringify(updated));
return updated;
});
}
} catch (err) {
if (controller.signal.aborted) {
// User cancelled — keep partial response
@ -592,6 +626,67 @@ export default function Dashboard() {
setTimeout(() => setCopied(false), 2000);
};
// F29: Export all llm-* localStorage keys as JSON
const exportSettings = () => {
const data: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('llm-')) {
data[key] = localStorage.getItem(key) || '';
}
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `llm-dashboard-settings-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
addToast('Settings exported', 'success');
};
// F29: Import settings from JSON file
const importSettings = (file: File) => {
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result as string);
if (typeof data !== 'object' || data === null) throw new Error('Invalid format');
let count = 0;
for (const [key, value] of Object.entries(data)) {
if (key.startsWith('llm-') && typeof value === 'string') {
localStorage.setItem(key, value);
count++;
}
}
addToast(`Imported ${count} settings — refresh to apply`, 'success');
} catch {
addToast('Invalid settings file', 'error');
}
};
reader.readAsText(file);
};
// F31: Clear all llm-* localStorage keys
const factoryReset = () => {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('llm-')) keysToRemove.push(key);
}
keysToRemove.forEach(k => localStorage.removeItem(k));
setModelTags({});
setAutoLoadModel(null);
setModelBenchmarks({});
setInferenceLog([]);
setModelSort('name');
setChatMessages([]);
setTheme('dark');
document.documentElement.classList.remove('light');
addToast(`Cleared ${keysToRemove.length} settings`, 'success');
setShowSettings(false);
};
// Chat mode streaming (F4)
const handleChat = async () => {
if (!promptModel || !promptText.trim()) return;
@ -729,6 +824,95 @@ export default function Dashboard() {
<span className="text-xs hidden sm:inline" style={{ color: 'var(--text-tertiary)' }}>
Last refresh: {lastRefresh.toLocaleTimeString()}
</span>
{/* F30: Inference log toggle */}
<button
onClick={() => setShowInferenceLog(s => !s)}
className="p-2 rounded-lg transition-colors relative"
style={{
background: showInferenceLog ? 'var(--accent-primary)' : 'var(--surface-card)',
border: '1px solid var(--border-subtle)',
color: showInferenceLog ? 'white' : 'var(--text-secondary)',
}}
title="Inference history"
>
<FileText className="w-4 h-4" />
{inferenceLog.length > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 rounded-full text-[9px] flex items-center justify-center"
style={{ background: 'var(--accent-secondary)', color: 'var(--bg-canvas)' }}
>
{inferenceLog.length > 99 ? '99' : inferenceLog.length}
</span>
)}
</button>
{/* F29/F31: Settings popover toggle */}
<div className="relative">
<button
onClick={() => setShowSettings(s => !s)}
className="p-2 rounded-lg transition-colors"
style={{
background: showSettings ? 'var(--accent-primary)' : 'var(--surface-card)',
border: '1px solid var(--border-subtle)',
color: showSettings ? 'white' : 'var(--text-secondary)',
}}
title="Dashboard settings"
>
<Settings className="w-4 h-4" />
</button>
{showSettings && (
<div
className="absolute right-0 top-12 w-60 p-4 rounded-lg shadow-xl z-50 space-y-3"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border-default)',
}}
>
<h3 className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Settings
</h3>
<button
onClick={exportSettings}
className="w-full text-left text-xs px-3 py-2 rounded transition-colors"
style={{ background: 'var(--surface-muted)', color: 'var(--text-secondary)' }}
>
Export settings (JSON)
</button>
<label
className="block w-full text-left text-xs px-3 py-2 rounded transition-colors cursor-pointer"
style={{ background: 'var(--surface-muted)', color: 'var(--text-secondary)' }}
>
Import settings...
<input
type="file"
accept=".json"
className="hidden"
onChange={e => {
const f = e.target.files?.[0];
if (f) importSettings(f);
e.target.value = '';
setShowSettings(false);
}}
/>
</label>
<hr style={{ borderColor: 'var(--border-subtle)' }} />
<button
onClick={() => {
if (
confirm(
'This will clear all tags, history, benchmarks, and preferences. Continue?'
)
) {
factoryReset();
}
}}
className="w-full text-left text-xs px-3 py-2 rounded transition-colors"
style={{ background: 'rgba(255,110,110,0.1)', color: 'var(--danger)' }}
>
Reset all dashboard data
</button>
</div>
)}
</div>
<button
onClick={toggleTheme}
className="p-2 rounded-lg transition-colors"
@ -786,6 +970,105 @@ export default function Dashboard() {
))}
</div>
{/* F30: Inference History Log Panel */}
{showInferenceLog && (
<div
className="mb-6 card p-4"
style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border-default)' }}
>
<div className="flex items-center justify-between mb-3">
<h3
className="text-sm font-semibold flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<FileText className="w-4 h-4" style={{ color: 'var(--accent-primary)' }} />
Inference History ({inferenceLog.length})
</h3>
<div className="flex items-center gap-2">
<input
type="text"
value={inferenceSearch}
onChange={e => setInferenceSearch(e.target.value)}
placeholder="Search..."
className="px-2 py-1 rounded text-xs font-mono focus:outline-none"
style={{
background: 'var(--surface-muted)',
border: '1px solid var(--border-subtle)',
color: 'var(--text-primary)',
width: '160px',
}}
/>
<button
onClick={() => setShowInferenceLog(false)}
className="p-1 rounded"
style={{ color: 'var(--text-tertiary)' }}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="max-h-60 overflow-y-auto space-y-2">
{inferenceLog
.filter(
e =>
!inferenceSearch ||
e.prompt.toLowerCase().includes(inferenceSearch.toLowerCase()) ||
e.model.toLowerCase().includes(inferenceSearch.toLowerCase())
)
.map((entry, i) => (
<div
key={i}
className="p-2 rounded text-xs"
style={{ background: 'var(--surface-muted)' }}
>
<div className="flex items-center justify-between mb-1">
<span
className="font-mono font-medium"
style={{ color: 'var(--accent-primary)' }}
>
{entry.model}
</span>
<span style={{ color: 'var(--text-tertiary)' }}>
{entry.tokPerSec ? `${entry.tokPerSec.toFixed(1)} tok/s · ` : ''}
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
<p
className="truncate"
style={{ color: 'var(--text-secondary)' }}
title={entry.prompt}
>
<strong>Q:</strong> {entry.prompt}
</p>
<p
className="truncate mt-0.5"
style={{ color: 'var(--text-tertiary)' }}
title={entry.response}
>
<strong>A:</strong> {entry.response.slice(0, 200)}
</p>
<button
onClick={() => {
setPromptModel(entry.model);
setPromptText(entry.prompt);
setShowInferenceLog(false);
}}
className="text-[10px] mt-1 px-2 py-0.5 rounded transition-colors"
style={{ background: 'var(--surface-card)', color: 'var(--accent-secondary)' }}
>
Replay
</button>
</div>
))}
{inferenceLog.length === 0 && (
<p className="text-center py-4 text-xs" style={{ color: 'var(--text-tertiary)' }}>
No inference history yet. Send a prompt to start recording.
</p>
)}
</div>
</div>
)}
{/* Top Stats Row (CQ5: skeleton loading when data not yet loaded) */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4 mb-6">
{loading && !ollama ? (