fix(ui): surface silently swallowed save and fetch errors

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-06 00:44:31 +02:00
parent 2479617964
commit 60eb108360
16 changed files with 32 additions and 30 deletions

View File

@ -12,6 +12,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
### Fixed ### Fixed
- Async routes no longer block the event loop on synchronous HTTP: seed proxy/samples, text refine/persona rewrite, voice matching (its retry back-off is now `asyncio.sleep`), character deep analysis, character image-from-URL, LLM/engine model listing, source audio import/proxy and ElevenLabs shared voices now run `requests` through `asyncio.to_thread` (same timeouts and responses). - Async routes no longer block the event loop on synchronous HTTP: seed proxy/samples, text refine/persona rewrite, voice matching (its retry back-off is now `asyncio.sleep`), character deep analysis, character image-from-URL, LLM/engine model listing, source audio import/proxy and ElevenLabs shared voices now run `requests` through `asyncio.to_thread` (same timeouts and responses).
- Silently swallowed errors now surface: failed saves of the audiobook prompt default, generated character sheets and character colours show an error toast, and ~20 other hidden failures (rehearsal/cast write-back, reader progress and audio fetches, seed-finder DB writes, settings/model fetches, export server save) are logged with `logErr` instead of being dropped.
### Changed ### Changed
- Conversation Playground: the Language Model base URL is now picked from a dropdown of the language models configured under Engines (Docker LLM containers incl. saved URL overrides, and custom LLM cards; running/stopped marked), with a "Custom…" option that reveals the free-text URL. Selecting an entry saves `conv_llm_url` and fetches the models. - Conversation Playground: the Language Model base URL is now picked from a dropdown of the language models configured under Engines (Docker LLM containers incl. saved URL overrides, and custom LLM cards; running/stopped marked), with a "Custom…" option that reveals the free-text URL. Selecting an entry saves `conv_llm_url` and fetches the models.
- Playwright specs now import `test` from `tests/_guard.js`, whose auto fixture aborts and fails any unmocked non-GET request to `/api/**` so a spec can no longer modify live settings. See `architecture/qa.md`. - Playwright specs now import `test` from `tests/_guard.js`, whose auto fixture aborts and fails any unmocked non-GET request to `/api/**` so a spec can no longer modify live settings. See `architecture/qa.md`.

File diff suppressed because one or more lines are too long

View File

@ -434,7 +434,7 @@
url = s.llm_url || ''; url = s.llm_url || '';
if (!_appSettings) _appSettings = s; if (!_appSettings) _appSettings = s;
else { _appSettings.llm_url = s.llm_url || _appSettings.llm_url; _appSettings.llm_model = s.llm_model || _appSettings.llm_model; } else { _appSettings.llm_url = s.llm_url || _appSettings.llm_url; _appSettings.llm_model = s.llm_model || _appSettings.llm_model; }
} catch (_) {} } catch (_) { logErr('llm-url-settings', _); }
} }
// wait a tick for docker cards to render so options are available // wait a tick for docker cards to render so options are available
@ -564,7 +564,7 @@
_appSettings.vision_ocr_model = s.vision_ocr_model || _appSettings.vision_ocr_model; _appSettings.vision_ocr_model = s.vision_ocr_model || _appSettings.vision_ocr_model;
_appSettings.vision_ocr_enabled = s.vision_ocr_enabled; _appSettings.vision_ocr_enabled = s.vision_ocr_enabled;
} }
} catch (_) {} } catch (_) { logErr('ocr-settings', _); }
} }
if (enabledCb) enabledCb.checked = enabled !== false; if (enabledCb) enabledCb.checked = enabled !== false;
setTimeout(() => { setTimeout(() => {
@ -593,7 +593,7 @@
`<option value="${escHtml(b.id)}"${!b.available ? ' disabled' : ''}>${b.available ? '✓' : '✗'} ${escHtml(b.label)}</option>` `<option value="${escHtml(b.id)}"${!b.available ? ' disabled' : ''}>${b.available ? '✓' : '✗'} ${escHtml(b.label)}</option>`
).join(''); ).join('');
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) sel.value = prev; if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) sel.value = prev;
} catch(_) {} } catch (_) { logErr('backend-list', _); }
} }
refreshSttTestBackends(); refreshSttTestBackends();

View File

