Keep audiobook casting moving on slow LLMs
This commit is contained in:
parent
32a3c838fe
commit
34e4bb99e1
@ -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
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user