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:
parent
554a5137ec
commit
2da67c2f74
@ -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',
|
||||
|
||||
@ -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 · 48 GB · {system?.platform || 'macOS'}
|
||||
{system?.chip || 'Loading...'} · {formatBytes(system?.memory.total || 0)}{' '}
|
||||
· {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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user