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:
parent
44ad8a6301
commit
07d391101a
@ -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 ? (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user