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 {