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:
saravanakumardb1 2026-02-19 15:44:20 -08:00
parent ed93a6f0af
commit 8bdd5ee1c8
4 changed files with 590 additions and 104 deletions

View 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,
});
}

View File

@ -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;
}

View 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>
);
}

View File

@ -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>