From e0925b9d3886301f9ce58619019d5f8efd5f9c10 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 2 Aug 2026 18:00:53 -0400 Subject: [PATCH] Make a failed AI send visible instead of hanging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET reaches the endpoint and POST does not, with nothing in the request log, no CSRF termination and no PHP error — so it fails in the browser before the request goes out, and the only symptom was the pending indicator sitting there. Reads the response as text before parsing so an empty body reports as rejected-before-execution rather than a JSON error, wraps the synchronous path, and surfaces script errors into the transcript. --- Plugin/unraid/pages/ai.php | 63 +++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index f8d7c7c..1410962 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -399,18 +399,44 @@ $('vv-ai-input').value = ''; addPending(); - const fd = new FormData(); - fd.append('action', 'ask'); - fd.append('question', q); - fd.append('history', JSON.stringify(history.slice(-MAXTURN * 2))); - fd.append('kind', $('vv-ai-kind').value); - fd.append('think', $('vv-ai-think').checked ? '1' : '0'); + // Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper + // installed elsewhere on the page — would escape the promise chain entirely and leave the + // pending indicator up forever with nothing logged anywhere. A hang is the one failure + // that tells you nothing, so every path below has to end in a visible message. + let res; + try { + const fd = new FormData(); + fd.append('action', 'ask'); + fd.append('question', q); + fd.append('history', JSON.stringify(history.slice(-MAXTURN * 2))); + fd.append('kind', $('vv-ai-kind').value); + fd.append('think', $('vv-ai-think').checked ? '1' : '0'); + res = fetch(API, { method: 'POST', body: fd }); + } catch (e) { + addError('Could not send the request: ' + (e && e.message ? e.message : e) + + ' — this failed in the browser before reaching the server.'); + finish(); + return; + } - fetch(API, { method: 'POST', body: fd }).then(r => r.json()).then(d => { - if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; } - history.push({ role: 'user', content: q }); - poll(d.token, Date.now()); - }).catch(e => { addError('Request failed: ' + e); finish(); }); + res.then(r => r.text().then(t => ({ status: r.status, text: t }))) + .then(({ status, text }) => { + if (!text.trim()) { + // The CSRF prepend terminates with an empty body, so this is the shape that failure + // takes. Naming it beats a bare JSON parse error. + addError('Empty response (HTTP ' + status + '). This usually means the request was ' + + 'rejected before the endpoint ran — check the CSRF token shim.'); + finish(); return; + } + let d; + try { d = JSON.parse(text); } + catch (e) { addError('Unparseable response (HTTP ' + status + '): ' + text.slice(0, 160)); + finish(); return; } + if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; } + history.push({ role: 'user', content: q }); + poll(d.token, Date.now()); + }) + .catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); }); } function finish() { busy = false; $('vv-ai-send').disabled = false; } @@ -467,6 +493,21 @@ }); document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); }); + // Surface any script error on this tab into the transcript. Without it a throw anywhere in + // the page is invisible unless the console happens to be open, which is how a silent hang + // survives a diagnosis session. + window.addEventListener('error', e => { + if (!busy) return; + addError('Script error: ' + (e.message || 'unknown') + + (e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : '')); + finish(); + }); + window.addEventListener('unhandledrejection', e => { + if (!busy) return; + addError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason)); + finish(); + }); + loadBanner(); setInterval(loadBanner, 30000); })();