100 lines
3.4 KiB
JavaScript
100 lines
3.4 KiB
JavaScript
// 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);
|