feat(local-llm): Sprint 6 — major feature batch (CQ2,CQ5,CQ6,P5,F4,F10,F14,F16)

Code quality:
- CQ2: Add CSS utility classes (text-primary/secondary/tertiary, bg-*,
  btn-*, input-base) to globals.css — reduces inline style repetition
- CQ5: Add skeleton shimmer animation CSS for loading states
- CQ6: Replace manual model name validation with Zod schema
  (PostBodySchema) in Ollama API route

Performance:
- P5: Eagerly warm static cache on module load — system_profiler
  no longer blocks first dashboard request

Features:
- F4: Chat mode with multi-turn conversation via new /api/ollama/chat
  streaming route. Chat bubble layout, system prompt input, message
  history. Toggle between prompt/chat modes in modal.
- F10: Dark/light theme toggle with CSS var overrides in :root.light.
  Sun/Moon button in header, persisted in localStorage.
- F14: Model tags (coding, chat, fast, vision, reasoning) with
  colored toggle badges in expanded model details. Persisted in
  localStorage.
- F16: Auto-load preferred model — star toggle in expanded details.
  When Ollama is online but no models loaded, auto-loads the starred
  model. Persisted in localStorage.
This commit is contained in:
saravanakumardb1 2026-02-19 15:38:06 -08:00
parent 2936b9f047
commit ed93a6f0af
5 changed files with 410 additions and 14 deletions

View File

