// Network and streaming helpers for audiobook attribution. // Loaded after audiobook.js; the functions are called from user actions and // therefore do not need to execute while the main audiobook module initializes. (function (root) { async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) { const parentSignal = options.signal; const ac = new AbortController(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; ac.abort(); }, timeoutMs); const onParentAbort = () => ac.abort(parentSignal?.reason); if (parentSignal) { if (parentSignal.aborted) onParentAbort(); else parentSignal.addEventListener('abort', onParentAbort, { once: true }); } try { return await fetch(url, { ...options, signal: ac.signal }); } catch (err) { if (timedOut) { const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`); timeoutErr.name = 'TimeoutError'; throw timeoutErr; } throw err; } finally { clearTimeout(timer); if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort); } } function audiobookTimeoutSeconds(timeoutMs) { return Math.max(5, Math.round(timeoutMs / 1000)); } async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) { const ctl = new AbortController(); const onAbort = () => ctl.abort(); if (outerSignal) { if (outerSignal.aborted) ctl.abort(); else outerSignal.addEventListener('abort', onAbort, { once: true }); } let idleTimer = null; const armIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs); }; try { armIdle(); const r = await fetch('/api/attribute-dialogue/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ctl.signal, body: JSON.stringify({ ...body, want_reasoning: true }), }); if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status); const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = '', result = null; for (;;) { const { done, value } = await reader.read(); if (done) break; armIdle(); buf += dec.decode(value, { stream: true }); let at; while ((at = buf.indexOf('\n\n')) >= 0) { const line = buf.slice(0, at).trim(); buf = buf.slice(at + 2); if (!line.startsWith('data:')) continue; let d; try { d = JSON.parse(line.slice(5)); } catch (_) { continue; } if (d.t && view?.thinking) view.thinking(d.t); if (d.error) throw new Error(d.error); if (d.done) result = d.result || null; } } if (!result) throw new Error('stream ended without result'); return result; } catch (err) { if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) { throw new Error('stream idle timeout'); } throw err; } finally { clearTimeout(idleTimer); if (outerSignal) outerSignal.removeEventListener('abort', onAbort); } } Object.assign(root, { audiobookFetchWithTimeout, audiobookTimeoutSeconds, audiobookAttributeStream }); root.TTVC = root.TTVC || {}; root.TTVC.audiobook = root.TTVC.audiobook || {}; Object.assign(root.TTVC.audiobook, { fetchWithTimeout: audiobookFetchWithTimeout, attributeStream: audiobookAttributeStream, timeoutSeconds: audiobookTimeoutSeconds, }); })(window);