feat(local-llm): Sprint 4 — UX enhancements (F2, F3, F9, F11)

New features:
- F2: Model search/filter — search input above models list (shown when
  4+ models installed). Filters by name, family, and quantization level.
  Press / to focus the search input.
- F3: Prompt history — saves last 20 prompts to localStorage with model
  name and timestamp. History dropdown in prompt modal with one-click
  re-run. Toggle via clock icon in textarea.
- F9: Modelfile viewer — expanded model details now fetch and display
  the Modelfile via the show action. Collapsible <details> element
  with syntax-highlighted pre block.
- F11: Keyboard shortcuts panel — press ? to toggle. Shows all shortcuts:
  ? (help), R (refresh), / (search), Esc (close/cancel), Cmd+Enter (send).
  Shortcuts only fire when not in an input field.
This commit is contained in:
saravanakumardb1 2026-02-19 15:25:43 -08:00
parent 40c40756ed
commit 9c2f5f3396

View File

@ -26,6 +26,10 @@ import {
Plus, Plus,
Send, Send,
Terminal, Terminal,
Search,
History,
Keyboard,
FileText,
} from 'lucide-react'; } from 'lucide-react';
import type { import type {
OllamaData, OllamaData,
@ -58,6 +62,10 @@ export default function Dashboard() {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [streamMetrics, setStreamMetrics] = useState<StreamMetrics | null>(null); const [streamMetrics, setStreamMetrics] = useState<StreamMetrics | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [modelSearch, setModelSearch] = useState('');
const [showHistory, setShowHistory] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [modelfileData, setModelfileData] = useState<Record<string, string>>({});
const responseRef = useRef<HTMLDivElement>(null); const responseRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@ -91,10 +99,49 @@ export default function Dashboard() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchAll, promptLoading, pullLoading]); }, [fetchAll, promptLoading, pullLoading]);
// Escape key closes modals (respects streaming state) // Prompt history helpers (F3)
const getPromptHistory = (): Array<{ prompt: string; model: string; ts: number }> => {
try {
return JSON.parse(localStorage.getItem('llm-prompt-history') || '[]');
} catch {
return [];
}
};
const saveToHistory = (prompt: string, model: string) => {
const history = getPromptHistory().filter(h => h.prompt !== prompt || h.model !== model);
history.unshift({ prompt, model, ts: Date.now() });
localStorage.setItem('llm-prompt-history', JSON.stringify(history.slice(0, 20)));
};
// Fetch modelfile for expanded model (F9)
const fetchModelfile = async (modelName: string) => {
if (modelfileData[modelName]) return;
try {
const res = await fetch('/api/ollama', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'show', model: modelName }),
});
const data = await res.json();
if (data.modelfile) {
setModelfileData(prev => ({ ...prev, [modelName]: data.modelfile }));
}
} catch {
/* ignore */
}
};
// Keyboard shortcuts (F11) + Escape handler
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const inInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (showShortcuts) {
setShowShortcuts(false);
return;
}
if (promptLoading) { if (promptLoading) {
abortRef.current?.abort(); abortRef.current?.abort();
setPromptLoading(false); setPromptLoading(false);
@ -104,11 +151,27 @@ export default function Dashboard() {
setPromptText(''); setPromptText('');
} }
setDeleteConfirm(null); setDeleteConfirm(null);
return;
}
if (inInput) return;
if (e.key === '?') {
setShowShortcuts(s => !s);
return;
}
if (e.key === 'r' || e.key === 'R') {
fetchAll();
return;
}
if (e.key === '/') {
e.preventDefault();
setModelSearch('');
document.querySelector<HTMLInputElement>('[data-model-search]')?.focus();
return;
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [promptLoading]); }, [promptLoading, showShortcuts, fetchAll]);
const handleModelAction = async (action: string, model: string) => { const handleModelAction = async (action: string, model: string) => {
setActionLoading(`${action}-${model}`); setActionLoading(`${action}-${model}`);
@ -193,6 +256,7 @@ export default function Dashboard() {
// Streaming prompt // Streaming prompt
const handlePrompt = async () => { const handlePrompt = async () => {
if (!promptModel || !promptText.trim()) return; if (!promptModel || !promptText.trim()) return;
saveToHistory(promptText.trim(), promptModel);
setPromptLoading(true); setPromptLoading(true);
setPromptResponse(''); setPromptResponse('');
setStreamMetrics(null); setStreamMetrics(null);
@ -433,6 +497,30 @@ export default function Dashboard() {
)} )}
</div> </div>
{/* Model Search (F2) */}
{ollama?.status === 'online' && ollama.models.length > 3 && (
<div className="relative mb-3">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'var(--text-tertiary)' }}
/>
<input
data-model-search
type="text"
value={modelSearch}
onChange={e => setModelSearch(e.target.value)}
placeholder="Filter models... (press / to focus)"
className="w-full pl-9 pr-3 py-2 rounded-lg text-sm font-mono focus:outline-none focus:ring-2"
style={{
background: 'var(--surface-muted)',
border: '1px solid var(--border-subtle)',
color: 'var(--text-primary)',
caretColor: 'var(--accent-primary)',
}}
/>
</div>
)}
{/* Pull Model Input */} {/* Pull Model Input */}
{ollama?.status === 'online' && ( {ollama?.status === 'online' && (
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
@ -508,7 +596,15 @@ export default function Dashboard() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{ollama.models.map(model => { {ollama.models
.filter(
m =>
!modelSearch ||
m.name.toLowerCase().includes(modelSearch.toLowerCase()) ||
m.details?.family?.toLowerCase().includes(modelSearch.toLowerCase()) ||
m.details?.quantization_level?.toLowerCase().includes(modelSearch.toLowerCase())
)
.map(model => {
const running = isRunning(model.name); const running = isRunning(model.name);
const expanded = expandedModel === model.name; const expanded = expandedModel === model.name;
return ( return (
@ -530,7 +626,10 @@ export default function Dashboard() {
{running ? ( {running ? (
<Zap className="w-4 h-4" style={{ color: 'var(--success)' }} /> <Zap className="w-4 h-4" style={{ color: 'var(--success)' }} />
) : ( ) : (
<Brain className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> <Brain
className="w-4 h-4"
style={{ color: 'var(--text-tertiary)' }}
/>
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@ -613,7 +712,11 @@ export default function Dashboard() {
</button> </button>
)} )}
<button <button
onClick={() => setExpandedModel(expanded ? null : model.name)} onClick={() => {
const next = expanded ? null : model.name;
setExpandedModel(next);
if (next) fetchModelfile(model.name);
}}
className="p-1.5 rounded-md transition-colors" className="p-1.5 rounded-md transition-colors"
style={{ color: 'var(--text-tertiary)' }} style={{ color: 'var(--text-tertiary)' }}
> >
@ -634,9 +737,13 @@ export default function Dashboard() {
className="flex items-center gap-4 mt-2 text-[11px]" className="flex items-center gap-4 mt-2 text-[11px]"
style={{ color: 'var(--text-tertiary)' }} style={{ color: 'var(--text-tertiary)' }}
> >
{info.size_vram > 0 && <span>VRAM: {formatBytes(info.size_vram)}</span>} {info.size_vram > 0 && (
<span>VRAM: {formatBytes(info.size_vram)}</span>
)}
{info.expires_at && ( {info.expires_at && (
<span>Expires: {new Date(info.expires_at).toLocaleTimeString()}</span> <span>
Expires: {new Date(info.expires_at).toLocaleTimeString()}
</span>
)} )}
</div> </div>
) : null; ) : null;
@ -652,6 +759,25 @@ export default function Dashboard() {
<p>Digest: {model.digest?.substring(0, 16)}...</p> <p>Digest: {model.digest?.substring(0, 16)}...</p>
<p>Modified: {new Date(model.modified_at).toLocaleString()}</p> <p>Modified: {new Date(model.modified_at).toLocaleString()}</p>
{model.details?.family && <p>Family: {model.details.family}</p>} {model.details?.family && <p>Family: {model.details.family}</p>}
{modelfileData[model.name] && (
<details className="mt-2">
<summary
className="cursor-pointer flex items-center gap-1"
style={{ color: 'var(--accent-secondary)' }}
>
<FileText className="w-3 h-3" /> Modelfile
</summary>
<pre
className="mt-1 p-2 rounded text-[11px] max-h-40 overflow-auto whitespace-pre-wrap"
style={{
background: 'var(--bg-canvas)',
color: 'var(--text-tertiary)',
}}
>
{modelfileData[model.name]}
</pre>
</details>
)}
<div <div
className="mt-3 pt-2" className="mt-3 pt-2"
style={{ borderTop: '1px solid var(--border-subtle)' }} style={{ borderTop: '1px solid var(--border-subtle)' }}
@ -987,13 +1113,53 @@ export default function Dashboard() {
</button> </button>
</div> </div>
</div> </div>
{/* Prompt History (F3) */}
{showHistory && (
<div
className="mb-3 max-h-40 overflow-y-auto rounded-lg"
style={{
background: 'var(--surface-card)',
border: '1px solid var(--border-subtle)',
}}
>
{getPromptHistory().length === 0 ? (
<p className="text-xs p-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
No history yet
</p>
) : (
getPromptHistory().map((h, i) => (
<button
key={i}
onClick={() => {
setPromptText(h.prompt);
setShowHistory(false);
}}
className="w-full text-left px-3 py-2 text-xs hover:bg-[var(--surface-muted)] transition-colors flex items-center justify-between gap-2"
style={{
color: 'var(--text-secondary)',
borderBottom: '1px solid var(--border-subtle)',
}}
>
<span className="truncate flex-1">{h.prompt}</span>
<span
className="text-[10px] shrink-0"
style={{ color: 'var(--text-tertiary)' }}
>
{h.model}
</span>
</button>
))
)}
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex-1 relative">
<textarea <textarea
value={promptText} value={promptText}
onChange={e => setPromptText(e.target.value)} onChange={e => setPromptText(e.target.value)}
placeholder="Type your prompt here..." placeholder="Type your prompt here..."
rows={2} rows={2}
className="flex-1 p-3 rounded-lg text-sm resize-none focus:outline-none focus:ring-2" className="w-full p-3 pr-10 rounded-lg text-sm resize-none focus:outline-none focus:ring-2"
style={{ style={{
background: 'var(--surface-card)', background: 'var(--surface-card)',
border: '1px solid var(--border-subtle)', border: '1px solid var(--border-subtle)',
@ -1005,6 +1171,15 @@ export default function Dashboard() {
}} }}
disabled={promptLoading} disabled={promptLoading}
/> />
<button
onClick={() => setShowHistory(s => !s)}
className="absolute right-2 top-2 p-1 rounded transition-colors"
style={{ color: showHistory ? 'var(--accent-primary)' : 'var(--text-tertiary)' }}
title="Prompt history"
>
<History className="w-4 h-4" />
</button>
</div>
<button <button
onClick={handlePrompt} onClick={handlePrompt}
disabled={promptLoading || !promptText.trim()} disabled={promptLoading || !promptText.trim()}
@ -1070,6 +1245,57 @@ export default function Dashboard() {
</div> </div>
</div> </div>
)} )}
{/* Keyboard Shortcuts Modal (F11) */}
{showShortcuts && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.6)' }}
onClick={e => {
if (e.target === e.currentTarget) setShowShortcuts(false);
}}
>
<div
className="card w-full max-w-sm p-6"
style={{ background: 'var(--bg-elevated)', border: '1px solid var(--border-default)' }}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Keyboard className="w-5 h-5" style={{ color: 'var(--accent-primary)' }} />
Keyboard Shortcuts
</h3>
<button
onClick={() => setShowShortcuts(false)}
className="p-1.5 rounded-lg"
style={{ color: 'var(--text-tertiary)' }}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-2">
{[
['?', 'Toggle this panel'],
['R', 'Refresh all data'],
['/', 'Focus model search'],
['Esc', 'Close modal / cancel stream'],
['⌘+Enter', 'Send prompt'],
].map(([key, desc]) => (
<div key={key} className="flex items-center justify-between py-1.5">
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{desc}
</span>
<kbd
className="text-xs font-mono px-2 py-1 rounded"
style={{ background: 'var(--surface-muted)', color: 'var(--text-primary)' }}
>
{key}
</kbd>
</div>
))}
</div>
</div>
</div>
)}
</div> </div>
); );
} }