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:
parent
40c40756ed
commit
9c2f5f3396
@ -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 "ollama pull <model>" to get started.
|
No models installed. Run "ollama pull <model>" 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user