@ -652,7 +652,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
// Also save as global default for next time // Also save as global default for next time
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = val; if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = val;
try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: val }) }); } catch (e) {} try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: val }) }); } catch (e) { logErr('audiobook-prompt-save', e); toast('Could not save the prompt as the default', 'error'); }
}); });
// Sync the LLM engine dropdown with global options // Sync the LLM engine dropdown with global options
@ -1163,7 +1163,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
: (typeof clGetAll === 'function' ? await clGetAll() : []); : (typeof clGetAll === 'function' ? await clGetAll() : []);
for (const r of all || []) registerCharacterRecord(r); for (const r of all || []) registerCharacterRecord(r);
rec = recordForName(name); rec = recordForName(name);
} catch (_) {} } catch (_) { logErr('character-record-load', _); }
const detail = document.createElement('div'); const detail = document.createElement('div');
detail.className = 'ab-char-detail-panel'; detail.className = 'ab-char-detail-panel';
@ -1256,7 +1256,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
_abRefreshCharacterColors(rec.name); _abRefreshCharacterColors(rec.name);
clearTimeout(_audiobook._colorSaveTimer); clearTimeout(_audiobook._colorSaveTimer);
_audiobook._colorSaveTimer = setTimeout(async () => { _audiobook._colorSaveTimer = setTimeout(async () => {
try { if (typeof clPut === 'function') await clPut(rec); } catch (_) {} try { if (typeof clPut === 'function') await clPut(rec); } catch (_) { logErr('character-colour-save', _); toast('Could not save the character colour', 'error'); }
}, 400); }, 400);
}); });
detail.querySelector('.ab-cd-pick')?.addEventListener('click', () => { detail.querySelector('.ab-cd-pick')?.addEventListener('click', () => {

View File

@ -507,7 +507,7 @@ async function audiobookOpenCastView() {
const bookId = _abBookId(); const bookId = _abBookId();
let chosen = _localDraft, source = 'local', serverDraft = null; let chosen = _localDraft, source = 'local', serverDraft = null;
if (bookId) { if (bookId) {
try { serverDraft = await _abLoadDraftServer(bookId); } catch (_) {} try { serverDraft = await _abLoadDraftServer(bookId); } catch (_) { logErr('draft-server-load', _); }
if (serverDraft && (serverDraft.savedAt || 0) > (_localDraft.savedAt || 0)) { if (serverDraft && (serverDraft.savedAt || 0) > (_localDraft.savedAt || 0)) {
chosen = serverDraft; source = 'server'; chosen = serverDraft; source = 'server';
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch (_) {} try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch (_) {}

View File

@ -132,7 +132,7 @@ async function audiobookOpenInRehearser(script, title, dialogueEmotions) {
// Script Rehearser → Bibliothek (Library) and reopen anytime to edit & synthesise. // Script Rehearser → Bibliothek (Library) and reopen anytime to edit & synthesise.
let saved = false; let saved = false;
if (typeof saveToLibrary === 'function') { if (typeof saveToLibrary === 'function') {
try { rehState.savedId = null; await saveToLibrary(); saved = true; } catch (_) {} try { rehState.savedId = null; await saveToLibrary(); saved = true; } catch (_) { logErr('rehearsal-save', _); }
} }
toast(`Cast ${speakers} character${speakers !== 1 ? 's' : ''} · ${lines} lines` + (saved ? ' — saved to Rehearser → Bibliothek' : ''), 'success'); toast(`Cast ${speakers} character${speakers !== 1 ? 's' : ''} · ${lines} lines` + (saved ? ' — saved to Rehearser → Bibliothek' : ''), 'success');
} }
@ -649,7 +649,7 @@ async function audiobookExport(opts = {}) {
const saveUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}${folderQS}`; const saveUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}${folderQS}`;
const saveResp = await fetch(saveUrl, { method: 'POST', body: blob }); const saveResp = await fetch(saveUrl, { method: 'POST', body: blob });
if (saveResp.ok) savedFiles.push({ name, url: saveUrl }); if (saveResp.ok) savedFiles.push({ name, url: saveUrl });
} catch (_) {} } catch (_) { logErr('export-server-save', _); }
files++; files++;
await new Promise(r => setTimeout(r, 400)); await new Promise(r => setTimeout(r, 400));
} }

View File

@ -1624,7 +1624,7 @@ async function _audiobookBuildRehRecord(segs) {
// Fetch the existing record and merge its cast so voice assignments survive an update // Fetch the existing record and merge its cast so voice assignments survive an update
let existing = null; let existing = null;
if (_audiobook.rehId && typeof window.rehDbGetById === 'function') { if (_audiobook.rehId && typeof window.rehDbGetById === 'function') {
try { existing = await window.rehDbGetById(_audiobook.rehId); } catch (_) {} try { existing = await window.rehDbGetById(_audiobook.rehId); } catch (_) { logErr('rehearsal-db-lookup', _); }
} }
if (existing && existing.cast) { if (existing && existing.cast) {
Object.entries(existing.cast).forEach(([sp, info]) => { Object.entries(existing.cast).forEach(([sp, info]) => {

View File

@ -587,7 +587,7 @@
list.innerHTML = '<div class="bench-engine-empty">Checking STT engines...</div>'; list.innerHTML = '<div class="bench-engine-empty">Checking STT engines...</div>';
let settings = {}; let settings = {};
const items = []; const items = [];
try { settings = await fetchJson('/api/settings'); } catch (_) {} try { settings = await fetchJson('/api/settings'); } catch (_) { logErr('bench-settings', _); }
try { try {
const d = await fetchJson('/api/stt-backends'); const d = await fetchJson('/api/stt-backends');
@ -852,7 +852,7 @@
try { try {
const settings = await fetchJson('/api/settings'); const settings = await fetchJson('/api/settings');
if (q('bench-turn-llm-url')) q('bench-turn-llm-url').value = settings.conv_llm_url || settings.llm_url || ''; if (q('bench-turn-llm-url')) q('bench-turn-llm-url').value = settings.conv_llm_url || settings.llm_url || '';
} catch (_) {} } catch (_) { logErr('bench-turn-settings', _); }
try { try {
const d = await fetchJson('/api/stt-backends'); const d = await fetchJson('/api/stt-backends');
const sel = q('bench-turn-stt'); const sel = q('bench-turn-stt');

View File

@ -2271,7 +2271,7 @@ async function csShow(sheets, title, sourceText, book, hostEl = null) {
const id = `${bk}::${s.name}`.toLowerCase(); const id = `${bk}::${s.name}`.toLowerCase();
if (imgMap.has(id)) s._image = imgMap.get(id); if (imgMap.has(id)) s._image = imgMap.get(id);
}); });
} catch (_) {} } catch (_) { logErr('character-sheets-images', _); }
} }
// Browsing grid, not the profile itself — use the SAME compact card the // Browsing grid, not the profile itself — use the SAME compact card the
@ -2403,7 +2403,7 @@ async function csAutoGenerateExternalPrompts(book, sheets) {
if (repeatCount >= 3) break; if (repeatCount >= 3) break;
} }
} }
try { await clUpsertMany(book, sheets); } catch (_) {} try { await clUpsertMany(book, sheets); } catch (_) { logErr('character-sheets-save', _); toast('Could not save the generated sheets to the library', 'error'); }
const suffix = failed ? ` (${failed} failed${repeatMsg ? ': ' + repeatMsg.slice(0, 160) : ''})` : ''; const suffix = failed ? ` (${failed} failed${repeatMsg ? ': ' + repeatMsg.slice(0, 160) : ''})` : '';
toast(`External prompts generated for ${done} character${done !== 1 ? 's' : ''}${suffix}`, failed && !done ? 'error' : 'success'); toast(`External prompts generated for ${done} character${done !== 1 ? 's' : ''}${suffix}`, failed && !done ? 'error' : 'success');

View File

@ -282,7 +282,7 @@ async function castWriteBack(title, castMap) {
if (curId === voice) continue; if (curId === voice) continue;
rec.voice = { id: voice }; rec.voice = { id: voice };
rec.updated = new Date(); rec.updated = new Date();
try { await clPut(rec); n++; } catch (_) {} try { await clPut(rec); n++; } catch (_) { logErr('library-voice-assign', _); }
} }
return n; return n;
} }

View File

@ -1327,7 +1327,7 @@ async function readerFetchServerAudio(idx) {
try { try {
const r = await fetch(`${READER_API}/${readerState.savedId}/audio/${idx}`); const r = await fetch(`${READER_API}/${readerState.savedId}/audio/${idx}`);
if (r.ok) { const b = await r.blob(); readerState.blobCache.set(idx, b); return b; } if (r.ok) { const b = await r.blob(); readerState.blobCache.set(idx, b); return b; }
} catch (_) {} } catch (_) { logErr('reader-audio-fetch', _); }
return null; return null;
} }
@ -1596,7 +1596,7 @@ async function readerPersistProgress() {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idx: readerState.idx, updated: new Date().toISOString() }), body: JSON.stringify({ idx: readerState.idx, updated: new Date().toISOString() }),
}); });
} catch (_) {} } catch (_) { logErr('reader-progress-save', _); }
} }
async function readerDeleteLibraryDoc(id) { async function readerDeleteLibraryDoc(id) {
@ -1632,7 +1632,7 @@ async function readerOpenLibraryDoc(id) {
if ($('reader-instruct')) $('reader-instruct').value = rec.instruct || ''; if ($('reader-instruct')) $('reader-instruct').value = rec.instruct || '';
// fetch the source document from the server // fetch the source document from the server
let sourceBlob = null; let sourceBlob = null;
try { const sr = await fetch(`${READER_API}/${id}/source`); if (sr.ok) sourceBlob = await sr.blob(); } catch (_) {} try { const sr = await fetch(`${READER_API}/${id}/source`); if (sr.ok) sourceBlob = await sr.blob(); } catch (_) { logErr('reader-source-fetch', _); }
try { try {
if (rec.kind === 'pdf') { if (rec.kind === 'pdf') {
if (!sourceBlob) { if (!sourceBlob) {

View File

@ -92,7 +92,7 @@ async function saveToLibrary() {
toast('Saved to library', 'success'); toast('Saved to library', 'success');
rehState._keepSavedIdOnce = true; rehState._keepSavedIdOnce = true;
// Sync cast voice choices into the shared character roster (existing chars only) // Sync cast voice choices into the shared character roster (existing chars only)
if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) {} } if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) { logErr('cast-write-back', _); } }
await renderLibraryList(); await renderLibraryList();
} }

View File

@ -1157,7 +1157,7 @@ async function rehResearchCast(speakers) {
}), }),
}); });
if (r.ok) chars = (await r.json()).characters || []; if (r.ok) chars = (await r.json()).characters || [];
} catch (_) {} } catch (_) { logErr('reh-character-analysis', _); }
const byName = {}; const byName = {};
chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; }); chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
speakers.forEach(sp => rehWriteCharacterNote(sp, byName[sp.toUpperCase().trim()])); speakers.forEach(sp => rehWriteCharacterNote(sp, byName[sp.toUpperCase().trim()]));

