diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da7e79..c9eaa5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.12.95] — 2026-07-05 + +### Added +- **Watch the LLM think, live** — the "LLM Reading…" card now splits into two panes the moment the model starts responding: its raw output stream (reasoning + the answer JSON as it's written) on the left, the passage it's reading on the right. Backed by a new streaming endpoint (`/api/attribute-dialogue/stream`, SSE) that forwards both `reasoning_content` (thinking models) and `content` deltas; the prompt-building and answer-parsing are shared with the blocking endpoint so the two can never drift. Any stream failure falls back to the blocking endpoint automatically — with an inactivity timeout (reset on every received chunk) instead of an overall one, since a slow model legitimately takes minutes per passage but long silence means the stream died. Works in the main cast, Recast unknown, and 2nd Quality Run. + +### Fixed +- **Resolver invented characters from scenery words** — the deterministic colon-rule's fallback accepted any "der/die + capitalized noun", turning "auf dem Platz", "die Gesichter", "eine Kleinigkeit" into speakers PLATZ/GESICHTER/KLEINIGKEIT. The fallback is now a closed whitelist of person/role nouns, plus a clause-subject pattern ("Marcian unterdrückte seinen Ärger und sagte:" → Marcian). +- **More Unknowns resolved, safely** — two new deterministic rules: the impersonal post-quote formula ("ertönte es plötzlich über ihm. Karyla hatte…" → Karyla), and strict two-person alternation (both nearest preceding dialogue lines named and different → the one who didn't just speak), guarded to never fire across page boundaries, beyond a short window, or when the preceding narration ends with ":" (that colon introduces someone the other rules couldn't name — alternation would be a guess, not a deduction). All screenshot failure cases verified against the exact book sentences. + +--- + ## [1.12.94] — 2026-07-04 ### Fixed diff --git a/VERSION b/VERSION index 7c2b16b..a65ccb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.94 +1.12.95 diff --git a/routes/conversation.py b/routes/conversation.py index a130425..4faf0ef 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -934,16 +934,10 @@ async def character_generate_prompts(request: Request): return out -@router.post("/api/attribute-dialogue") -async def attribute_dialogue(request: Request): - """Split a prose passage into attributed segments for a multi-voice audiobook. - - Body: {text, known_characters:[...], language, llm_url, model} - Returns: {segments:[{speaker, type:"narration"|"dialogue", text, emotion}], characters:[names]} - The frontend calls this per chunk, passing the running character roster so the - same speaker keeps the same name across the whole book. - """ - data = await request.json() +def _attribution_prepare(data: dict) -> dict: + """Resolve settings and build the chat payload for one attribution request. + Shared by the blocking endpoint and the streaming (watch-the-LLM-think) + endpoint so the prompt logic can never drift between the two.""" text: str = (data.get("text") or "").strip() known: list = data.get("known_characters") or [] recent: str = (data.get("recent") or "").strip() # last few attributed lines, for continuity @@ -1054,14 +1048,36 @@ async def attribute_dialogue(request: Request): } if model: payload["model"] = model + return { + "payload": payload, "llm_url": llm_url, "model": model, + "api_key": _settings.get("llm_api_key") or "sk-dummy-key", + "timeout_seconds": timeout_seconds, "text": text, + "fallback_model": (_settings.get("llm_model") or "").strip(), + } + + +@router.post("/api/attribute-dialogue") +async def attribute_dialogue(request: Request): + """Split a prose passage into attributed segments for a multi-voice audiobook. + + Body: {text, known_characters:[...], language, llm_url, model} + Returns: {segments:[{speaker, type:"narration"|"dialogue", text, emotion}], characters:[names]} + The frontend calls this per chunk, passing the running character roster so the + same speaker keeps the same name across the whole book. + """ + data = await request.json() + prep = _attribution_prepare(data) + payload, llm_url, model = prep["payload"], prep["llm_url"], prep["model"] + timeout_seconds, text = prep["timeout_seconds"], prep["text"] + api_key = prep["api_key"] try: async with _attribution_llm_lock: resp = await asyncio.to_thread( _post_llm_chat_completion, llm_url, payload, - {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds, + {"Authorization": f"Bearer {api_key}"}, timeout_seconds, ) - fallback_model = (_settings.get("llm_model") or "").strip() + fallback_model = prep["fallback_model"] if ( resp.status_code >= 400 and _is_router_model_alias(model) @@ -1074,7 +1090,7 @@ async def attribute_dialogue(request: Request): resp = await asyncio.to_thread( _post_llm_chat_completion, llm_url, retry_payload, - {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds, + {"Authorization": f"Bearer {api_key}"}, timeout_seconds, ) payload = retry_payload if resp.status_code >= 400: @@ -1091,15 +1107,20 @@ async def attribute_dialogue(request: Request): print(f"[attribute-dialogue] LLM failed, using deterministic fallback: {e}") return _fallback_attribute_response(text) + return _attribution_parse(raw, text) + + +def _attribution_parse(raw: str, text: str) -> dict: + """Turn the LLM's raw answer into normalised {segments, characters}.""" content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw segments = [] - + block = _extract_json_block(content) candidates = [content, block] if block: # If the JSON was truncated by token limits, appending ]} to the last complete object often salvages it. candidates.append(block + "]}") - + for cand in candidates: if not cand: continue @@ -1110,7 +1131,7 @@ async def attribute_dialogue(request: Request): break except Exception: continue - + if not segments: # If the LLM completely failed to output JSON despite the strict system prompt, # it means it dropped into a conversational/reasoning hallucination. @@ -1136,6 +1157,89 @@ async def attribute_dialogue(request: Request): return {"segments": clean, "characters": chars} +# Streaming attribution runs in a plain thread (StreamingResponse with a sync +# generator), so it serializes on a threading.Lock rather than the asyncio one. +_attribution_stream_tlock = threading.Lock() + + +@router.post("/api/attribute-dialogue/stream") +async def attribute_dialogue_stream(request: Request): + """Same job as /api/attribute-dialogue, but streams the LLM's live output + (reasoning + the JSON being written) as SSE `{"t": "..."}` events so the UI + can show what the model is thinking, ending with `{"done": true, "result"}`. + On upstream failure it emits `{"error": "..."}` — the client then falls + back to the blocking endpoint.""" + data = await request.json() + prep = _attribution_prepare(data) + payload = dict(prep["payload"]) + payload["stream"] = True + headers = {"Authorization": f"Bearer {prep['api_key']}"} + + def gen(): + if not _attribution_stream_tlock.acquire(timeout=prep["timeout_seconds"]): + yield 'data: {"error": "Attribution engine busy"}\n\n' + return + upstream = None + try: + urls = _llm_chat_completion_urls(prep["llm_url"]) + last_err = None + for u in urls: + try: + upstream = requests.post( + u, json=payload, headers=headers, + timeout=(15, prep["timeout_seconds"]), stream=True, + ) + if upstream.status_code in (404, 405) and u != urls[-1]: + upstream.close() + upstream = None + continue + break + except Exception as exc: + last_err = exc + upstream = None + if upstream is None: + yield f'data: {json.dumps({"error": str(last_err or "LLM connect failed")})}\n\n' + return + if upstream.status_code >= 400: + yield f'data: {json.dumps({"error": f"HTTP {upstream.status_code}: {_response_error_text(upstream)[:300]}"})}\n\n' + return + raw_parts = [] + for line in upstream.iter_lines(decode_unicode=True): + if not line or not line.startswith("data:"): + continue + chunk = line[5:].strip() + if chunk == "[DONE]": + break + try: + delta = json.loads(chunk)["choices"][0]["delta"] + except Exception: + continue + # Reasoning models emit thoughts via reasoning_content; the + # answer JSON arrives via content. Forward both to the viewer, + # but only content counts toward the parseable answer. + t = delta.get("reasoning_content") or delta.get("content") or "" + if delta.get("content"): + raw_parts.append(delta["content"]) + if t: + yield f'data: {json.dumps({"t": t})}\n\n' + result = _attribution_parse("".join(raw_parts), prep["text"]) + yield f'data: {json.dumps({"done": True, "result": result})}\n\n' + except GeneratorExit: + raise + except Exception as e: + yield f'data: {json.dumps({"error": str(e)})}\n\n' + finally: + try: + if upstream is not None: + upstream.close() + except Exception: + pass + _attribution_stream_tlock.release() + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + # ── Audio effects ───────────────────────────────────────────────────────────── def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes: diff --git a/static/index.html b/static/index.html index b8b4cab..2a20f81 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 af29361..f3050c1 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -59,6 +59,66 @@ function audiobookTimeoutSeconds(timeoutMs) { return Math.max(5, Math.round(timeoutMs / 1000)); } +// Streamed attribution: reads the SSE feed from /api/attribute-dialogue/stream, +// pushing each delta into view.thinking() so the user can watch the LLM work, +// and resolves with the final parsed result. Uses an INACTIVITY timeout (reset +// on every received chunk) rather than an overall one — a passage legitimately +// takes minutes on a slow model, but silence that long means the stream died. +// Throws on any failure; callers fall back to the blocking endpoint. A user +// Stop (outer signal) propagates as AbortError; a stalled stream does not. +async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) { + const ctl = new AbortController(); + const onAbort = () => ctl.abort(); + if (outerSignal) { + if (outerSignal.aborted) ctl.abort(); + else outerSignal.addEventListener('abort', onAbort, { once: true }); + } + let idleTimer = null; + const armIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs); + }; + try { + armIdle(); + const r = await fetch('/api/attribute-dialogue/stream', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: ctl.signal, body: JSON.stringify(body), + }); + if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status); + const reader = r.body.getReader(); + const dec = new TextDecoder(); + let buf = '', result = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + armIdle(); + buf += dec.decode(value, { stream: true }); + let at; + while ((at = buf.indexOf('\n\n')) >= 0) { + const line = buf.slice(0, at).trim(); + buf = buf.slice(at + 2); + if (!line.startsWith('data:')) continue; + let d; + try { d = JSON.parse(line.slice(5)); } catch (_) { continue; } + if (d.t && view?.thinking) view.thinking(d.t); + if (d.error) throw new Error(d.error); + if (d.done) result = d.result || null; + } + } + if (!result) throw new Error('stream ended without result'); + return result; + } catch (err) { + // An idle-timeout abort is a stream failure (fall back), not a user Stop. + if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) { + throw new Error('stream idle timeout'); + } + throw err; + } finally { + clearTimeout(idleTimer); + if (outerSignal) outerSignal.removeEventListener('abort', onAbort); + } +} + function audiobookDialogueKey(text) { return String(text || '') .normalize('NFKC') @@ -565,17 +625,33 @@ const AB_NOTNAME = new Set([ 'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']); const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)"; -// Deterministic Unknown-resolution — the LLM keeps missing two mechanical +// Person/role nouns that are plausible speakers when no proper name is found. +// A generic "der/die + any capitalized noun" fallback assigned scenery words +// as characters (PLATZ from "auf dem Platz", GESICHTER, KLEINIGKEIT) — a +// closed whitelist can't make that class of mistake. +const AB_PERSON_NOUNS = new Set(['Mann','Frau','Junge','Mädchen','Alte','Alter','Fremde','Fremder','Krieger','Kriegerin', + 'Wächter','Wache','Soldat','Hauptmann','Ork','Ritter','Magier','Magierin','Zwerg','Elf','Elfe','Händler','Wirt','Wirtin', + 'Bauer','Priester','Priesterin','Nachbar','Nachbarin','Sklave','Sklavin','Verweser','Inquisitor','General','König','Königin', + 'Prinz','Prinzessin','Fürst','Fürstin','Baron','Baronin','Bote','Diener','Dienerin','Knabe','Kind','Reiter','Reiterin', + 'Bogenschütze','Schmied','Schmiedin','Heiler','Heilerin','Gelehrte','Gelehrter','Kapitän','Anführer','Anführerin']); + +// Deterministic Unknown-resolution — the LLM keeps missing mechanical // patterns no matter how explicitly the prompt spells them out (measured: // ~44% Unknown on a full book WITH the rules in the prompt), so apply them // in code where they cannot be ignored: // A. Colon rule: the narration right before a quote ends with ":" → its -// last-mentioned character/role speaks ("… und rief in die Runde:"). +// last-mentioned character (roster name, whitelisted person noun, or the +// "Name … und sagte:" subject) speaks. // B. Post-quote inquit: the narration right after a quote starts with a -// speech verb + Name (or "Der X, der gesprochen hatte") → X spoke. +// speech verb + Name, "Der X, der gesprochen hatte", or the impersonal +// "ertönte es … . Name …" formula → that character spoke. +// C. Two-person alternation: an Unknown whose two nearest preceding dialogue +// lines have two different known speakers gets the earlier of the two +// (strict turn-taking) — only when both neighbours are unambiguous. // Only fills segments still Unknown; never overrides an LLM attribution. function audiobookResolveUnknowns(segs, prevTail, roster) { const isUnknown = s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)); + const isNamed = s => s?.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt|Narrator$/i.test(s.speaker); const names = (roster || []).filter(n => n && !/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a, b) => b.length - a.length); const lastNameIn = (text) => { let best = null, bestAt = -1; @@ -584,11 +660,13 @@ function audiobookResolveUnknowns(segs, prevTail, roster) { if (at > bestAt) { bestAt = at; best = n; } } if (best) return best; - // fallback: last "der/die " role noun — skipping non-agent - // nouns that commonly sit right before the colon ("rief in die Runde:"). - const NON_AGENT = /^(Runde|Stimme|Stimmen|Menge|Ferne|Höhe|Größe|Richtung|Seite|Stadt|Tür|Luft|Hand|Hände|Augen|Worte|Wort|Frage|Antwort|Dunkelheit|Stille|Nacht|Morgen|Abend|Erde|Himmel|Boden|Wand|Mauer)$/; + // "Marcian unterdrückte seinen Ärger und sagte:" — subject of the final + // speech clause, provided it isn't a sentence-opener adverb/article. + const clause = text.match(new RegExp(_AB_NAME + "[^.!?:]{0,80}\\b(?:und\\s+)?(?:" + AB_SPEECH_VERBS + ")[^:]{0,60}:\\s*$")); + if (clause && !AB_NOTNAME.has(clause[1]) && !AB_PERSON_NOUNS.has(clause[1])) return clause[1]; + // whitelisted person nouns only — never arbitrary capitalized nouns const m = [...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)] - .map(x => x[1]).filter(n => !NON_AGENT.test(n)); + .map(x => x[1]).filter(n => AB_PERSON_NOUNS.has(n)); return m.length ? m[m.length - 1] : null; }; const all = [...(prevTail || []), ...segs]; @@ -606,7 +684,27 @@ function audiobookResolveUnknowns(segs, prevTail, roster) { const nt = String(next.text || '').trim(); let m = nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:der|die)?\\s*' + _AB_NAME)); if (!m) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte')); - if (m) who = m[1]; + // "ertönte es plötzlich über ihm. Karyla hatte …" — impersonal formula, + // the next sentence's subject is the speaker. + if (!m) m = nt.match(new RegExp('^\\W{0,3}(?:ertönte|erklang|tönte|drang|kam)\\b[^.!?]{0,60}\\bes\\b[^.!?]{0,60}[.!?]\\s*' + _AB_NAME)); + if (m && !AB_NOTNAME.has(m[1])) who = m[1]; + } + const prevEndsColon = prev?.type === 'narration' && /:\s*$/.test(String(prev.text || '').trim()); + if (!who && !prevEndsColon) { + // Two-person alternation, strictly: the two nearest preceding dialogue + // lines carry two DIFFERENT known names → this line belongs to the one + // who didn't just speak. Guards: never when the preceding narration + // ends with ":" (that colon introduces someone the rules above already + // failed to identify — alternation would be a guess, not a deduction), + // never across page boundaries, and only within a short window. + const prevDialogues = []; + for (let j = i - 1; j >= 0 && j >= i - 6 && prevDialogues.length < 2; j--) { + if (all[j]?.page != null && s.page != null && all[j].page !== s.page) break; + if (all[j]?.type !== 'dialogue') continue; + if (!isNamed(all[j])) { prevDialogues.length = 0; break; } // an unresolved line breaks the chain + prevDialogues.push(all[j].speaker); + } + if (prevDialogues.length === 2 && prevDialogues[0] !== prevDialogues[1]) who = prevDialogues[1]; } if (who && !/^(Narrator|Unknown|Unbekannt)$/i.test(who)) { s.speaker = who; @@ -2688,12 +2786,24 @@ STRIKTE FORMAT- UND TEXTREGELN: this._procRow.className = 'ab-cv-row is-processing ab-cv-llm-row'; const isLong = text.length > 160; const preview = escHtml(text.slice(0, 160)) + (isLong ? '…' : ''); + // Split view while the LLM works: left = its live thinking stream (fed + // by view.thinking via the SSE endpoint), right = the passage it reads. this._procRow.innerHTML = `
LLM Reading… ${isLong ? '' : ''}
${preview}
+ ${isLong ? `` : ''} `; if (isLong) { @@ -2707,6 +2817,22 @@ STRIKTE FORMAT- UND TEXTREGELN: _abPage().appendChild(this._procRow); trim(); }, + // Live LLM output for the passage currently processing. First delta swaps + // the plain preview for the split thinking/passage view; subsequent deltas + // append and keep the thinking pane scrolled to the newest text. + thinking(delta) { + if (!this._procRow || !delta) return; + const split = this._procRow.querySelector('.ab-cv-llm-split'); + const think = this._procRow.querySelector('.ab-cv-think'); + if (!split || !think) return; + if (split.hidden) { + split.hidden = false; + const prev = this._procRow.querySelector('.ab-cv-llm-preview'); + if (prev) prev.hidden = true; + } + think.textContent = (think.textContent + delta).slice(-20000); // cap runaway output + think.scrollTop = think.scrollHeight; + }, clearProcessing() { if (this._procRow) { this._procRow.remove(); this._procRow = null; } }, @@ -3153,22 +3279,29 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) view.processing(passageText.trim()); let data = null; + const recastBody = audiobookAttributeBody({ + text: passageText.trim(), + known_characters: _audiobook.roster.slice(-40), + recent: recastCtx.recent || '', + language, + llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, + model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), + timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS) + }, promptOverride); try { - const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - signal: ac.signal, - body: JSON.stringify(audiobookAttributeBody({ - text: passageText.trim(), - known_characters: _audiobook.roster.slice(-40), - recent: recastCtx.recent || '', - language, - llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, - model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), - timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS) - }, promptOverride)) - }, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000); - if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); } - data = await r.json(); + // Streaming first (live thinking view); blocking endpoint as fallback. + try { + data = await audiobookAttributeStream(recastBody, view, ac.signal, AUDIOBOOK_RECAST_TIMEOUT_MS); + } catch (streamErr) { + if (streamErr.name === 'AbortError') throw streamErr; + const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: ac.signal, + body: JSON.stringify(recastBody) + }, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); } + data = await r.json(); + } } catch (err) { if (err.name === 'AbortError') { _audiobook.cancel = true; break; } // statusText is empty on HTTP/2, which used to leave this note blank after the colon @@ -3479,13 +3612,22 @@ async function audiobookCast(overrideUrl, overrideModel, resume) { view.processing(chunks[i]); // ── Helper: run one attribution call and return parsed segments (or null on error) ── const attributeChunk = async (chunkText, recentCtx, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) => { + const body = { text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(timeoutMs) }; + // Streaming first — shows the LLM's live thinking in the processing + // card. Any stream failure falls through to the blocking endpoint. + try { + const d = await audiobookAttributeStream(body, view, ac.signal, timeoutMs); + return Array.isArray(d.segments) ? d.segments : null; + } catch (err) { + if (err.name === 'AbortError') throw err; // propagate cancel + } try { const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(timeoutMs) }), + body: JSON.stringify(body), }, 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 || ('HTTP ' + r.status)); } const d = await r.json(); return Array.isArray(d.segments) ? d.segments : null; } catch (err) { diff --git a/static/style.css b/static/style.css index 1e38953..90f2b1f 100644 --- a/static/style.css +++ b/static/style.css @@ -5331,6 +5331,19 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami /* Every word in the narration/dialogue text is individually clickable — the basis for "click a name in the text to assign", including names the app doesn't already know (drag across a multi-word one to select it all). */ +/* Live LLM view while a passage is processing: thinking stream left, the + passage it reads right. Swapped in by view.thinking() on the first delta. */ +.ab-cv-llm-split { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 6px; } +.ab-cv-llm-col { min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.ab-cv-llm-col-label { font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); display: flex; align-items: center; gap: 5px; } +.ab-cv-think { + margin: 0; max-height: 240px; overflow-y: auto; white-space: pre-wrap; + overflow-wrap: anywhere; font-size: 11.5px; line-height: 1.5; color: var(--text); + background: rgba(37,99,235,.05); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; +} +.ab-cv-llm-split .ab-cv-llm-pre { max-height: 240px; overflow-y: auto; font-size: 11.5px; border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; background: var(--panel); } +@media (max-width: 900px) { .ab-cv-llm-split { grid-template-columns: 1fr; } } + /* Heading-like narration (recovered chapter titles, OCR headline bands): bold, larger, centered so titles read as titles inside the paper page. */ .ab-cv-row.is-heading .ab-cv-txt {