diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca08fa..0b066cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Fixed diff --git a/VERSION b/VERSION index a389f8c..26af9b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.57 +1.12.58 diff --git a/routes/conversation.py b/routes/conversation.py index c911801..32fcc29 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -95,6 +95,15 @@ def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str 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: if explicit_voice: return explicit_voice @@ -462,6 +471,7 @@ async def character_sheets(request: Request): _settings = _load_settings() 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() + timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0) if not text: raise HTTPException(400, "No text provided") @@ -541,9 +551,10 @@ async def character_sheets(request: Request): if model: payload["model"] = model try: - resp = requests.post( + resp = await asyncio.to_thread( + requests.post, 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() _msg = resp.json()["choices"][0]["message"] @@ -697,6 +708,7 @@ async def attribute_dialogue(request: Request): _settings = _load_settings() 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() + timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0) if not text: raise HTTPException(400, "No text provided") @@ -762,9 +774,10 @@ async def attribute_dialogue(request: Request): if model: payload["model"] = model try: - resp = requests.post( + resp = await asyncio.to_thread( + requests.post, 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() msg = resp.json()["choices"][0]["message"] diff --git a/static/index.html b/static/index.html index 72b49fd..cf572ea 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -362,7 +362,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 94e3406..6f5b3ff 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -12,8 +12,44 @@ // (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js). 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 }; +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 function _abCharHue(name) { 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)…'); try { - await fetch('/api/attribute-dialogue', { + await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify({ text: 'Wake up.', known_characters: [], recent: '', language, 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) { if (err.name === 'AbortError') { _audiobook.cancel = true; @@ -1627,7 +1664,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) { let data = null; try { - const r = await fetch('/api/attribute-dialogue', { + const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify({ @@ -1636,9 +1673,10 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) { recent: '', language, 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); } data = await r.json(); } 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)…'); try { - await fetch('/api/attribute-dialogue', { + await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify({ text: 'Wake up.', known_characters: [], recent: '', language, 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) { if (err.name === 'AbortError') { _audiobook.cancel = true; @@ -1843,13 +1882,13 @@ async function audiobookCast(overrideUrl, overrideModel) { view.processing(chunks[i]); // ── 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 { - const r = await fetch('/api/attribute-dialogue', { + const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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); } const d = await r.json(); 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 chunkA = chunks[i].slice(0, splitAt).trim(); const chunkB = chunks[i].slice(splitAt).trim(); - const resA = await attributeChunk(chunkA, recent); - const resB = await attributeChunk(chunkB, recent); + const resA = await attributeChunk(chunkA, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS); + const resB = await attributeChunk(chunkB, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS); const segsA = (resA && !resA.error) ? resA : null; const segsB = (resB && !resB.error) ? resB : null;