From ed93a6f0af24ac4316e899dbe510800bc830f78a Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Feb 2026 15:38:06 -0800 Subject: [PATCH] =?UTF-8?q?feat(local-llm):=20Sprint=206=20=E2=80=94=20maj?= =?UTF-8?q?or=20feature=20batch=20(CQ2,CQ5,CQ6,P5,F4,F10,F14,F16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/api/ollama/chat/route.ts | 42 +++ .../dashboard/src/app/api/ollama/route.ts | 25 +- .../dashboard/src/app/api/system/route.ts | 3 + __LOCAL_LLMs/dashboard/src/app/globals.css | 73 +++++ __LOCAL_LLMs/dashboard/src/app/page.tsx | 281 +++++++++++++++++- 5 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 __LOCAL_LLMs/dashboard/src/app/api/ollama/chat/route.ts diff --git a/__LOCAL_LLMs/dashboard/src/app/api/ollama/chat/route.ts b/__LOCAL_LLMs/dashboard/src/app/api/ollama/chat/route.ts new file mode 100644 index 00000000..9718cd3a --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/api/ollama/chat/route.ts @@ -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' }, + }); + } +} diff --git a/__LOCAL_LLMs/dashboard/src/app/api/ollama/route.ts b/__LOCAL_LLMs/dashboard/src/app/api/ollama/route.ts index 4cf60520..3056c848 100644 --- a/__LOCAL_LLMs/dashboard/src/app/api/ollama/route.ts +++ b/__LOCAL_LLMs/dashboard/src/app/api/ollama/route.ts @@ -1,6 +1,16 @@ import { NextResponse } from 'next/server'; +import { z } from 'zod'; 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) { const controller = new AbortController(); 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) { try { - const body = await request.json(); - const { action, model } = body; - - if (!model || typeof model !== 'string' || !MODEL_NAME_RE.test(model)) { - return NextResponse.json({ error: 'Invalid model name' }, { status: 400 }); + const raw = await request.json(); + const parsed = PostBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message || 'Invalid request' }, + { status: 400 } + ); } + const { action, model } = parsed.data; if (action === 'load') { await fetchOllama('/api/generate', { diff --git a/__LOCAL_LLMs/dashboard/src/app/api/system/route.ts b/__LOCAL_LLMs/dashboard/src/app/api/system/route.ts index 7e6b71bb..b58f1170 100644 --- a/__LOCAL_LLMs/dashboard/src/app/api/system/route.ts +++ b/__LOCAL_LLMs/dashboard/src/app/api/system/route.ts @@ -95,6 +95,9 @@ async function getStaticInfo() { return staticCache; } +// P5: Eagerly warm static cache on module load (system_profiler takes 2-3s) +getStaticInfo().catch(() => {}); + async function getCachedOllamaDiskUsage(): Promise { if (ollamaDiskCache && Date.now() - ollamaDiskCache.ts < OLLAMA_DISK_TTL) return ollamaDiskCache.value; diff --git a/__LOCAL_LLMs/dashboard/src/app/globals.css b/__LOCAL_LLMs/dashboard/src/app/globals.css index 50c4341c..f8c379f8 100644 --- a/__LOCAL_LLMs/dashboard/src/app/globals.css +++ b/__LOCAL_LLMs/dashboard/src/app/globals.css @@ -88,3 +88,76 @@ body { outline: 2px solid var(--accent-primary); 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; +} diff --git a/__LOCAL_LLMs/dashboard/src/app/page.tsx b/__LOCAL_LLMs/dashboard/src/app/page.tsx index 5b5f321a..8a4423f9 100644 --- a/__LOCAL_LLMs/dashboard/src/app/page.tsx +++ b/__LOCAL_LLMs/dashboard/src/app/page.tsx @@ -30,6 +30,11 @@ import { History, Keyboard, FileText, + Sun, + Moon, + Tag, + Star, + MessageSquare, } from 'lucide-react'; import type { OllamaData, @@ -66,6 +71,14 @@ export default function Dashboard() { const [showHistory, setShowHistory] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const [modelfileData, setModelfileData] = useState>({}); + const [theme, setTheme] = useState<'dark' | 'light'>('dark'); + const [modelTags, setModelTags] = useState>({}); + const [autoLoadModel, setAutoLoadModel] = useState(null); + const [chatMode, setChatMode] = useState(false); + const [chatMessages, setChatMessages] = useState< + Array<{ role: 'user' | 'assistant'; content: string }> + >([]); + const [systemPrompt, setSystemPrompt] = useState(''); const responseRef = useRef(null); const abortRef = useRef(null); const fetchingRef = useRef(false); @@ -93,10 +106,34 @@ export default function Dashboard() { 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(() => { 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(() => { if (promptLoading || pullLoading) return; const interval = setInterval(fetchAll, 15000); @@ -117,6 +154,32 @@ export default function Dashboard() { 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) const fetchModelfile = async (modelName: string) => { if (modelfileData[modelName]) return; @@ -325,6 +388,86 @@ export default function Dashboard() { 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 getRunningInfo = (name: string) => ollama?.running.find(r => r.name === name); @@ -349,10 +492,22 @@ export default function Dashboard() {

-
- +
+ Last refresh: {lastRefresh.toLocaleTimeString()} +
@@ -763,6 +918,51 @@ export default function Dashboard() {

Digest: {model.digest?.substring(0, 16)}...

Modified: {new Date(model.modified_at).toLocaleString()}

{model.details?.family &&

Family: {model.details.family}

} + {/* Model Tags (F14) */} +
+ {['coding', 'chat', 'fast', 'vision', 'reasoning'].map(tag => ( + + ))} +
+ {/* Auto-load toggle (F16) */} + {modelfileData[model.name] && (
- {promptResponse && ( + {/* Chat mode toggle (F4) */} + + {(promptResponse || chatMessages.length > 0) && (
+ {/* System prompt for chat mode (F4) */} + {chatMode && ( + 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) */} {showHistory && (
{ - if (e.key === 'Enter' && e.metaKey) handlePrompt(); + if (e.key === 'Enter' && e.metaKey) handleSend(); }} disabled={promptLoading} /> @@ -1185,7 +1419,7 @@ export default function Dashboard() {