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() {