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) {
-
+