Keep audiobook casting moving on slow LLMs

This commit is contained in:
mARTin-B78 2026-06-30 22:06:39 +02:00
parent 32a3c838fe
commit 34e4bb99e1
5 changed files with 83 additions and 23 deletions

View File

@ -9,6 +9,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.58] — 2026-06-30
### Fixed
- **Audiobook cast stalls** — per-passage LLM attribution now uses bounded UI timeouts and falls back to deterministic quote detection when a passage or retry half takes too long.
- **LLM server responsiveness** — audiobook attribution and character-sheet extraction now run blocking LLM HTTP calls in worker threads and honor a clamped `timeout_seconds` request value, so slow local LLM calls no longer block the whole app server event loop.
---
## [1.12.57] — 2026-06-30 ## [1.12.57] — 2026-06-30
### Fixed ### Fixed

View File

@ -1 +1 @@
1.12.57 1.12.58

View File

@ -95,6 +95,15 @@ def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str
return result return result
def _request_timeout_seconds(value, default: float = 600.0, minimum: float = 5.0, maximum: float = 600.0) -> float:
"""Clamp caller-provided LLM timeouts so UI recovery cannot hang indefinitely."""
try:
timeout = float(value)
except Exception:
timeout = default
return max(minimum, min(maximum, timeout))
def _resolve_speak_voice(settings: dict, client_id: str, explicit_voice: str) -> str: def _resolve_speak_voice(settings: dict, client_id: str, explicit_voice: str) -> str:
if explicit_voice: if explicit_voice:
return explicit_voice return explicit_voice
@ -462,6 +471,7 @@ async def character_sheets(request: Request):
_settings = _load_settings() _settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip() model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0)
if not text: if not text:
raise HTTPException(400, "No text provided") raise HTTPException(400, "No text provided")
@ -541,9 +551,10 @@ async def character_sheets(request: Request):
if model: if model:
payload["model"] = model payload["model"] = model
try: try:
resp = requests.post( resp = await asyncio.to_thread(
requests.post,
f"{llm_url}/chat/completions", json=payload, f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=timeout_seconds,
) )
resp.raise_for_status() resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"] _msg = resp.json()["choices"][0]["message"]
@ -697,6 +708,7 @@ async def attribute_dialogue(request: Request):
_settings = _load_settings() _settings = _load_settings()
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
model: str = (data.get("model") or _settings.get("llm_model") or "").strip() model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0)
if not text: if not text:
raise HTTPException(400, "No text provided") raise HTTPException(400, "No text provided")
@ -762,9 +774,10 @@ async def attribute_dialogue(request: Request):
if model: if model:
payload["model"] = model payload["model"] = model
try: try:
resp = requests.post( resp = await asyncio.to_thread(
requests.post,
f"{llm_url}/chat/completions", json=payload, f"{llm_url}/chat/completions", json=payload,
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=timeout_seconds,
) )
resp.raise_for_status() resp.raise_for_status()
msg = resp.json()["choices"][0]["message"] msg = resp.json()["choices"][0]["message"]

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.12.57"> <meta name="app-version" content="1.12.58">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.57"> <link rel="stylesheet" href="/static/style.css?v=1.12.58">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -362,7 +362,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.57"></script> <script src="/static/loader.js?v=1.12.58"></script>
</body> </body>
</html> </html>

View File

@ -12,8 +12,44 @@
// (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js). // (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js).
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
const AUDIOBOOK_WARMUP_TIMEOUT_MS = 180000;
const AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS = 90000;
const AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS = 60000;
const AUDIOBOOK_RECAST_TIMEOUT_MS = 75000;
const _audiobook = { running: false, cancel: false }; const _audiobook = { running: false, cancel: false };
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));
}
// Same hue algorithm as library.js _charHue so avatar colours match across views // Same hue algorithm as library.js _charHue so avatar colours match across views
function _abCharHue(name) { function _abCharHue(name) {
return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0)); return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0));
@ -1582,14 +1618,15 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…'); view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
try { try {
await fetch('/api/attribute-dialogue', { await audiobookFetchWithTimeout('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({ body: JSON.stringify({
text: 'Wake up.', known_characters: [], recent: '', language, text: 'Wake up.', known_characters: [], recent: '', language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: document.getElementById('ab-cv-llm-select')?.value || model model: document.getElementById('ab-cv-llm-select')?.value || model,
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)
}) })
}); }, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
} catch (err) { } catch (err) {
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
_audiobook.cancel = true; _audiobook.cancel = true;
@ -1627,7 +1664,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
let data = null; let data = null;
try { try {
const r = await fetch('/api/attribute-dialogue', { const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal, signal: ac.signal,
body: JSON.stringify({ body: JSON.stringify({
@ -1636,9 +1673,10 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
recent: '', recent: '',
language, language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: document.getElementById('ab-cv-llm-select')?.value || model model: document.getElementById('ab-cv-llm-select')?.value || model,
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)
}) })
}); }, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); } if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); }
data = await r.json(); data = await r.json();
} catch (err) { } catch (err) {
@ -1790,14 +1828,15 @@ async function audiobookCast(overrideUrl, overrideModel) {
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…'); view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
try { try {
await fetch('/api/attribute-dialogue', { await audiobookFetchWithTimeout('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({ body: JSON.stringify({
text: 'Wake up.', known_characters: [], recent: '', language, text: 'Wake up.', known_characters: [], recent: '', language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: document.getElementById('ab-cv-llm-select')?.value || model model: document.getElementById('ab-cv-llm-select')?.value || model,
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)
}) })
}); }, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
} catch (err) { } catch (err) {
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
_audiobook.cancel = true; _audiobook.cancel = true;
@ -1843,13 +1882,13 @@ async function audiobookCast(overrideUrl, overrideModel) {
view.processing(chunks[i]); view.processing(chunks[i]);
// ── Helper: run one attribution call and return parsed segments (or null on error) ── // ── Helper: run one attribution call and return parsed segments (or null on error) ──
const attributeChunk = async (chunkText, recentCtx) => { const attributeChunk = async (chunkText, recentCtx, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) => {
try { try {
const r = await fetch('/api/attribute-dialogue', { const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal, signal: ac.signal,
body: JSON.stringify({ text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model }), body: JSON.stringify({ text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model, timeout_seconds: audiobookTimeoutSeconds(timeoutMs) }),
}); }, timeoutMs + 5000);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); } if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); }
const d = await r.json(); const d = await r.json();
return Array.isArray(d.segments) ? d.segments : null; return Array.isArray(d.segments) ? d.segments : null;
@ -1872,8 +1911,8 @@ async function audiobookCast(overrideUrl, overrideModel) {
const splitAt = chunks[i].lastIndexOf(' ', half) || half; const splitAt = chunks[i].lastIndexOf(' ', half) || half;
const chunkA = chunks[i].slice(0, splitAt).trim(); const chunkA = chunks[i].slice(0, splitAt).trim();
const chunkB = chunks[i].slice(splitAt).trim(); const chunkB = chunks[i].slice(splitAt).trim();
const resA = await attributeChunk(chunkA, recent); const resA = await attributeChunk(chunkA, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS);
const resB = await attributeChunk(chunkB, recent); const resB = await attributeChunk(chunkB, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS);
const segsA = (resA && !resA.error) ? resA : null; const segsA = (resA && !resA.error) ? resA : null;
const segsB = (resB && !resB.error) ? resB : null; const segsB = (resB && !resB.error) ? resB : null;