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 <think> 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 <noreply@anthropic.com>
This commit is contained in:
parent
6bc98e2b62
commit
3fd4f7d052
12
CHANGELOG.md
12
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 `<think>...</think>` 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.12.96">
|
||||
<meta name="app-version" content="1.12.97">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.96">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.97">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.12.96"></script>
|
||||
<script src="/static/loader.js?v=1.12.97"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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 = `<span style="font-size:14px; font-weight:bold;">+</span> 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:
|
||||
<div class="ab-cv-llm-preview">${preview}</div>
|
||||
<div class="ab-cv-llm-split" hidden>
|
||||
<div class="ab-cv-llm-col">
|
||||
<div class="ab-cv-llm-col-label"><span class="mdi mdi-head-dots-horizontal-outline"></span> LLM Thinking…</div>
|
||||
<div class="ab-cv-llm-col-label ab-cv-think-label"><span class="mdi mdi-head-dots-horizontal-outline"></span> LLM Thinking…</div>
|
||||
<pre class="ab-cv-think"></pre>
|
||||
</div>
|
||||
<div class="ab-cv-llm-col">
|
||||
@ -2824,21 +2852,35 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
// append and keep the thinking pane scrolled to the newest text.
|
||||
thinking(delta) {
|
||||
if (!this._procRow || !delta || this._thinkDone) return;
|
||||
this._thinkRaw = (this._thinkRaw || '') + delta;
|
||||
const split = this._procRow.querySelector('.ab-cv-llm-split');
|
||||
const think = this._procRow.querySelector('.ab-cv-think');
|
||||
const label = this._procRow.querySelector('.ab-cv-think-label');
|
||||
if (!split || !think) return;
|
||||
if (split.hidden) {
|
||||
split.hidden = false;
|
||||
const prev = this._procRow.querySelector('.ab-cv-llm-preview');
|
||||
if (prev) prev.hidden = true;
|
||||
}
|
||||
// The model wraps its reasoning in <think>...</think>; once it closes,
|
||||
// stop appending — everything after is the raw JSON answer, not
|
||||
// "thinking", and dumping it here just reproduces the passage text.
|
||||
this._thinkRaw = (this._thinkRaw || '') + delta;
|
||||
const closeAt = this._thinkRaw.indexOf('</think>');
|
||||
const shown = closeAt >= 0 ? this._thinkRaw.slice(0, closeAt) : this._thinkRaw;
|
||||
think.textContent = shown.replace(/<think>/g, '').trim().slice(-20000);
|
||||
// Some models wrap real reasoning in <think>...</think>; others ignore
|
||||
// that instruction and stream straight into the JSON answer. Show
|
||||
// whichever is actually arriving — live progress beats nothing — but
|
||||
// label it honestly instead of calling raw JSON "thinking".
|
||||
const openAt = this._thinkRaw.indexOf('<think>');
|
||||
let shown, isReal = false;
|
||||
if (openAt >= 0) {
|
||||
const afterOpen = this._thinkRaw.slice(openAt + '<think>'.length);
|
||||
const closeAt = afterOpen.indexOf('</think>');
|
||||
shown = closeAt >= 0 ? afterOpen.slice(0, closeAt) : afterOpen;
|
||||
isReal = true;
|
||||
if (closeAt >= 0) this._thinkDone = true;
|
||||
} else {
|
||||
shown = this._thinkRaw;
|
||||
}
|
||||
if (label) label.innerHTML = isReal
|
||||
? '<span class="mdi mdi-head-dots-horizontal-outline"></span> LLM Thinking…'
|
||||
: '<span class="mdi mdi-code-json"></span> Live Output (raw — no reasoning exposed)';
|
||||
think.textContent = shown.trim().slice(-20000);
|
||||
think.scrollTop = think.scrollHeight;
|
||||
if (closeAt >= 0) this._thinkDone = true;
|
||||
},
|
||||
|
||||
@ -5200,7 +5200,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.ab-cv-row {
|
||||
display: block;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 13.5px;
|
||||
/* Fixed px here silently defeated the A-/A+ controls (which set
|
||||
--reh-stage-scale on an ancestor) — this hardcoded size always won over
|
||||
the inherited calc() on .ab-cv-page, so the buttons visibly changed
|
||||
nothing. Scale it the same way. */
|
||||
font-size: calc(13.5px * var(--reh-stage-scale, 1));
|
||||
line-height: 1.5;
|
||||
padding: 20px 50px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user