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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-06 15:28:26 +02:00
parent f2bdd98a4f
commit 960389ef5b
6 changed files with 249 additions and 41 deletions

View File

@ -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 ## [1.13.10] — 2026-07-06
### Fixed ### Fixed

View File

@ -1 +1 @@
1.13.10 1.14.0

View File

@ -536,19 +536,10 @@ def _extract_json_block(text: str) -> str:
return "" return ""
@router.post("/api/character-sheets") def _charsheets_prepare(data: dict) -> dict:
async def character_sheets(request: Request): """Resolve settings and build the chat payload for one character-sheets
"""Extract actor-facing RPG-style character sheets from a passage. extraction request. Shared by the blocking endpoint and the streaming
(watch-it-fill-out) endpoint so the prompt can never drift between them."""
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()
text: str = (data.get("text") or "").strip() text: str = (data.get("text") or "").strip()
known: list = data.get("known_characters") or [] known: list = data.get("known_characters") or []
target_mode: bool = bool(data.get("target_mode")) target_mode: bool = bool(data.get("target_mode"))
@ -658,18 +649,15 @@ async def character_sheets(request: Request):
} }
if model: if model:
payload["model"] = model payload["model"] = model
try: return {
resp = await asyncio.to_thread( "payload": payload, "llm_url": llm_url, "model": model,
_post_llm_chat_completion, "api_key": _settings.get("llm_api_key") or "sk-dummy-key",
llm_url, payload, "timeout_seconds": timeout_seconds,
{"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}")
def _charsheets_parse(raw: str) -> dict:
"""Turn the LLM's raw answer into normalised {sheets, characters}."""
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
sheets = [] sheets = []
for cand in (content, _extract_json_block(content)): for cand in (content, _extract_json_block(content)):
@ -744,6 +732,123 @@ async def character_sheets(request: Request):
return {"sheets": clean, "characters": names} 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") @router.post("/api/character-deep-analysis")
async def character_deep_analysis(request: Request): async def character_deep_analysis(request: Request):
"""Run a deep 5-area psychological analysis of a single character. """Run a deep 5-area psychological analysis of a single character.

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.13.10"> <meta name="app-version" content="1.14.0">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.13.10"> <link rel="stylesheet" href="/static/style.css?v=1.14.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.13.10"></script> <script src="/static/loader.js?v=1.14.0"></script>
</body> </body>
</html> </html>

View File

@ -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); }).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) { async function csGenerate(text, cacheKey, initialRoster) {
if (_cs.running) return null; if (_cs.running) return null;
if (!text) { toast('Nothing to analyse', 'error'); return null; } if (!text) { toast('Nothing to analyse', 'error'); return null; }
@ -265,10 +314,18 @@ async function csGenerate(text, cacheKey, initialRoster) {
? `${knownTotal} cast characters queued · ${detailed} profiles with details` ? `${knownTotal} cast characters queued · ${detailed} profiles with details`
: `${map.size} characters found`; : `${map.size} characters found`;
prog.update(i, `Passage ${i + 1} / ${chunks.length}`, charLabel, csProgressSnapshot(map)); prog.update(i, `Passage ${i + 1} / ${chunks.length}`, charLabel, csProgressSnapshot(map));
prog.startPassage();
try { try {
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', { const r = await fetch('/api/character-sheets', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, 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 }), body: JSON.stringify(body),
}); });
if (!r.ok) { if (!r.ok) {
let detail = `HTTP ${r.status}`; let detail = `HTTP ${r.status}`;
@ -276,9 +333,9 @@ async function csGenerate(text, cacheKey, initialRoster) {
throw new Error(detail); throw new Error(detail);
} }
const raw = await r.text(); const raw = await r.text();
let data;
try { data = JSON.parse(raw); } try { data = JSON.parse(raw); }
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
}
const changed = csMerge(map, data.sheets || []); const changed = csMerge(map, data.sheets || []);
if (!targetMode) { if (!targetMode) {
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); (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) { if (!ov) {
ov = document.createElement('div'); ov = document.createElement('div');
ov.id = 'cs-progress'; ov.className = 'audiobook-overlay'; ov.id = 'cs-progress'; ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box"> ov.innerHTML = `<div class="audiobook-box cs-progress-box">
<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div> <div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>
<div class="audiobook-msg" id="cs-progress-msg">Analysing</div> <div class="audiobook-msg" id="cs-progress-msg">Analysing</div>
<div class="reader-synth-track" style="margin-bottom:4px"><div class="reader-synth-fill" id="cs-progress-fill"></div></div> <div class="reader-synth-track" style="margin-bottom:4px"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
<details class="cs-progress-live" id="cs-progress-live" open>
<summary><span class="mdi mdi-text-long"></span> Live output <span class="cs-progress-live-status" id="cs-progress-live-status">watching</span></summary>
<pre class="cs-progress-live-pre" id="cs-progress-live-pre"></pre>
</details>
<div class="cs-progress-chars" id="cs-progress-chars"></div> <div class="cs-progress-chars" id="cs-progress-chars"></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div> <div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
</div>`; </div>`;
@ -325,7 +386,28 @@ function csProgress(total) {
const fill = ov.querySelector('#cs-progress-fill'); const fill = ov.querySelector('#cs-progress-fill');
const msg = ov.querySelector('#cs-progress-msg'); const msg = ov.querySelector('#cs-progress-msg');
const chars = ov.querySelector('#cs-progress-chars'); const chars = ov.querySelector('#cs-progress-chars');
const livePre = ov.querySelector('#cs-progress-live-pre');
const liveStatus = ov.querySelector('#cs-progress-live-status');
return { 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 = []) { update(d, label, charLabel, roster = []) {
if (fill) fill.style.width = (d / total * 100) + '%'; if (fill) fill.style.width = (d / total * 100) + '%';
if (msg && label) msg.textContent = label; if (msg && label) msg.textContent = label;

View File

@ -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); 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-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-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-summary { text-align: center; margin-bottom: 8px; color: var(--subtext); }
.cs-progress-list { .cs-progress-list {