View File

@ -54,7 +54,7 @@ async function _sfDbGet(key) {
catch (_) { return null; } catch (_) { return null; }
} }
async function _sfDbPut(rec) { async function _sfDbPut(rec) {
try { const db = await _sfDbOpen(); await new Promise((res, rej) => { const r = db.transaction(SF_STORE, 'readwrite').objectStore(SF_STORE).put(rec); r.onsuccess = () => res(); r.onerror = e => rej(e.target.error); }); } catch (_) {} try { const db = await _sfDbOpen(); await new Promise((res, rej) => { const r = db.transaction(SF_STORE, 'readwrite').objectStore(SF_STORE).put(rec); r.onsuccess = () => res(); r.onerror = e => rej(e.target.error); }); } catch (_) { logErr('seed-db-put', _); }
} }
async function _sfDbAllForVoice(voiceId) { async function _sfDbAllForVoice(voiceId) {
try { try {
@ -75,7 +75,7 @@ async function _sfDbClearVoice(voiceId) {
cur.onsuccess = e => { const c = e.target.result; if (c) { if (c.value.voiceId === voiceId) store.delete(c.primaryKey); c.continue(); } else res(); }; cur.onsuccess = e => { const c = e.target.result; if (c) { if (c.value.voiceId === voiceId) store.delete(c.primaryKey); c.continue(); } else res(); };
cur.onerror = e => rej(e.target.error); cur.onerror = e => rej(e.target.error);
}); });
} catch (_) {} } catch (_) { logErr('seed-db-delete', _); }
} }
// Called from voice-inspector.js after the persona panel is appended. // Called from voice-inspector.js after the persona panel is appended.
@ -249,7 +249,7 @@ function attachSeedFinder(voiceId, body) {
resultsEl.appendChild(row); resultsEl.appendChild(row);
} }
} catch (_) {} } catch (_) { logErr('seed-results-render', _); }
} }
// Save (or clear, with seed=null) the voice's seed in the TTS server's voices.json. // Save (or clear, with seed=null) the voice's seed in the TTS server's voices.json.

