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) {
-
+