feat(local-llm): Sprint 7 — all remaining features (F5,F7,F8,F12,F13,F15,CQ5,S3)
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
This commit is contained in:
parent
ed93a6f0af
commit
8bdd5ee1c8
36
__LOCAL_LLMs/dashboard/src/app/api/ollama/logs/route.ts
Normal file
36
__LOCAL_LLMs/dashboard/src/app/api/ollama/logs/route.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
42
__LOCAL_LLMs/dashboard/src/app/components/Sparkline.tsx
Normal file
42
__LOCAL_LLMs/dashboard/src/app/components/Sparkline.tsx
Normal file
@ -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 (
|
||||
<svg width={width} height={height} className="inline-block">
|
||||
{fillColor && <path d={fillPath} fill={fillColor} opacity={0.15} />}
|
||||
<polyline
|
||||
points={points.join(' ')}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -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<OllamaData | null>(null);
|
||||
@ -79,6 +80,20 @@ export default function Dashboard() {
|
||||
Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
>([]);
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [memoryHistory, setMemoryHistory] = useState<number[]>([]);
|
||||
const [extractionHealth, setExtractionHealth] = useState<{
|
||||
status: string;
|
||||
service?: string;
|
||||
} | null>(null);
|
||||
const [ollamaLogs, setOllamaLogs] = useState<string[]>([]);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [whisperTestResult, setWhisperTestResult] = useState<{
|
||||
text: string;
|
||||
latencyMs: number;
|
||||
} | null>(null);
|
||||
const [whisperTestLoading, setWhisperTestLoading] = useState(false);
|
||||
const [compareModel, setCompareModel] = useState<string | null>(null);
|
||||
const [compareResponse, setCompareResponse] = useState('');
|
||||
const responseRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(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 (
|
||||
<div className="min-h-screen p-6 max-w-[1600px] mx-auto">
|
||||
<div className="min-h-screen p-3 sm:p-6 max-w-[1600px] mx-auto">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
@ -553,88 +659,116 @@ export default function Dashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Top Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-4 h-4" style={{ color: 'var(--accent-primary)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
OLLAMA
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot status={ollama?.status === 'online' ? 'online' : 'offline'} />
|
||||
<span className="text-lg font-bold">
|
||||
{ollama?.status === 'online' ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{ollama?.url}
|
||||
</p>
|
||||
</div>
|
||||
{/* Top Stats Row (CQ5: skeleton loading when data not yet loaded) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||
{loading && !ollama ? (
|
||||
<>
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="card p-4">
|
||||
<div className="skeleton h-3 w-16 mb-3" />
|
||||
<div className="skeleton h-6 w-24 mb-2" />
|
||||
<div className="skeleton h-2 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-4 h-4" style={{ color: 'var(--accent-primary)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
OLLAMA
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot status={ollama?.status === 'online' ? 'online' : 'offline'} />
|
||||
<span className="text-lg font-bold">
|
||||
{ollama?.status === 'online' ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{ollama?.url}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Download className="w-4 h-4" style={{ color: 'var(--accent-secondary)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
MODELS
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">{ollama?.totalModels || 0}</span>
|
||||
<span className="text-sm ml-2" style={{ color: 'var(--text-tertiary)' }}>
|
||||
({formatBytes(ollama?.totalSize || 0)})
|
||||
</span>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--success)' }}>
|
||||
{ollama?.runningCount || 0} loaded in RAM
|
||||
</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Download className="w-4 h-4" style={{ color: 'var(--accent-secondary)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
MODELS
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">{ollama?.totalModels || 0}</span>
|
||||
<span className="text-sm ml-2" style={{ color: 'var(--text-tertiary)' }}>
|
||||
({formatBytes(ollama?.totalSize || 0)})
|
||||
</span>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--success)' }}>
|
||||
{ollama?.runningCount || 0} loaded in RAM
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Mic className="w-4 h-4" style={{ color: 'var(--purple)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
WHISPER
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot status={whisper?.installed ? 'online' : 'offline'} />
|
||||
<span className="text-lg font-bold">
|
||||
{whisper?.installed ? 'Installed' : 'Not Found'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{whisper?.models.length || 0} model(s)
|
||||
</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Mic className="w-4 h-4" style={{ color: 'var(--purple)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
WHISPER
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot status={whisper?.installed ? 'online' : 'offline'} />
|
||||
<span className="text-lg font-bold">
|
||||
{whisper?.installed ? 'Installed' : 'Not Found'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{whisper?.models.length || 0} model(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="w-4 h-4" style={{ color: 'var(--warning)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
MEMORY
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">{formatBytes(system?.memory.appMemory || 0)}</span>
|
||||
<span className="text-sm ml-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
/ {formatBytes(system?.memory.total || 0)}
|
||||
</span>
|
||||
<p className="text-[10px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{formatBytes(system?.memory.cached || 0)} cached (reclaimable)
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
value={system?.memory.appMemory || 0}
|
||||
max={system?.memory.total || 1}
|
||||
color={
|
||||
system?.memory.pressure === 'critical'
|
||||
? 'var(--danger)'
|
||||
: system?.memory.pressure === 'warning'
|
||||
? 'var(--warning)'
|
||||
: 'var(--accent-primary)'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="w-4 h-4" style={{ color: 'var(--warning)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
MEMORY
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">
|
||||
{formatBytes(system?.memory.appMemory || 0)}
|
||||
</span>
|
||||
<span className="text-sm ml-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
/ {formatBytes(system?.memory.total || 0)}
|
||||
</span>
|
||||
<p className="text-[10px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{formatBytes(system?.memory.cached || 0)} cached (reclaimable)
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
value={system?.memory.appMemory || 0}
|
||||
max={system?.memory.total || 1}
|
||||
color={
|
||||
system?.memory.pressure === 'critical'
|
||||
? 'var(--danger)'
|
||||
: system?.memory.pressure === 'warning'
|
||||
? 'var(--warning)'
|
||||
: 'var(--accent-primary)'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* F7: Memory sparkline */}
|
||||
{memoryHistory.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<Sparkline
|
||||
data={memoryHistory}
|
||||
width={160}
|
||||
height={24}
|
||||
color="var(--warning)"
|
||||
fillColor="var(--warning)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
@ -1211,6 +1345,37 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Whisper Transcription Test (F12) */}
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--border-subtle)' }}>
|
||||
<label
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors"
|
||||
style={{ background: 'var(--surface-muted)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<Mic className="w-3.5 h-3.5" />
|
||||
{whisperTestLoading ? 'Transcribing...' : 'Test transcription'}
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleWhisperTest(f);
|
||||
}}
|
||||
disabled={whisperTestLoading}
|
||||
/>
|
||||
</label>
|
||||
{whisperTestResult && (
|
||||
<div
|
||||
className="mt-2 p-2 rounded text-xs"
|
||||
style={{ background: 'var(--surface-muted)' }}
|
||||
>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>{whisperTestResult.text}</p>
|
||||
<p className="mt-1 text-[10px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{whisperTestResult.latencyMs}ms
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@ -1230,6 +1395,52 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extraction Service (F15) */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 mb-4">
|
||||
<Activity className="w-5 h-5" style={{ color: 'var(--accent-secondary)' }} />
|
||||
Extraction Service
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Status
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot status={extractionHealth?.status === 'ok' ? 'online' : 'offline'} />
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
color: extractionHealth?.status === 'ok' ? 'var(--success)' : 'var(--danger)',
|
||||
}}
|
||||
>
|
||||
{extractionHealth?.status === 'ok'
|
||||
? 'Online'
|
||||
: extractionHealth
|
||||
? 'Offline'
|
||||
: 'Checking...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Endpoint
|
||||
</span>
|
||||
<span className="text-xs font-mono" style={{ color: 'var(--text-tertiary)' }}>
|
||||
localhost:4005
|
||||
</span>
|
||||
</div>
|
||||
{extractionHealth?.service && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Service
|
||||
</span>
|
||||
<span className="text-xs font-mono" style={{ color: 'var(--text-secondary)' }}>
|
||||
{extractionHealth.service}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brew Packages */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2 mb-4">
|
||||
@ -1264,6 +1475,49 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ollama Logs Viewer (F8) */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLogs(!showLogs);
|
||||
if (!showLogs) fetchLogs();
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm font-medium mb-3 transition-colors"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
{showLogs ? 'Hide' : 'Show'} Ollama Logs
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showLogs && (
|
||||
<div
|
||||
className="card p-4 max-h-60 overflow-y-auto font-mono text-[11px]"
|
||||
style={{ background: 'var(--bg-canvas)', border: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
{ollamaLogs.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-tertiary)' }}>No logs available</p>
|
||||
) : (
|
||||
ollamaLogs.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="py-0.5"
|
||||
style={{
|
||||
color:
|
||||
line.includes('ERR') || line.includes('error')
|
||||
? 'var(--danger)'
|
||||
: line.includes('WARN') || line.includes('warn')
|
||||
? 'var(--warning)'
|
||||
: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt Panel (slide-up modal with streaming) */}
|
||||
{promptModel && (
|
||||
<div
|
||||
@ -1486,31 +1740,92 @@ export default function Dashboard() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Single prompt response */}
|
||||
{/* Single prompt response + comparison (F5) */}
|
||||
{!chatMode && promptResponse && (
|
||||
<div
|
||||
ref={responseRef}
|
||||
className="mt-4 p-4 rounded-lg max-h-80 overflow-y-auto"
|
||||
style={{
|
||||
background: 'var(--surface-card)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className="text-sm whitespace-pre-wrap"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
|
||||
}}
|
||||
>
|
||||
{promptResponse}
|
||||
{promptLoading && (
|
||||
<span
|
||||
className="inline-block w-2 h-4 ml-0.5 animate-pulse"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className={compareModel ? 'grid grid-cols-1 sm:grid-cols-2 gap-3' : ''}>
|
||||
<div
|
||||
ref={responseRef}
|
||||
className="p-4 rounded-lg max-h-80 overflow-y-auto"
|
||||
style={{
|
||||
background: 'var(--surface-card)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
{compareModel && (
|
||||
<p
|
||||
className="text-[10px] font-medium mb-2"
|
||||
style={{ color: 'var(--accent-primary)' }}
|
||||
>
|
||||
{promptModel}
|
||||
</p>
|
||||
)}
|
||||
<pre
|
||||
className="text-sm whitespace-pre-wrap"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
|
||||
}}
|
||||
>
|
||||
{promptResponse}
|
||||
{promptLoading && (
|
||||
<span
|
||||
className="inline-block w-2 h-4 ml-0.5 animate-pulse"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
/>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
{compareModel && (
|
||||
<div
|
||||
className="p-4 rounded-lg max-h-80 overflow-y-auto"
|
||||
style={{
|
||||
background: 'var(--surface-card)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-[10px] font-medium mb-2"
|
||||
style={{ color: 'var(--accent-secondary)' }}
|
||||
>
|
||||
{compareModel}
|
||||
</p>
|
||||
<pre
|
||||
className="text-sm whitespace-pre-wrap"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
|
||||
}}
|
||||
>
|
||||
{compareResponse || 'Generating...'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
{/* Compare with another model (F5) */}
|
||||
{!promptLoading && !compareModel && ollama && ollama.models.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Compare with:
|
||||
</span>
|
||||
{ollama.models
|
||||
.filter(m => m.name !== promptModel)
|
||||
.slice(0, 3)
|
||||
.map(m => (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => handleCompare(promptText, m.name)}
|
||||
className="text-[11px] px-2 py-1 rounded font-mono transition-colors"
|
||||
style={{
|
||||
background: 'var(--surface-muted)',
|
||||
color: 'var(--accent-secondary)',
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user