View File

@ -255,7 +255,7 @@ async function _statusOpenLlmFlyup(anchor) {
const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url); const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
const data = await fetch(fetchUrl).then(r => r.json()); const data = await fetch(fetchUrl).then(r => r.json());
models = Array.isArray(data.models) ? data.models : []; models = Array.isArray(data.models) ? data.models : [];
} catch (_) {} } catch (_) { logErr('status-llm-models', _); }
if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting
_statusFlyupList(anchor, models.map(m => ({ id: m, label: m, active: m === model })), (picked) => { _statusFlyupList(anchor, models.map(m => ({ id: m, label: m, active: m === model })), (picked) => {
if (typeof _appSettings !== 'undefined') _appSettings.llm_model = picked; if (typeof _appSettings !== 'undefined') _appSettings.llm_model = picked;

View File

@ -1797,7 +1797,7 @@ $('lib-add-save').addEventListener('click', async () => {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice_id: voiceId, image_url: libAddState.pendingSource.imageUrl}) body: JSON.stringify({voice_id: voiceId, image_url: libAddState.pendingSource.imageUrl})
}); });
} catch (_) {} } catch (_) { logErr('voice-picture-save', _); toast('Voice added, but its picture could not be saved', 'error'); }
} }
libAddState.pendingSource = null; libAddState.pendingSource = null;
setLibAddSourcePreview({}); setLibAddSourcePreview({});