From 3fd4f7d0520d91a9171fcd7e8461e13f8e26b7a1 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 5 Jul 2026 20:18:41 +0200 Subject: [PATCH] Fix UTF-8 mojibake, thinking-pane duplication, stale popup closures (v1.12.97) The streaming attribution endpoint decoded the LLM's SSE response with requests' guessed encoding (Latin-1 fallback when no charset is declared), mangling every German umlaut. Forced UTF-8 explicitly. The "LLM Thinking" pane duplicated the passage text for models that ignore the instruction and stream straight into JSON - it now only shows real reasoning when present, and otherwise labels raw output honestly instead of passing it off as thinking. Also fixed three UI bugs found while testing a live multi-hour cast: - A-/A+ font buttons had no effect (a hardcoded font-size on .ab-cv-row always overrode the CSS variable they set). - Typing a name + Enter in the "Assign to" popup (and drag-to-assign, which reuses it) silently did nothing after the first cast/recast run in a session - the popup is a page-lifetime singleton but its input handlers closed over the first run's now-stale assignName/closePopup. Every popup open now repoints them at the current run. - "Split text to Unknown Speaker" split at the wrong spot when the selected phrase repeated earlier in the same paragraph (indexOf found the first occurrence, not the dragged one). Now uses the exact DOM range offset instead. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 +++++ VERSION | 2 +- routes/conversation.py | 5 ++ static/index.html | 6 +-- static/js/audiobook.js | 104 +++++++++++++++++++++++++++++------------ static/style.css | 6 ++- 6 files changed, 99 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7cb52a..2aa9e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.12.97] — 2026-07-05 + +### Fixed +- **German umlauts mojibake'd during live casting** ("Häfen" → "Häfen") — the streaming attribution endpoint decoded the LLM's SSE response with `requests`' guessed encoding, which falls back to Latin-1 when the upstream doesn't declare a charset. Forced UTF-8 explicitly. +- **"LLM Thinking" pane duplicated the passage text** for models that ignore the reasoning-preamble instruction and stream straight into JSON. It now only shows genuine `...` content when present, and otherwise labels the pane honestly ("Live Output (raw — no reasoning exposed)") instead of passing off raw JSON as thinking. +- **A−/A+ font-size buttons had no visible effect** — `.ab-cv-row` had a hardcoded `font-size: 13.5px` that always overrode the CSS variable the buttons set on an ancestor. Now scales with it. +- **Typing a new name + Enter in the "Assign to" popup silently did nothing** (after the first cast/recast run in a session) — the popup is a page-lifetime singleton, but its input's Enter/typing handlers closed over whichever run's `assignName` existed the *first* time the popup was created. Every later run's popup opens now repoint those handlers at the current run. +- **Click-and-drag assign stopped working after the first run**, for the same reason as above (drag pre-fills the popup, then confirming it hit the same stale closure). +- **"Split text to Unknown Speaker" split at the wrong spot** when the selected phrase (or a whitespace-trimmed variant) occurred earlier in the same paragraph — it searched for the text with `indexOf` instead of using the actual selection position. Now computes the exact DOM-range character offset, so it always splits where you dragged, regardless of repeated text elsewhere in the passage. + +--- + ## [1.12.96] — 2026-07-05 ### Fixed diff --git a/VERSION b/VERSION index 2fbc2df..3940254 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.96 +1.12.97 diff --git a/routes/conversation.py b/routes/conversation.py index 4fdb02b..844d79e 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -1228,6 +1228,11 @@ async def attribute_dialogue_stream(request: Request): yield f'data: {json.dumps({"error": f"HTTP {upstream.status_code}: {_response_error_text(upstream)[:300]}"})}\n\n' return raw_parts = [] + # The LLM's SSE response usually omits a charset on its Content-Type, + # so requests falls back to Latin-1 (the old HTTP default) instead of + # UTF-8 when decode_unicode=True guesses the encoding — every umlaut + # then comes out mojibake'd ("Häfen" -> "Häfen"). Force UTF-8. + upstream.encoding = "utf-8" for line in upstream.iter_lines(decode_unicode=True): if not line or not line.startswith("data:"): continue diff --git a/static/index.html b/static/index.html index ce34f1d..128b89a 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index d4a758d..7cc41a6 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -2323,16 +2323,25 @@ STRIKTE FORMAT- UND TEXTREGELN: assignPopup.appendChild(list); document.body.appendChild(assignPopup); - header.querySelector('.mdi-close').addEventListener('click', closeAssignPopup); - + // assignPopup is a page-lifetime DOM singleton, but audiobookCastView() + // (and its assignName/closeAssignPopup/roster closures) gets re-created + // on every fresh cast/recast run. Listeners attached here would otherwise + // permanently close over whichever run's functions existed the first + // time this "if (!assignPopup)" block ran — so typing a new name and + // hitting Enter would silently call a stale, disconnected assignName + // from an earlier session. _abOpenAssignPopup() refreshes + // assignPopup._assignName/_close on every open, so route through those + // instead of the closed-over references. + header.querySelector('.mdi-close').addEventListener('click', () => assignPopup._close?.()); + document.addEventListener('mousedown', e => { if (assignPopup.style.display !== 'none' && !assignPopup.contains(e.target) && !e.target.closest('.ab-cv-spk')) { - closeAssignPopup(); + assignPopup._close?.(); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && assignPopup.style.display !== 'none') { - closeAssignPopup(); + assignPopup._close?.(); } }); inp.addEventListener('keydown', e => { @@ -2345,10 +2354,10 @@ STRIKTE FORMAT- UND TEXTREGELN: visibleBtns[0].click(); } else { const val = inp.value.trim(); - if (val) assignName(val); + if (val) assignPopup._assignName?.(val); } } else if (e.key === 'Escape') { - closeAssignPopup(); + assignPopup._close?.(); } }); inp.addEventListener('input', () => { @@ -2371,7 +2380,7 @@ STRIKTE FORMAT- UND TEXTREGELN: addBtn.onmouseout = () => addBtn.style.background = 'transparent'; } addBtn.innerHTML = `+ Add "${escHtml(inp.value.trim())}"`; - addBtn.onclick = () => assignName(inp.value.trim()); + addBtn.onclick = () => assignPopup._assignName?.(inp.value.trim()); addBtn.style.display = 'flex'; assignPopup.querySelector('.ab-cv-popup-list').appendChild(addBtn); // keep at bottom } else if (addBtn) { @@ -2431,6 +2440,12 @@ STRIKTE FORMAT- UND TEXTREGELN: assignModeSeg = row.__seg; assignModeRow = row; row.classList.add('is-assigning'); + // Re-point the popup's shared listeners at THIS view's assignName/close — + // audiobookCastView() is re-invoked per cast/recast run, so these must be + // refreshed on every open rather than captured once (see the singleton + // setup above for why). + assignPopup._assignName = assignName; + assignPopup._close = closeAssignPopup; window.getSelection().removeAllRanges(); assignPopup.style.display = 'flex'; @@ -2625,6 +2640,19 @@ STRIKTE FORMAT- UND TEXTREGELN: } let currentSplitState = null; + // Character offset of a (node, offset) point within txtSpan's full text + // content — used instead of fullText.indexOf(selectedText) below, which + // silently split at the WRONG spot whenever the selected phrase (or a + // trimmed/whitespace variant of it) occurred more than once earlier in the + // same paragraph. Range-counting always finds the exact spot you dragged, + // no matter how common the selected text is elsewhere in the passage. + const _abOffsetInEl = (el, node, offset) => { + const r = document.createRange(); + r.selectNodeContents(el); + r.setEnd(node, offset); + return r.toString().length; + }; + document.addEventListener('selectionchange', () => { if (!feed) return; // Panel closed const sel = window.getSelection(); @@ -2635,10 +2663,10 @@ STRIKTE FORMAT- UND TEXTREGELN: } const txtSpan = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode.closest('.ab-cv-txt') : sel.anchorNode.closest('.ab-cv-txt'); if (!txtSpan) { splitBtn.style.display = 'none'; return; } - + const row = txtSpan.closest('.ab-cv-row'); if (!row || !row.__seg) return; - + const rawText = sel.toString(); const text = rawText.trim(); if (text.length > 0) { @@ -2647,12 +2675,18 @@ STRIKTE FORMAT- UND TEXTREGELN: splitBtn.style.display = 'none'; return; } - - const rect = sel.getRangeAt(0).getBoundingClientRect(); + + const range = sel.getRangeAt(0); + const rect = range.getBoundingClientRect(); splitBtn.style.display = 'block'; splitBtn.style.top = (rect.bottom + 8) + 'px'; splitBtn.style.left = Math.max(10, rect.left + (rect.width / 2) - 100) + 'px'; - currentSplitState = { row, text, rawText, seg: row.__seg }; + // Forward selections have anchor==start; a backward drag (dragging + // right-to-left) has anchor==end, so use range.start/end (always + // document-order) rather than sel.anchor/focus for offset math. + const startOffset = _abOffsetInEl(txtSpan, range.startContainer, range.startOffset); + const endOffset = _abOffsetInEl(txtSpan, range.endContainer, range.endOffset); + currentSplitState = { row, text, rawText, seg: row.__seg, startOffset, endOffset }; } else { splitBtn.style.display = 'none'; currentSplitState = null; @@ -2661,18 +2695,12 @@ STRIKTE FORMAT- UND TEXTREGELN: splitBtn.addEventListener('click', () => { if (!currentSplitState) return; - const { row, text, rawText, seg } = currentSplitState; + const { row, seg, startOffset, endOffset } = currentSplitState; const fullText = seg.text || ''; - let selectedText = rawText || text; - let idx = fullText.indexOf(selectedText); - if (idx === -1) { - selectedText = text; - idx = fullText.indexOf(selectedText); - } - if (idx === -1) return; - - const before = fullText.substring(0, idx); - const after = fullText.substring(idx + selectedText.length); + const before = fullText.slice(0, startOffset); + const selectedText = fullText.slice(startOffset, endOffset); + const after = fullText.slice(endOffset); + if (!selectedText) return; // Try committed segments first, then live array (during active casting) let arr = _audiobook.segments || []; @@ -2798,7 +2826,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
${preview}