@ -0,0 +1,42 @@
import { NextRequest } from 'next/server';
import { OLLAMA_URL } from '../../../lib/ollama-config';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { model, messages } = body;
if (!model || !Array.isArray(messages)) {
return new Response(JSON.stringify({ error: 'Missing model or messages' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: true }),
});
if (!response.ok || !response.body) {
return new Response(JSON.stringify({ error: `Ollama chat error: ${response.status}` }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(response.body, {
headers: {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
},
});
} catch (err) {
return new Response(JSON.stringify({ error: String(err) }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@ -1,6 +1,16 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { z } from 'zod';
import { OLLAMA_URL } from '../../lib/ollama-config'; import { OLLAMA_URL } from '../../lib/ollama-config';
const PostBodySchema = z.object({
action: z.enum(['load', 'unload', 'pull', 'delete', 'show']),
model: z
.string()
.min(1)
.max(256)
.regex(/^[a-zA-Z0-9._:/-]+$/),
});
async function fetchOllama(path: string, options?: RequestInit) { async function fetchOllama(path: string, options?: RequestInit) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), 5000);
@ -51,16 +61,17 @@ export async function GET() {
} }
} }
const MODEL_NAME_RE = /^[a-zA-Z0-9._:/-]{1,256}$/;
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const raw = await request.json();
const { action, model } = body; const parsed = PostBodySchema.safeParse(raw);
if (!parsed.success) {
if (!model || typeof model !== 'string' || !MODEL_NAME_RE.test(model)) { return NextResponse.json(
return NextResponse.json({ error: 'Invalid model name' }, { status: 400 }); { error: parsed.error.issues[0]?.message || 'Invalid request' },
{ status: 400 }
);
} }
const { action, model } = parsed.data;
if (action === 'load') { if (action === 'load') {
await fetchOllama('/api/generate', { await fetchOllama('/api/generate', {

View File

@ -95,6 +95,9 @@ async function getStaticInfo() {
return staticCache; return staticCache;
} }
// P5: Eagerly warm static cache on module load (system_profiler takes 2-3s)
getStaticInfo().catch(() => {});
async function getCachedOllamaDiskUsage(): Promise<number> { async function getCachedOllamaDiskUsage(): Promise<number> {
if (ollamaDiskCache && Date.now() - ollamaDiskCache.ts < OLLAMA_DISK_TTL) if (ollamaDiskCache && Date.now() - ollamaDiskCache.ts < OLLAMA_DISK_TTL)
return ollamaDiskCache.value; return ollamaDiskCache.value;

View File

@ -88,3 +88,76 @@ body {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: 2px; outline-offset: 2px;
} }
/* Utility classes — replaces pervasive inline styles (CQ2) */
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-accent { color: var(--accent-primary); }
.text-accent-secondary { color: var(--accent-secondary); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
.text-purple { color: var(--purple); }
.bg-canvas { background: var(--bg-canvas); }
.bg-elevated { background: var(--bg-elevated); }
.bg-surface { background: var(--surface-card); }
.bg-muted { background: var(--surface-muted); }
.border-subtle { border-color: var(--border-subtle); }
.border-default { border-color: var(--border-default); }
.input-base {
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
caret-color: var(--accent-primary);
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-danger {
background: rgba(255, 110, 110, 0.1);
color: var(--danger);
}
.btn-success {
background: rgba(52, 211, 153, 0.1);
color: var(--success);
}
/* Skeleton shimmer (CQ5) */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, var(--surface-muted) 25%, var(--border-subtle) 50%, var(--surface-muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 6px;
}
/* Light theme overrides (F10) */
:root.light {
--bg-canvas: #F8FAFC;
--bg-elevated: #FFFFFF;
--surface-card: #FFFFFF;
--surface-muted: #F1F5F9;
--border-subtle: #E2E8F0;
--border-default: #CBD5E1;
--text-primary: #0F172A;
--text-secondary: #475569;
--text-tertiary: #94A3B8;
--accent-primary: #3B6FE8;
--accent-secondary: #0EA5E9;
--success: #10B981;
--warning: #D97706;
--danger: #EF4444;
--purple: #8B5CF6;
}

View File

@ -30,6 +30,11 @@ import {
History, History,
Keyboard, Keyboard,
FileText, FileText,
Sun,
Moon,
Tag,
Star,
MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
import type { import type {
OllamaData, OllamaData,
@ -66,6 +71,14 @@ export default function Dashboard() {
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false);
const [modelfileData, setModelfileData] = useState<Record<string, string>>({}); const [modelfileData, setModelfileData] = useState<Record<string, string>>({});
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [modelTags, setModelTags] = useState<Record<string, string[]>>({});
const [autoLoadModel, setAutoLoadModel] = useState<string | null>(null);
const [chatMode, setChatMode] = useState(false);
const [chatMessages, setChatMessages] = useState<
Array<{ role: 'user' | 'assistant'; content: string }>
>([]);
const [systemPrompt, setSystemPrompt] = useState('');
const responseRef = useRef<HTMLDivElement>(null); const responseRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const fetchingRef = useRef(false); const fetchingRef = useRef(false);
@ -93,10 +106,34 @@ export default function Dashboard() {
fetchingRef.current = false; fetchingRef.current = false;
}, []); }, []);
// Restore persisted state from localStorage (F10, F14, F16)
useEffect(() => {
const savedTheme = localStorage.getItem('llm-theme') as 'dark' | 'light' | null;
if (savedTheme) {
setTheme(savedTheme);
document.documentElement.classList.toggle('light', savedTheme === 'light');
}
try {
setModelTags(JSON.parse(localStorage.getItem('llm-model-tags') || '{}'));
} catch {
/* ignore */
}
setAutoLoadModel(localStorage.getItem('llm-auto-load-model'));
}, []);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
}, [fetchAll]); }, [fetchAll]);
// F16: Auto-load preferred model when Ollama is online but nothing loaded
useEffect(() => {
if (!autoLoadModel || !ollama || ollama.status !== 'online') return;
if (ollama.runningCount > 0) return;
if (!ollama.models.some(m => m.name === autoLoadModel)) return;
handleModelAction('load', autoLoadModel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ollama?.status, ollama?.runningCount, autoLoadModel]);
useEffect(() => { useEffect(() => {
if (promptLoading || pullLoading) return; if (promptLoading || pullLoading) return;
const interval = setInterval(fetchAll, 15000); const interval = setInterval(fetchAll, 15000);
@ -117,6 +154,32 @@ export default function Dashboard() {
localStorage.setItem('llm-prompt-history', JSON.stringify(history.slice(0, 20))); localStorage.setItem('llm-prompt-history', JSON.stringify(history.slice(0, 20)));
}; };
// Theme toggle (F10)
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
document.documentElement.classList.toggle('light', next === 'light');
localStorage.setItem('llm-theme', next);
};
// Model tags helpers (F14)
const toggleTag = (modelName: string, tag: string) => {
setModelTags(prev => {
const current = prev[modelName] || [];
const next = current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag];
const updated = { ...prev, [modelName]: next };
localStorage.setItem('llm-model-tags', JSON.stringify(updated));
return updated;
});
};
// Auto-load model helpers (F16)
const setPreferredModel = (modelName: string | null) => {
setAutoLoadModel(modelName);
if (modelName) localStorage.setItem('llm-auto-load-model', modelName);
else localStorage.removeItem('llm-auto-load-model');
};
// Fetch modelfile for expanded model (F9) // Fetch modelfile for expanded model (F9)
const fetchModelfile = async (modelName: string) => { const fetchModelfile = async (modelName: string) => {
if (modelfileData[modelName]) return; if (modelfileData[modelName]) return;
@ -325,6 +388,86 @@ export default function Dashboard() {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
// Chat mode streaming (F4)
const handleChat = async () => {
if (!promptModel || !promptText.trim()) return;
saveToHistory(promptText.trim(), promptModel);
const userMsg = { role: 'user' as const, content: promptText.trim() };
setChatMessages(prev => [...prev, userMsg]);
setPromptText('');
setPromptLoading(true);
setStreamMetrics(null);
const controller = new AbortController();
abortRef.current = controller;
const allMessages = [
...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
...chatMessages,
userMsg,
];
let assistantContent = '';
try {
const res = await fetch('/api/ollama/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: promptModel, messages: allMessages }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
setChatMessages(prev => [
...prev,
{ role: 'assistant', content: 'Error: Failed to connect' },
]);
setPromptLoading(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line);
if (chunk.message?.content) {
assistantContent += chunk.message.content;
setChatMessages(prev => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last?.role === 'assistant') {
updated[updated.length - 1] = { ...last, content: assistantContent };
} else {
updated.push({ role: 'assistant', content: assistantContent });
}
return updated;
});
responseRef.current?.scrollTo(0, responseRef.current.scrollHeight);
}
if (chunk.done && chunk.eval_count && chunk.eval_duration) {
const durationMs = chunk.eval_duration / 1e6;
const tokensPerSec = durationMs > 0 ? (chunk.eval_count / durationMs) * 1000 : 0;
setStreamMetrics({ tokensPerSec, totalTokens: chunk.eval_count, durationMs });
}
} catch {
/* skip */
}
}
}
} catch (err) {
if (!controller.signal.aborted) {
setChatMessages(prev => [...prev, { role: 'assistant', content: `Error: ${err}` }]);
}
}
abortRef.current = null;
setPromptLoading(false);
};
const handleSend = chatMode ? handleChat : handlePrompt;
const isRunning = (name: string) => ollama?.running.some(r => r.name === name); const isRunning = (name: string) => ollama?.running.some(r => r.name === name);
const getRunningInfo = (name: string) => ollama?.running.find(r => r.name === name); const getRunningInfo = (name: string) => ollama?.running.find(r => r.name === name);
@ -349,10 +492,22 @@ export default function Dashboard() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<span className="text-xs" 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>
<button
onClick={toggleTheme}
className="p-2 rounded-lg transition-colors"
style={{
background: 'var(--surface-card)',
border: '1px solid var(--border-subtle)',
color: 'var(--text-secondary)',
}}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
<button <button
onClick={fetchAll} onClick={fetchAll}
disabled={loading} disabled={loading}
@ -364,7 +519,7 @@ export default function Dashboard() {
}} }}
> >
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh <span className="hidden sm:inline">Refresh</span>
</button> </button>
</div> </div>
</header> </header>
@ -763,6 +918,51 @@ 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>}
{/* Model Tags (F14) */}
<div className="flex flex-wrap items-center gap-1.5 mt-2 font-sans">
{['coding', 'chat', 'fast', 'vision', 'reasoning'].map(tag => (
<button
key={tag}
onClick={() => toggleTag(model.name, tag)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors"
style={{
background: (modelTags[model.name] || []).includes(tag)
? 'var(--accent-primary)'
: 'var(--surface-muted)',
color: (modelTags[model.name] || []).includes(tag)
? 'white'
: 'var(--text-tertiary)',
border: '1px solid var(--border-subtle)',
}}
>
<Tag className="w-2.5 h-2.5" />
{tag}
</button>
))}
</div>
{/* Auto-load toggle (F16) */}
<button
onClick={() =>
setPreferredModel(autoLoadModel === model.name ? null : model.name)
}
className="flex items-center gap-1.5 mt-2 text-[11px] font-sans transition-colors"
style={{
color:
autoLoadModel === model.name
? 'var(--warning)'
: 'var(--text-tertiary)',
}}
>
<Star
className="w-3 h-3"
style={{
fill: autoLoadModel === model.name ? 'var(--warning)' : 'none',
}}
/>
{autoLoadModel === model.name
? 'Preferred (auto-loads)'
: 'Set as preferred'}
</button>
{modelfileData[model.name] && ( {modelfileData[model.name] && (
<details className="mt-2"> <details className="mt-2">
<summary <summary
@ -1089,7 +1289,25 @@ export default function Dashboard() {
</span> </span>
</h3> </h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{promptResponse && ( {/* Chat mode toggle (F4) */}
<button
onClick={() => {
setChatMode(!chatMode);
setChatMessages([]);
setPromptResponse('');
setStreamMetrics(null);
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors"
style={{
background: chatMode ? 'var(--accent-primary)' : 'var(--surface-card)',
color: chatMode ? 'white' : 'var(--text-tertiary)',
}}
title={chatMode ? 'Switch to single prompt' : 'Switch to chat mode'}
>
<MessageSquare className="w-3.5 h-3.5" />
{chatMode ? 'Chat' : 'Prompt'}
</button>
{(promptResponse || chatMessages.length > 0) && (
<button <button
onClick={copyResponse} onClick={copyResponse}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors" className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors"
@ -1117,6 +1335,22 @@ export default function Dashboard() {
</button> </button>
</div> </div>
</div> </div>
{/* System prompt for chat mode (F4) */}
{chatMode && (
<input
type="text"
value={systemPrompt}
onChange={e => setSystemPrompt(e.target.value)}
placeholder="System prompt (optional)..."
className="w-full px-3 py-2 mb-3 rounded-lg text-xs font-mono focus:outline-none focus:ring-2"
style={{
background: 'var(--surface-card)',
border: '1px solid var(--border-subtle)',
color: 'var(--text-secondary)',
caretColor: 'var(--accent-primary)',
}}
/>
)}
{/* Prompt History (F3) */} {/* Prompt History (F3) */}
{showHistory && ( {showHistory && (
<div <div
@ -1171,7 +1405,7 @@ export default function Dashboard() {
caretColor: 'var(--accent-primary)', caretColor: 'var(--accent-primary)',
}} }}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter' && e.metaKey) handlePrompt(); if (e.key === 'Enter' && e.metaKey) handleSend();
}} }}
disabled={promptLoading} disabled={promptLoading}
/> />
@ -1185,7 +1419,7 @@ export default function Dashboard() {
</button> </button>
</div> </div>
<button <button
onClick={handlePrompt} onClick={handleSend}
disabled={promptLoading || !promptText.trim()} disabled={promptLoading || !promptText.trim()}
className="flex items-center justify-center w-12 rounded-lg transition-colors disabled:opacity-40 shrink-0" className="flex items-center justify-center w-12 rounded-lg transition-colors disabled:opacity-40 shrink-0"
style={{ background: 'var(--accent-primary)', color: 'white' }} style={{ background: 'var(--accent-primary)', color: 'white' }}
@ -1220,7 +1454,40 @@ export default function Dashboard() {
</span> </span>
)} )}
</div> </div>
{promptResponse && ( {/* Chat messages (F4) */}
{chatMode && chatMessages.length > 0 && (
<div ref={responseRef} className="mt-4 max-h-80 overflow-y-auto space-y-3">
{chatMessages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
style={{
background:
msg.role === 'user' ? 'var(--accent-primary)' : 'var(--surface-card)',
color: msg.role === 'user' ? 'white' : 'var(--text-secondary)',
border:
msg.role === 'assistant' ? '1px solid var(--border-subtle)' : 'none',
}}
>
{msg.content}
{i === chatMessages.length - 1 &&
msg.role === 'assistant' &&
promptLoading && (
<span
className="inline-block w-2 h-4 ml-0.5 animate-pulse"
style={{ background: 'var(--accent-primary)' }}
/>
)}
</div>
</div>
))}
</div>
)}
{/* Single prompt response */}
{!chatMode && promptResponse && (
<div <div
ref={responseRef} ref={responseRef}
className="mt-4 p-4 rounded-lg max-h-80 overflow-y-auto" className="mt-4 p-4 rounded-lg max-h-80 overflow-y-auto"