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,192 +596,230 @@ export default function Dashboard() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{ollama.models.map(model => { {ollama.models
const running = isRunning(model.name); .filter(
const expanded = expandedModel === model.name; m =>
return ( !modelSearch ||
<div m.name.toLowerCase().includes(modelSearch.toLowerCase()) ||
key={model.name} m.details?.family?.toLowerCase().includes(modelSearch.toLowerCase()) ||
className="rounded-lg p-4 transition-colors" m.details?.quantization_level?.toLowerCase().includes(modelSearch.toLowerCase())
style={{ )
background: running ? 'rgba(52, 211, 153, 0.05)' : 'var(--surface-muted)', .map(model => {
border: running const running = isRunning(model.name);
? '1px solid rgba(52, 211, 153, 0.2)' const expanded = expandedModel === model.name;
: '1px solid transparent', return (
}} <div
> key={model.name}
<div className="flex items-center justify-between"> className="rounded-lg p-4 transition-colors"
<div className="flex items-center gap-3 flex-1 min-w-0"> style={{
<div background: running ? 'rgba(52, 211, 153, 0.05)' : 'var(--surface-muted)',
className={`w-8 h-8 rounded-lg flex items-center justify-center ${running ? 'bg-[rgba(52,211,153,0.15)]' : 'bg-[var(--surface-card)]'}`} border: running
> ? '1px solid rgba(52, 211, 153, 0.2)'
{running ? ( : '1px solid transparent',
<Zap className="w-4 h-4" style={{ color: 'var(--success)' }} /> }}
) : ( >
<Brain className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> <div className="flex items-center justify-between">
)} <div className="flex items-center gap-3 flex-1 min-w-0">
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center ${running ? 'bg-[rgba(52,211,153,0.15)]' : 'bg-[var(--surface-card)]'}`}
>
{running ? (
<Zap className="w-4 h-4" style={{ color: 'var(--success)' }} />
) : (
<Brain
className="w-4 h-4"
style={{ color: 'var(--text-tertiary)' }}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold truncate">
{model.name}
</span>
{running && (
<span
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
style={{
background: 'rgba(52, 211, 153, 0.15)',
color: 'var(--success)',
}}
>
LOADED
</span>
)}
</div>
<div
className="flex items-center gap-3 text-xs mt-0.5"
style={{ color: 'var(--text-tertiary)' }}
>
<span>{formatBytes(model.size)}</span>
{model.details?.parameter_size && (
<span>{model.details.parameter_size}</span>
)}
{model.details?.quantization_level && (
<span>{model.details.quantization_level}</span>
)}
</div>
</div>
</div> </div>
<div className="min-w-0 flex-1"> <div className="flex items-center gap-2 ml-3">
<div className="flex items-center gap-2"> {running ? (
<span className="font-mono text-sm font-semibold truncate"> <>
{model.name} <button
</span> onClick={() => {
{running && ( setPromptModel(model.name);
<span setPromptResponse('');
className="text-[10px] px-1.5 py-0.5 rounded font-medium" }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
style={{ background: 'var(--accent-primary)', color: 'white' }}
>
<Zap className="w-3 h-3" /> Prompt
</button>
<button
onClick={() => handleModelAction('unload', model.name)}
disabled={actionLoading === `unload-${model.name}`}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
style={{ style={{
background: 'rgba(52, 211, 153, 0.15)', background: 'rgba(255, 110, 110, 0.1)',
color: 'var(--success)', color: 'var(--danger)',
}} }}
> >
LOADED {actionLoading === `unload-${model.name}` ? (
</span> <RefreshCw className="w-3 h-3 animate-spin" />
)} ) : (
</div> <Square className="w-3 h-3" />
<div )}
className="flex items-center gap-3 text-xs mt-0.5" Unload
style={{ color: 'var(--text-tertiary)' }} </button>
> </>
<span>{formatBytes(model.size)}</span> ) : (
{model.details?.parameter_size && (
<span>{model.details.parameter_size}</span>
)}
{model.details?.quantization_level && (
<span>{model.details.quantization_level}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-3">
{running ? (
<>
<button <button
onClick={() => { onClick={() => handleModelAction('load', model.name)}
setPromptModel(model.name); disabled={actionLoading === `load-${model.name}`}
setPromptResponse('');
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
style={{ background: 'var(--accent-primary)', color: 'white' }}
>
<Zap className="w-3 h-3" /> Prompt
</button>
<button
onClick={() => handleModelAction('unload', model.name)}
disabled={actionLoading === `unload-${model.name}`}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
style={{ style={{
background: 'rgba(255, 110, 110, 0.1)', background: 'rgba(52, 211, 153, 0.1)',
color: 'var(--danger)', color: 'var(--success)',
}} }}
> >
{actionLoading === `unload-${model.name}` ? ( {actionLoading === `load-${model.name}` ? (
<RefreshCw className="w-3 h-3 animate-spin" /> <RefreshCw className="w-3 h-3 animate-spin" />
) : ( ) : (
<Square className="w-3 h-3" /> <Play className="w-3 h-3" />
)} )}
Unload Load
</button> </button>
</>
) : (
<button
onClick={() => handleModelAction('load', model.name)}
disabled={actionLoading === `load-${model.name}`}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
style={{
background: 'rgba(52, 211, 153, 0.1)',
color: 'var(--success)',
}}
>
{actionLoading === `load-${model.name}` ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Play className="w-3 h-3" />
)}
Load
</button>
)}
<button
onClick={() => setExpandedModel(expanded ? null : model.name)}
className="p-1.5 rounded-md transition-colors"
style={{ color: 'var(--text-tertiary)' }}
>
{expanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)} )}
</button> <button
</div> onClick={() => {
</div> const next = expanded ? null : model.name;
{/* Running model VRAM info */} setExpandedModel(next);
{running && if (next) fetchModelfile(model.name);
(() => { }}
const info = getRunningInfo(model.name); className="p-1.5 rounded-md transition-colors"
return info ? (
<div
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>} {expanded ? (
{info.expires_at && ( <ChevronUp className="w-4 h-4" />
<span>Expires: {new Date(info.expires_at).toLocaleTimeString()}</span> ) : (
<ChevronDown className="w-4 h-4" />
)} )}
</div> </button>
) : null;
})()}
{expanded && (
<div
className="mt-3 pt-3 text-xs font-mono space-y-1"
style={{
borderTop: '1px solid var(--border-subtle)',
color: 'var(--text-tertiary)',
}}
>
<p>Digest: {model.digest?.substring(0, 16)}...</p>
<p>Modified: {new Date(model.modified_at).toLocaleString()}</p>
{model.details?.family && <p>Family: {model.details.family}</p>}
<div
className="mt-3 pt-2"
style={{ borderTop: '1px solid var(--border-subtle)' }}
>
{deleteConfirm === model.name ? (
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: 'var(--danger)' }}>
Delete this model?
</span>
<button
onClick={() => handleModelAction('delete', model.name)}
disabled={actionLoading === `delete-${model.name}`}
className="px-2 py-1 rounded text-xs font-medium"
style={{ background: 'var(--danger)', color: 'white' }}
>
{actionLoading === `delete-${model.name}`
? 'Deleting...'
: 'Yes, delete'}
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="px-2 py-1 rounded text-xs"
style={{ color: 'var(--text-tertiary)' }}
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(model.name)}
className="flex items-center gap-1.5 text-xs transition-colors"
style={{ color: 'var(--danger)' }}
>
<Trash2 className="w-3 h-3" /> Remove from disk
</button>
)}
</div> </div>
</div> </div>
)} {/* Running model VRAM info */}
</div> {running &&
); (() => {
})} const info = getRunningInfo(model.name);
return info ? (
<div
className="flex items-center gap-4 mt-2 text-[11px]"
style={{ color: 'var(--text-tertiary)' }}
>
{info.size_vram > 0 && (
<span>VRAM: {formatBytes(info.size_vram)}</span>
)}
{info.expires_at && (
<span>
Expires: {new Date(info.expires_at).toLocaleTimeString()}
</span>
)}
</div>
) : null;
})()}
{expanded && (
<div
className="mt-3 pt-3 text-xs font-mono space-y-1"
style={{
borderTop: '1px solid var(--border-subtle)',
color: 'var(--text-tertiary)',
}}
>
<p>Digest: {model.digest?.substring(0, 16)}...</p>
<p>Modified: {new Date(model.modified_at).toLocaleString()}</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
className="mt-3 pt-2"
style={{ borderTop: '1px solid var(--border-subtle)' }}
>
{deleteConfirm === model.name ? (
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: 'var(--danger)' }}>
Delete this model?
</span>
<button
onClick={() => handleModelAction('delete', model.name)}
disabled={actionLoading === `delete-${model.name}`}
className="px-2 py-1 rounded text-xs font-medium"
style={{ background: 'var(--danger)', color: 'white' }}
>
{actionLoading === `delete-${model.name}`
? 'Deleting...'
: 'Yes, delete'}
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="px-2 py-1 rounded text-xs"
style={{ color: 'var(--text-tertiary)' }}
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(model.name)}
className="flex items-center gap-1.5 text-xs transition-colors"
style={{ color: 'var(--danger)' }}
>
<Trash2 className="w-3 h-3" /> Remove from disk
</button>
)}
</div>
</div>
)}
</div>
);
})}
{ollama.models.length === 0 && ( {ollama.models.length === 0 && (
<p className="text-center py-8 text-sm" style={{ color: 'var(--text-tertiary)' }}> <p className="text-center py-8 text-sm" style={{ color: 'var(--text-tertiary)' }}>
No models installed. Run &quot;ollama pull &lt;model&gt;&quot; to get started. No models installed. Run &quot;ollama pull &lt;model&gt;&quot; to get started.
@ -987,24 +1113,73 @@ export default function Dashboard() {
</button> </button>
</div> </div>
</div> </div>
<div className="flex gap-2"> {/* Prompt History (F3) */}
<textarea {showHistory && (
value={promptText} <div
onChange={e => setPromptText(e.target.value)} className="mb-3 max-h-40 overflow-y-auto rounded-lg"
placeholder="Type your prompt here..."
rows={2}
className="flex-1 p-3 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)',
color: 'var(--text-primary)',
caretColor: 'var(--accent-primary)',
}} }}
onKeyDown={e => { >
if (e.key === 'Enter' && e.metaKey) handlePrompt(); {getPromptHistory().length === 0 ? (
}} <p className="text-xs p-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
disabled={promptLoading} 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-1 relative">
<textarea
value={promptText}
onChange={e => setPromptText(e.target.value)}
placeholder="Type your prompt here..."
rows={2}
className="w-full p-3 pr-10 rounded-lg text-sm resize-none focus:outline-none focus:ring-2"
style={{
background: 'var(--surface-card)',
border: '1px solid var(--border-subtle)',
color: 'var(--text-primary)',
caretColor: 'var(--accent-primary)',
}}
onKeyDown={e => {
if (e.key === 'Enter' && e.metaKey) handlePrompt();
}}
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>
); );
} }