fix(local-llm): Sprint 1 — critical dashboard bug fixes (B1,B3-B6,B9-B11,P4)

Bug fixes:
- B4: Escape key now respects streaming state — during active stream,
  Escape aborts the generation instead of closing the modal
- B5: Auto-refresh (15s interval) pauses during streaming and pull
  operations to prevent background churn and UI flicker
- B9: Add AbortController to streaming fetch — closing modal or pressing
  Escape cancels the underlying HTTP request, saving CPU/bandwidth
- B1: Header subtitle now dynamically shows chip name and RAM from the
  system API instead of hardcoded 'Apple M4 Pro · 48 GB'
- B11: Escape handler clears promptText and promptResponse on close
- B6: Toast IDs use Date.now()+random instead of incrementing ref
  (prevents collision on HMR remount)
- B10: Brew panel distinguishes 'Loading...' (system=null) from
  'No tracked packages found' (system loaded, empty array)
- B3: Remove dead non-streaming generate action from Ollama API route
- P4: Add 5-second AbortController timeout to all fetchOllama() calls
  to prevent indefinite hangs when Ollama is unresponsive
This commit is contained in:
saravanakumardb1 2026-02-19 15:12:41 -08:00
parent 554a5137ec
commit 2da67c2f74
2 changed files with 44 additions and 30 deletions

View File

@ -3,12 +3,19 @@ import { NextResponse } from 'next/server';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
async function fetchOllama(path: string, options?: RequestInit) {
const res = await fetch(`${OLLAMA_URL}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options?.headers },
});
if (!res.ok) throw new Error(`Ollama ${path}: ${res.status}`);
return res.json();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${OLLAMA_URL}${path}`, {
...options,
signal: controller.signal,
headers: { 'Content-Type': 'application/json', ...options?.headers },
});
if (!res.ok) throw new Error(`Ollama ${path}: ${res.status}`);
return res.json();
} finally {
clearTimeout(timeout);
}
}
export async function GET() {
@ -66,21 +73,6 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, message: `Unloaded ${model}` });
}
if (action === 'generate') {
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt: body.prompt, stream: false }),
});
const data = await res.json();
return NextResponse.json({
success: true,
response: data.response,
eval_count: data.eval_count,
eval_duration: data.eval_duration,
});
}
if (action === 'pull') {
const res = await fetch(`${OLLAMA_URL}/api/pull`, {
method: 'POST',

View File

@ -153,10 +153,10 @@ export default function Dashboard() {
const [copied, setCopied] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const responseRef = useRef<HTMLDivElement>(null);
const toastId = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const addToast = useCallback((message: string, type: Toast['type'] = 'info') => {
const id = ++toastId.current;
const id = Date.now() + Math.random();
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000);
}, []);
@ -180,21 +180,29 @@ export default function Dashboard() {
}, [fetchAll]);
useEffect(() => {
if (promptLoading || pullLoading) return;
const interval = setInterval(fetchAll, 15000);
return () => clearInterval(interval);
}, [fetchAll]);
}, [fetchAll, promptLoading, pullLoading]);
// Escape key closes modals
// Escape key closes modals (respects streaming state)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setPromptModel(null);
if (promptLoading) {
abortRef.current?.abort();
setPromptLoading(false);
} else {
setPromptModel(null);
setPromptResponse('');
setPromptText('');
}
setDeleteConfirm(null);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
}, [promptLoading]);
const handleModelAction = async (action: string, model: string) => {
setActionLoading(`${action}-${model}`);
@ -247,11 +255,14 @@ export default function Dashboard() {
if (!promptModel || !promptText.trim()) return;
setPromptLoading(true);
setPromptResponse('');
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch('/api/ollama/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: promptModel, prompt: promptText }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
setPromptResponse('Error: Failed to connect to Ollama');
@ -284,8 +295,13 @@ export default function Dashboard() {
}
if (!fullResponse) setPromptResponse('(empty response)');
} catch (err) {
setPromptResponse(`Error: ${err}`);
if (controller.signal.aborted) {
// User cancelled — keep partial response
} else {
setPromptResponse(`Error: ${err}`);
}
}
abortRef.current = null;
setPromptLoading(false);
};
@ -314,7 +330,8 @@ export default function Dashboard() {
Local LLM Mission Control
</h1>
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
Apple M4 Pro &middot; 48 GB &middot; {system?.platform || 'macOS'}
{system?.chip || 'Loading...'} &middot; {formatBytes(system?.memory.total || 0)}{' '}
&middot; {system?.platform || 'macOS'}
</p>
</div>
</div>
@ -933,11 +950,16 @@ export default function Dashboard() {
</span>
</div>
))}
{(!system?.brewPackages || system.brewPackages.length === 0) && (
{!system && (
<p className="text-sm text-center py-4" style={{ color: 'var(--text-tertiary)' }}>
Loading...
</p>
)}
{system && (!system.brewPackages || system.brewPackages.length === 0) && (
<p className="text-sm text-center py-4" style={{ color: 'var(--text-tertiary)' }}>
No tracked packages found
</p>
)}
</div>
</div>
</div>