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