From 8bdd5ee1c8e95f382d75e128fb2e7dba90734698 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Feb 2026 15:44:20 -0800 Subject: [PATCH] =?UTF-8?q?feat(local-llm):=20Sprint=207=20=E2=80=94=20all?= =?UTF-8?q?=20remaining=20features=20(F5,F7,F8,F12,F13,F15,CQ5,S3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - F5: Model comparison side-by-side — after a prompt response, click any other model to compare. Responses display in two-column grid. - F7: System resource sparklines — memory usage ring buffer (30 points) with SVG sparkline component in the memory stats card. - F8: Ollama logs viewer — collapsible terminal-style panel below main grid. Fetches from /api/ollama/logs route. Color-coded by level. - F12: Whisper transcription test — file upload button in Whisper panel. Uploads audio to /api/whisper/transcribe, displays text + latency. - F13: Responsive mobile layout — p-3/sm:p-6 padding, gap-3/sm:gap-4, hidden sm:inline for header text, responsive comparison grid. - F15: Extraction service panel — health check to localhost:4005 on each refresh. Status card in right column with endpoint + service. Code quality: - CQ5: Skeleton shimmer loading UI — 4 skeleton cards shown while initial data loads. Uses CSS shimmer animation from globals.css. Security: - S3: Documented CORS/auth assumption in code comment — dashboard is local-only, no auth needed for dev tool. New files: - components/Sparkline.tsx — reusable SVG sparkline component - api/ollama/chat/route.ts — streaming chat endpoint (from Sprint 6) - api/ollama/logs/route.ts — Ollama log file reader - api/whisper/transcribe/route.ts — Whisper STT test endpoint --- .../src/app/api/ollama/logs/route.ts | 36 ++ .../src/app/api/whisper/transcribe/route.ts | 93 ++++ .../src/app/components/Sparkline.tsx | 42 ++ __LOCAL_LLMs/dashboard/src/app/page.tsx | 523 ++++++++++++++---- 4 files changed, 590 insertions(+), 104 deletions(-) create mode 100644 __LOCAL_LLMs/dashboard/src/app/api/ollama/logs/route.ts create mode 100644 __LOCAL_LLMs/dashboard/src/app/api/whisper/transcribe/route.ts create mode 100644 __LOCAL_LLMs/dashboard/src/app/components/Sparkline.tsx diff --git a/__LOCAL_LLMs/dashboard/src/app/api/ollama/logs/route.ts b/__LOCAL_LLMs/dashboard/src/app/api/ollama/logs/route.ts new file mode 100644 index 00000000..9e1c4228 --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/api/ollama/logs/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { readFile } from 'fs/promises'; +import { homedir } from 'os'; +import { join } from 'path'; +import { existsSync } from 'fs'; + +export async function GET() { + const logPaths = [ + join(homedir(), '.ollama', 'logs', 'server.log'), + join(homedir(), '.ollama', 'logs', 'gpu.log'), + '/tmp/ollama.log', + ]; + + for (const logPath of logPaths) { + if (existsSync(logPath)) { + try { + const content = await readFile(logPath, 'utf-8'); + // Return last 100 lines + const lines = content.split('\n').filter(Boolean); + const tail = lines.slice(-100); + return NextResponse.json({ lines: tail, path: logPath, total: lines.length }); + } catch (err) { + return NextResponse.json({ error: String(err), lines: [] }, { status: 500 }); + } + } + } + + // On macOS, Ollama logs via unified logging + return NextResponse.json({ + lines: [ + 'Ollama uses macOS unified logging. Use: log show --predicate \'subsystem == "com.ollama"\' --last 5m', + ], + path: 'system', + total: 1, + }); +} diff --git a/__LOCAL_LLMs/dashboard/src/app/api/whisper/transcribe/route.ts b/__LOCAL_LLMs/dashboard/src/app/api/whisper/transcribe/route.ts new file mode 100644 index 00000000..55e9897f --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/api/whisper/transcribe/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { writeFile, unlink } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const execAsync = promisify(exec); + +export async function POST(request: NextRequest) { + const startTime = Date.now(); + try { + const formData = await request.formData(); + const file = formData.get('audio') as File | null; + const model = (formData.get('model') as string) || 'ggml-base.en.bin'; + + if (!file) { + return NextResponse.json({ error: 'No audio file provided' }, { status: 400 }); + } + + // Write uploaded file to temp + const buffer = Buffer.from(await file.arrayBuffer()); + const tmpPath = join(tmpdir(), `whisper-${Date.now()}.wav`); + await writeFile(tmpPath, buffer); + + try { + // Try to find whisper-cli + const whisperBin = await findWhisperBin(); + if (!whisperBin) { + return NextResponse.json({ error: 'whisper-cli not found' }, { status: 500 }); + } + + // Find model path + const modelPaths = [ + `/opt/homebrew/share/whisper-cpp/models/${model}`, + `${process.env.HOME}/whisper-models/${model}`, + `${process.env.HOME}/.cache/whisper/${model}`, + ]; + + let modelPath = ''; + for (const p of modelPaths) { + try { + await execAsync(`test -f "${p}"`); + modelPath = p; + break; + } catch { + /* not found */ + } + } + + if (!modelPath) { + return NextResponse.json({ error: `Model ${model} not found` }, { status: 404 }); + } + + const { stdout } = await execAsync( + `"${whisperBin}" -m "${modelPath}" -f "${tmpPath}" --no-timestamps -otxt 2>/dev/null`, + { timeout: 30000 } + ); + + const latencyMs = Date.now() - startTime; + return NextResponse.json({ + text: stdout.trim(), + model, + latencyMs, + fileSize: buffer.length, + }); + } finally { + await unlink(tmpPath).catch(() => {}); + } + } catch (err) { + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} + +async function findWhisperBin(): Promise { + const candidates = ['whisper-cli', 'whisper-cpp', 'main']; + for (const bin of candidates) { + try { + const { stdout } = await execAsync(`which ${bin}`); + if (stdout.trim()) return stdout.trim(); + } catch { + /* not found */ + } + } + // Check homebrew path + try { + const { stdout } = await execAsync('ls /opt/homebrew/bin/whisper-cli 2>/dev/null'); + if (stdout.trim()) return stdout.trim(); + } catch { + /* not found */ + } + return null; +} diff --git a/__LOCAL_LLMs/dashboard/src/app/components/Sparkline.tsx b/__LOCAL_LLMs/dashboard/src/app/components/Sparkline.tsx new file mode 100644 index 00000000..6d63e14c --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/components/Sparkline.tsx @@ -0,0 +1,42 @@ +'use client'; + +interface SparklineProps { + data: number[]; + width?: number; + height?: number; + color?: string; + fillColor?: string; +} + +export function Sparkline({ + data, + width = 120, + height = 30, + color = 'var(--accent-primary)', + fillColor, +}: SparklineProps) { + if (data.length < 2) return null; + const max = Math.max(...data, 1); + const min = Math.min(...data, 0); + const range = max - min || 1; + const points = data.map((v, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - ((v - min) / range) * (height - 4) - 2; + return `${x},${y}`; + }); + const linePath = `M${points.join(' L')}`; + const fillPath = `${linePath} L${width},${height} L0,${height} Z`; + return ( + + {fillColor && } + + + ); +} diff --git a/__LOCAL_LLMs/dashboard/src/app/page.tsx b/__LOCAL_LLMs/dashboard/src/app/page.tsx index 8a4423f9..5b1419b3 100644 --- a/__LOCAL_LLMs/dashboard/src/app/page.tsx +++ b/__LOCAL_LLMs/dashboard/src/app/page.tsx @@ -47,6 +47,7 @@ import type { import { formatBytes, formatUptime } from './lib/format'; import { StatusDot } from './components/StatusDot'; import { ProgressBar } from './components/ProgressBar'; +import { Sparkline } from './components/Sparkline'; export default function Dashboard() { const [ollama, setOllama] = useState(null); @@ -79,6 +80,20 @@ export default function Dashboard() { Array<{ role: 'user' | 'assistant'; content: string }> >([]); const [systemPrompt, setSystemPrompt] = useState(''); + const [memoryHistory, setMemoryHistory] = useState([]); + const [extractionHealth, setExtractionHealth] = useState<{ + status: string; + service?: string; + } | null>(null); + const [ollamaLogs, setOllamaLogs] = useState([]); + const [showLogs, setShowLogs] = useState(false); + const [whisperTestResult, setWhisperTestResult] = useState<{ + text: string; + latencyMs: number; + } | null>(null); + const [whisperTestLoading, setWhisperTestLoading] = useState(false); + const [compareModel, setCompareModel] = useState(null); + const [compareResponse, setCompareResponse] = useState(''); const responseRef = useRef(null); const abortRef = useRef(null); const fetchingRef = useRef(false); @@ -100,7 +115,23 @@ export default function Dashboard() { ]); if (oRes.status === 'fulfilled') setOllama(oRes.value); if (wRes.status === 'fulfilled') setWhisper(wRes.value); - if (sRes.status === 'fulfilled') setSystem(sRes.value); + if (sRes.status === 'fulfilled') { + setSystem(sRes.value); + // F7: Record memory snapshot for sparkline (max 30 points = ~7.5 min at 15s) + if (sRes.value?.memory?.appMemory) { + setMemoryHistory(prev => [...prev.slice(-29), sRes.value.memory.appMemory]); + } + } + // F15: Check extraction service health (best-effort) + try { + const eRes = await fetch('http://localhost:4005/health', { + signal: AbortSignal.timeout(2000), + }); + if (eRes.ok) setExtractionHealth(await eRes.json()); + else setExtractionHealth({ status: 'error' }); + } catch { + setExtractionHealth({ status: 'offline' }); + } setLastRefresh(new Date()); setLoading(false); fetchingRef.current = false; @@ -173,6 +204,78 @@ export default function Dashboard() { }); }; + // Fetch Ollama logs (F8) + const fetchLogs = async () => { + try { + const res = await fetch('/api/ollama/logs'); + const data = await res.json(); + if (data.lines) setOllamaLogs(data.lines); + } catch { + /* ignore */ + } + }; + + // Whisper transcription test (F12) + const handleWhisperTest = async (file: File) => { + setWhisperTestLoading(true); + setWhisperTestResult(null); + try { + const formData = new FormData(); + formData.append('audio', file); + if (whisper?.models?.[0]?.name) formData.append('model', whisper.models[0].name); + const res = await fetch('/api/whisper/transcribe', { method: 'POST', body: formData }); + const data = await res.json(); + if (data.text) setWhisperTestResult({ text: data.text, latencyMs: data.latencyMs }); + else addToast(data.error || 'Transcription failed', 'error'); + } catch (err) { + addToast(`Whisper test failed: ${err}`, 'error'); + } + setWhisperTestLoading(false); + }; + + // Model comparison (F5) — send same prompt to second model + const handleCompare = async (prompt: string, model2: string) => { + setCompareModel(model2); + setCompareResponse(''); + try { + const res = await fetch('/api/ollama/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: model2, prompt }), + }); + if (!res.ok || !res.body) { + setCompareResponse('Error'); + return; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let full = ''; + 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 c = JSON.parse(line); + if (c.response) { + full += c.response; + setCompareResponse(full); + } + } catch { + /* skip */ + } + } + } + if (!full) setCompareResponse('(empty response)'); + } catch (err) { + setCompareResponse(`Error: ${err}`); + } + }; + // Auto-load model helpers (F16) const setPreferredModel = (modelName: string | null) => { setAutoLoadModel(modelName); @@ -471,8 +574,11 @@ export default function Dashboard() { const isRunning = (name: string) => ollama?.running.some(r => r.name === name); const getRunningInfo = (name: string) => ollama?.running.find(r => r.name === name); + // S3: This dashboard is local-only (localhost:3000). No CORS or auth is implemented. + // All API routes are accessible to any local process. This is acceptable for a dev tool. + return ( -
+
{/* Header */}
@@ -553,88 +659,116 @@ export default function Dashboard() { ))}
- {/* Top Stats Row */} -
-
-
- - - OLLAMA - -
-
- - - {ollama?.status === 'online' ? 'Online' : 'Offline'} - -
-

- {ollama?.url} -

-
+ {/* Top Stats Row (CQ5: skeleton loading when data not yet loaded) */} +
+ {loading && !ollama ? ( + <> + {[1, 2, 3, 4].map(i => ( +
+
+
+
+
+ ))} + + ) : ( + <> +
+
+ + + OLLAMA + +
+
+ + + {ollama?.status === 'online' ? 'Online' : 'Offline'} + +
+

+ {ollama?.url} +

+
-
-
- - - MODELS - -
- {ollama?.totalModels || 0} - - ({formatBytes(ollama?.totalSize || 0)}) - -

- {ollama?.runningCount || 0} loaded in RAM -

-
+
+
+ + + MODELS + +
+ {ollama?.totalModels || 0} + + ({formatBytes(ollama?.totalSize || 0)}) + +

+ {ollama?.runningCount || 0} loaded in RAM +

+
-
-
- - - WHISPER - -
-
- - - {whisper?.installed ? 'Installed' : 'Not Found'} - -
-

- {whisper?.models.length || 0} model(s) -

-
+
+
+ + + WHISPER + +
+
+ + + {whisper?.installed ? 'Installed' : 'Not Found'} + +
+

+ {whisper?.models.length || 0} model(s) +

+
-
-
- - - MEMORY - -
- {formatBytes(system?.memory.appMemory || 0)} - - / {formatBytes(system?.memory.total || 0)} - -

- {formatBytes(system?.memory.cached || 0)} cached (reclaimable) -

-
- -
-
+
+
+ + + MEMORY + +
+ + {formatBytes(system?.memory.appMemory || 0)} + + + / {formatBytes(system?.memory.total || 0)} + +

+ {formatBytes(system?.memory.cached || 0)} cached (reclaimable) +

+
+ +
+ {/* F7: Memory sparkline */} + {memoryHistory.length > 1 && ( +
+ +
+ )} +
+ + )}
{/* Main Grid */} @@ -1211,6 +1345,37 @@ export default function Dashboard() {
)}
+ {/* Whisper Transcription Test (F12) */} +
+ + {whisperTestResult && ( +
+

{whisperTestResult.text}

+

+ {whisperTestResult.latencyMs}ms +

+
+ )} +
) : (
+ {/* Extraction Service (F15) */} +
+

+ + Extraction Service +

+
+ + Status + +
+ + + {extractionHealth?.status === 'ok' + ? 'Online' + : extractionHealth + ? 'Offline' + : 'Checking...'} + +
+
+
+ + Endpoint + + + localhost:4005 + +
+ {extractionHealth?.service && ( +
+ + Service + + + {extractionHealth.service} + +
+ )} +
+ {/* Brew Packages */}

@@ -1264,6 +1475,49 @@ export default function Dashboard() {

+ {/* Ollama Logs Viewer (F8) */} +
+ + {showLogs && ( +
+ {ollamaLogs.length === 0 ? ( +

No logs available

+ ) : ( + ollamaLogs.map((line, i) => ( +
+ {line} +
+ )) + )} +
+ )} +
+ {/* Prompt Panel (slide-up modal with streaming) */} {promptModel && (
)} - {/* Single prompt response */} + {/* Single prompt response + comparison (F5) */} {!chatMode && promptResponse && ( -
-
-                  {promptResponse}
-                  {promptLoading && (
-                    
+              
+
+
+ {compareModel && ( +

+ {promptModel} +

+ )} +
+                      {promptResponse}
+                      {promptLoading && (
+                        
+                      )}
+                    
+
+ {compareModel && ( +
+

+ {compareModel} +

+
+                        {compareResponse || 'Generating...'}
+                      
+
)} -
+
+ {/* Compare with another model (F5) */} + {!promptLoading && !compareModel && ollama && ollama.models.length > 1 && ( +
+ + Compare with: + + {ollama.models + .filter(m => m.name !== promptModel) + .slice(0, 3) + .map(m => ( + + ))} +
+ )}
)}