From 960389ef5b8323e8a4b7fc4841e66dd54a17bc5a Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 6 Jul 2026 15:28:26 +0200 Subject: [PATCH] Add live-streaming output to Character Sheets generation (v1.14.0) Character Sheets generation only ever showed a progress bar - no visible reading/thinking/filling-out, unlike the casting flow which already streams the LLM's output live. Refactored /api/character-sheets into shared _charsheets_prepare/_charsheets_parse helpers (same split used for attribution) and added /api/character-sheets/stream, proxying the LLM's SSE stream through the same shared lock used by the other attribution endpoints. Client: new csGenerateStream (mirrors audiobookAttributeStream) tries the streaming endpoint first per passage, updating a new "Live output" panel in the progress dialog with the raw JSON answer as it's written - itself the "watch it fill out the sheet" experience, since there's no separate reasoning channel worth hiding it behind here. Falls back to the blocking endpoint on any stream failure. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++ VERSION | 2 +- routes/conversation.py | 153 ++++++++++++++++++++++++++++------ static/index.html | 6 +- static/js/character-sheets.js | 108 +++++++++++++++++++++--- static/style.css | 14 ++++ 6 files changed, 249 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0be2f..99aaf3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.14.0] — 2026-07-06 + +### Added +- **Watch Character Sheets fill out live** — the "Character sheets" progress dialog now has a "Live output" pane that streams the LLM's raw answer as it's written for the passage currently being processed, the same live-streaming approach already used for casting. Backed by a new `/api/character-sheets/stream` endpoint (sharing prompt-building and parsing with the existing blocking one), with automatic fallback to the blocking endpoint if the model/backend doesn't support streaming. + +--- + ## [1.13.10] — 2026-07-06 ### Fixed diff --git a/VERSION b/VERSION index f3352b3..850e742 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.10 +1.14.0 diff --git a/routes/conversation.py b/routes/conversation.py index cdbc747..f26f01a 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -536,19 +536,10 @@ def _extract_json_block(text: str) -> str: return "" -@router.post("/api/character-sheets") -async def character_sheets(request: Request): - """Extract actor-facing RPG-style character sheets from a passage. - - Body: {text, known_characters:[...], language, llm_url, model} - The text may contain "[p.N]" page markers so the model can cite sources. - Returns: {sheets:[{name, aliases, first_name, last_name, full_name, title, archetype, physical, alignment, - attribute_high, attribute_low, skills, inventory:[...], secret, - conflict_style, win_condition, tier:"main"|"supporting", - sources:[{page, quote}]}], characters:[names]} - Deduced (not explicit) values are marked with a trailing " *". - """ - data = await request.json() +def _charsheets_prepare(data: dict) -> dict: + """Resolve settings and build the chat payload for one character-sheets + extraction request. Shared by the blocking endpoint and the streaming + (watch-it-fill-out) endpoint so the prompt can never drift between them.""" text: str = (data.get("text") or "").strip() known: list = data.get("known_characters") or [] target_mode: bool = bool(data.get("target_mode")) @@ -658,18 +649,15 @@ async def character_sheets(request: Request): } if model: payload["model"] = model - try: - 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, - ) - resp.raise_for_status() - _msg = resp.json()["choices"][0]["message"] - raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() - except Exception as e: - raise HTTPException(502, f"LLM character-sheet generation failed: {e}") + return { + "payload": payload, "llm_url": llm_url, "model": model, + "api_key": _settings.get("llm_api_key") or "sk-dummy-key", + "timeout_seconds": timeout_seconds, + } + +def _charsheets_parse(raw: str) -> dict: + """Turn the LLM's raw answer into normalised {sheets, characters}.""" content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw sheets = [] for cand in (content, _extract_json_block(content)): @@ -744,6 +732,123 @@ async def character_sheets(request: Request): return {"sheets": clean, "characters": names} +@router.post("/api/character-sheets") +async def character_sheets(request: Request): + """Extract actor-facing RPG-style character sheets from a passage. + + Body: {text, known_characters:[...], language, llm_url, model} + The text may contain "[p.N]" page markers so the model can cite sources. + Returns: {sheets:[{name, aliases, first_name, last_name, full_name, title, archetype, physical, alignment, + attribute_high, attribute_low, skills, inventory:[...], secret, + conflict_style, win_condition, tier:"main"|"supporting", + sources:[{page, quote}]}], characters:[names]} + Deduced (not explicit) values are marked with a trailing " *". + """ + data = await request.json() + prep = _charsheets_prepare(data) + try: + if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, prep["timeout_seconds"]): + raise HTTPException(503, "Attribution engine busy") + try: + resp = await asyncio.to_thread( + _post_llm_chat_completion, + prep["llm_url"], prep["payload"], + {"Authorization": f"Bearer {prep['api_key']}"}, prep["timeout_seconds"], + ) + finally: + _attribution_llm_lock.release() + resp.raise_for_status() + _msg = resp.json()["choices"][0]["message"] + raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"LLM character-sheet generation failed: {e}") + return _charsheets_parse(raw) + + +@router.post("/api/character-sheets/stream") +async def character_sheets_stream(request: Request): + """Same job as /api/character-sheets, but streams the LLM's raw output + (its JSON answer being written field by field) as SSE `{"t": "..."}` + events, ending with `{"done": true, "result": {...}}` — lets the UI show + the sheet actually filling out passage by passage instead of just a + progress bar. On upstream failure it emits `{"error": "..."}` and the + client falls back to the blocking endpoint.""" + data = await request.json() + prep = _charsheets_prepare(data) + payload = dict(prep["payload"]) + payload["stream"] = True + headers = {"Authorization": f"Bearer {prep['api_key']}"} + + def gen(): + if not _attribution_llm_lock.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: + print(f"[character-sheets/stream] LLM connect failed: {last_err}") + yield f'data: {json.dumps({"error": str(last_err or "LLM connect failed")})}\n\n' + return + if upstream.status_code >= 400: + print( + f"[character-sheets/stream] LLM failed ({upstream.status_code}) for model " + f"{payload.get('model') or '(default)'}: {_response_error_text(upstream)[:300]}" + ) + yield f'data: {json.dumps({"error": f"HTTP {upstream.status_code}: {_response_error_text(upstream)[:300]}"})}\n\n' + return + raw_parts = [] + upstream.encoding = "utf-8" + 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 + 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 = _charsheets_parse("".join(raw_parts)) + 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_llm_lock.release() + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + @router.post("/api/character-deep-analysis") async def character_deep_analysis(request: Request): """Run a deep 5-area psychological analysis of a single character. diff --git a/static/index.html b/static/index.html index 07de5bf..af2b3d5 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/character-sheets.js b/static/js/character-sheets.js index e96a1af..a9a31c5 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -233,6 +233,55 @@ function csProgressSnapshot(map, changed = []) { }).sort((a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name)).slice(0, 18); } +// SSE reader for /api/character-sheets/stream, mirroring +// audiobookAttributeStream — idle-timeout-based abort (re-armed on every +// received chunk) rather than one fixed overall timeout, since generation +// can legitimately take a while but a truly stalled stream should still +// give up. Falls back to the blocking endpoint on any failure. +async function csGenerateStream(body, onDelta, outerSignal, idleTimeoutMs = 60000) { + 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/character-sheets/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 && onDelta) onDelta(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; + } finally { + clearTimeout(idleTimer); + if (outerSignal) outerSignal.removeEventListener('abort', onAbort); + } +} + async function csGenerate(text, cacheKey, initialRoster) { if (_cs.running) return null; if (!text) { toast('Nothing to analyse', 'error'); return null; } @@ -265,20 +314,28 @@ async function csGenerate(text, cacheKey, initialRoster) { ? `${knownTotal} cast characters queued · ${detailed} profiles with details` : `${map.size} characters found`; prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel, csProgressSnapshot(map)); + prog.startPassage(); try { - const r = await fetch('/api/character-sheets', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model }), - }); - if (!r.ok) { - let detail = `HTTP ${r.status}`; - try { const e = await r.json(); detail = e.detail || e.error || detail; } catch (_) {} - throw new Error(detail); + const body = { text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model }; + let data = null; + let sawDelta = false; + try { + data = await csGenerateStream(body, (delta) => { sawDelta = true; prog.thinking(delta); }); + } catch (streamErr) { + if (!sawDelta) prog.noStream(); + const r = await fetch('/api/character-sheets', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) { + let detail = `HTTP ${r.status}`; + try { const e = await r.json(); detail = e.detail || e.error || detail; } catch (_) {} + throw new Error(detail); + } + const raw = await r.text(); + try { data = JSON.parse(raw); } + catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } } - const raw = await r.text(); - let data; - try { data = JSON.parse(raw); } - catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } const changed = csMerge(map, data.sheets || []); if (!targetMode) { (data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); @@ -311,10 +368,14 @@ function csProgress(total) { if (!ov) { ov = document.createElement('div'); ov.id = 'cs-progress'; ov.className = 'audiobook-overlay'; - ov.innerHTML = `
+ ov.innerHTML = `
Character sheets
Analysing…
+
+ Live output watching… +

+      
`; @@ -325,7 +386,28 @@ function csProgress(total) { const fill = ov.querySelector('#cs-progress-fill'); const msg = ov.querySelector('#cs-progress-msg'); const chars = ov.querySelector('#cs-progress-chars'); + const livePre = ov.querySelector('#cs-progress-live-pre'); + const liveStatus = ov.querySelector('#cs-progress-live-status'); return { + // Called once per passage before its request starts, so the live-output + // pane is empty and honestly labelled before we know whether this model + // streams real content or the request falls back to the blocking path. + startPassage() { + if (livePre) livePre.textContent = ''; + if (liveStatus) { liveStatus.textContent = 'watching…'; liveStatus.className = 'cs-progress-live-status'; } + }, + // Raw LLM output as it streams in — the JSON answer being written field + // by field is itself the "watch it fill out the sheet" experience here, + // there's no separate reasoning channel worth hiding it behind. + thinking(delta) { + if (!livePre || !delta) return; + if (liveStatus) { liveStatus.textContent = 'streaming'; liveStatus.className = 'cs-progress-live-status is-live'; } + livePre.textContent = (livePre.textContent + delta).slice(-20000); + livePre.scrollTop = livePre.scrollHeight; + }, + noStream() { + if (liveStatus) { liveStatus.textContent = 'no live output for this model'; liveStatus.className = 'cs-progress-live-status'; } + }, update(d, label, charLabel, roster = []) { if (fill) fill.style.width = (d / total * 100) + '%'; if (msg && label) msg.textContent = label; diff --git a/static/style.css b/static/style.css index 6922e3f..cb6afc5 100644 --- a/static/style.css +++ b/static/style.css @@ -5748,6 +5748,20 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami text-shadow: 0 1px 3px rgba(0,0,0,.8); } .cs-arc-note { font-size: 11px; color: var(--subtext); margin-top: 3px; font-style: italic; } +.cs-progress-box { width: min(520px, 92vw); } +.cs-progress-live { margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; } +.cs-progress-live summary { + display: flex; align-items: center; gap: 6px; padding: 7px 10px; font-size: 11.5px; + font-weight: 700; color: var(--subtext); cursor: pointer; list-style: none; +} +.cs-progress-live summary::-webkit-details-marker { display: none; } +.cs-progress-live-status { margin-left: auto; font-weight: 500; font-style: italic; opacity: .8; } +.cs-progress-live-status.is-live { color: var(--accent); opacity: 1; } +.cs-progress-live-pre { + margin: 0; padding: 8px 10px; max-height: 160px; overflow-y: auto; white-space: pre-wrap; + overflow-wrap: anywhere; font-size: 11px; line-height: 1.5; color: var(--text); + background: var(--bg); border-top: 1px solid var(--border); +} .cs-progress-chars { font-size: 11px; color: var(--subtext); min-height: 16px; margin-bottom: 8px; } .cs-progress-summary { text-align: center; margin-bottom: 8px; color: var(--subtext); } .cs-progress-list {