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,
|
Tag,
|
||||||
Star,
|
Star,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type {
|
import type {
|
||||||
OllamaData,
|
OllamaData,
|
||||||
@ -116,6 +117,18 @@ export default function Dashboard() {
|
|||||||
>({});
|
>({});
|
||||||
const [countdownTick, setCountdownTick] = useState(0);
|
const [countdownTick, setCountdownTick] = useState(0);
|
||||||
const [visionImages, setVisionImages] = useState<string[]>([]);
|
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 responseRef = useRef<HTMLDivElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const compareAbortRef = useRef<AbortController | null>(null);
|
const compareAbortRef = useRef<AbortController | null>(null);
|
||||||
@ -179,6 +192,12 @@ export default function Dashboard() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const savedLog = localStorage.getItem('llm-inference-log');
|
||||||
|
if (savedLog) setInferenceLog(JSON.parse(savedLog));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -575,6 +594,21 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!fullResponse) setPromptResponse('(empty response)');
|
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) {
|
} catch (err) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
// User cancelled — keep partial response
|
// User cancelled — keep partial response
|
||||||
@ -592,6 +626,67 @@ export default function Dashboard() {
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
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)
|
// Chat mode streaming (F4)
|
||||||
const handleChat = async () => {
|
const handleChat = async () => {
|
||||||
if (!promptModel || !promptText.trim()) return;
|
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)' }}>
|
<span className="text-xs hidden sm:inline" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
Last refresh: {lastRefresh.toLocaleTimeString()}
|
Last refresh: {lastRefresh.toLocaleTimeString()}
|
||||||
</span>
|
</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
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="p-2 rounded-lg transition-colors"
|
className="p-2 rounded-lg transition-colors"
|
||||||
@ -786,6 +970,105 @@ export default function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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) */}
|
{/* 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">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||||
{loading && !ollama ? (
|
{loading && !ollama ? (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user