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}