diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2aa9e29..e7f06e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,21 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.12.98] — 2026-07-05
+
+### Added
+- **Character table view** in Library → Characters/Cast (toggle next to "Import from SillyTavern") — one row per character with avatar, name, sex, line count, assigned voice's language, moral alignment bar, voice, tags, and check/cross indicators for whether the SillyTavern, TTS voice-design, and image prompts have been generated. Persists your choice between Cards/Table.
+- **Bulk voice auto-assign** — a checkbox on every character card/row plus an "Auto-assign selected" button in each production's header, so you can voice-cast a batch of characters in one click instead of one at a time. Runs sequentially (not in parallel) so each assignment sees what the previous one just picked and doesn't hand out the same voice twice.
+- **Character tags are now visible** on cards — every character is auto-tagged with the book it was extracted from (already happened silently); the cards just weren't showing it. Useful for spotting the same character recurring across different books.
+- **Jump-to-source links on detail fields** — Backstory, Motivation, Relationships, etc. now show small numbered links next to the label when the sheet has a citation for that field; clicking one scrolls straight to the exact page + quote in "Quellen im Text" instead of making you search the full source list.
+- **Gender is now an extracted character-sheet field** (male/female/nonbinary) — it existed in the UI already but the LLM was never actually asked for it, so every character silently showed the same default icon.
+- **Line-count tracking (best-effort)** — casting from Read Aloud or Script Rehearsal now tallies each character's dialogue-line count into their sheet when it's available in memory, feeding the new table view's "Zeilen" column.
+
+### Fixed
+- **SillyTavern & Concept Art prompts still silently failed to generate** even after the earlier max_tokens increase — they're the last two fields in a four-field JSON object, so any truncation always cost the same two fields regardless of the ceiling. Split into two independent LLM calls (Voice Design + Image / SillyTavern + Concept Art), run concurrently, each with its own full token budget — a squeeze in one pair can no longer cost the other pair anything.
+
+---
+
## [1.12.97] — 2026-07-05
### Fixed
diff --git a/VERSION b/VERSION
index 3940254..f12b77f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.12.97
+1.12.98
diff --git a/routes/conversation.py b/routes/conversation.py
index 844d79e..1839726 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -599,6 +599,7 @@ async def character_sheets(request: Request):
"- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.\n"
"- first_name, last_name, full_name, title: split the character identity when known. Leave unknown parts empty. Put noble/office/role labels in title (e.g. 'Henker', 'Vampir', 'Graf').\n"
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
+ "- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.\n"
"- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
"- clothing: distinctive clothing, armour, accessories — as observed in the text\n"
"- alignment: strict moral code + the one line they will never cross\n"
@@ -630,7 +631,7 @@ async def character_sheets(request: Request):
"Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. "
"Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n"
"Respond with STRICT JSON only:\n"
- '{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","physical":"","clothing":"",'
+ '{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","gender":"","physical":"","clothing":"",'
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
@@ -727,12 +728,16 @@ async def character_sheets(request: Request):
arc = str(s.get("arc_direction") or "neutral").strip()
if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"):
arc = "neutral"
+ gender = str(s.get("gender") or "").strip().lower()
+ if gender not in ("male", "female", "nonbinary"):
+ gender = ""
s.update({
"name": name, "aliases": aliases, "inventory": inv[:3],
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
"sources": src[:12],
"moral_alignment_score": mas,
"arc_direction": arc,
+ "gender": gender,
})
clean.append(s)
names.append(name)
@@ -858,82 +863,102 @@ async def character_generate_prompts(request: Request):
] if v)
lang_note = f" Write every prompt in {language} EXCEPT where told to use English." if language else ""
- system = (
- "You are a prompt engineer who turns a fiction character's profile into four ready-to-paste "
- "prompts for other tools. Use ONLY details present in the profile below; mark anything you must "
- f"reasonably infer with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n\n"
- "Produce exactly these four fields:\n"
- "- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, "
- "no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, "
- "accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not "
- "mention plot events — describe only how the voice should SOUND.\n"
- "- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. "
- "Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, "
- "clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style "
- "(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors "
- "are fine.\n"
- "- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their "
- "own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), "
- "'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character "
- "greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character "
- "lines showing their manner of speech). Keep each section a few lines at most.\n"
- "- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — "
- "a turnaround/reference sheet with multiple views and expressions: front view, side or back view, "
- "2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, "
- "in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, "
- "white background').\n\n"
- "Respond with STRICT JSON only: "
- '{"voice_design_prompt":"","image_prompt":"","silly_tavern_prompt":"","concept_art_prompt":""}/no-think'
+ preamble = (
+ "You are a prompt engineer who turns a fiction character's profile into ready-to-paste prompts for "
+ "other tools. Use ONLY details present in the profile below; mark anything you must reasonably infer "
+ f"with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n\n"
)
- user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\nGenerate the four prompts now."
- payload: dict = {
- "messages": [
- {"role": "system", "content": system},
- {"role": "user", "content": user},
- ],
- "temperature": 0.7,
- # Four prompts, one of them multi-section (SillyTavern) — a tighter cap
- # truncated the JSON mid-output, the parse failed, and the two later
- # fields silently came back empty.
- "max_tokens": 4096,
- }
- if model:
- payload["model"] = model
- try:
- resp = requests.post(
- f"{llm_url}/chat/completions", json=payload,
- headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
- )
- 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 prompt generation failed: {e}")
+ user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\n"
- content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw
- block = _extract_json_block(content)
- # If the model still got cut off mid-string, closing the dangling string +
- # object often salvages the fields that DID complete.
- candidates = [content, block]
- if block:
- candidates.extend([block + "\"}", block + "}"])
- result = {}
- for cand in candidates:
- if not cand:
- continue
+ # Split into two independent calls instead of one four-field JSON object.
+ # Even at a 4096-token ceiling, the model reliably ran out of budget before
+ # reaching the LAST two fields (silly_tavern_prompt, concept_art_prompt come
+ # after voice_design_prompt/image_prompt in the JSON) — truncation always
+ # cost the same two fields. Each pair now gets its own full token budget,
+ # so a squeeze in one pair can't cost the other pair anything, and the two
+ # calls run concurrently so this isn't slower than the single-call version.
+ field_groups = [
+ (
+ ("voice_design_prompt", "image_prompt"),
+ preamble
+ + "Produce exactly these two fields:\n"
+ "- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, "
+ "no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, "
+ "accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not "
+ "mention plot events — describe only how the voice should SOUND.\n"
+ "- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. "
+ "Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, "
+ "clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style "
+ "(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors "
+ "are fine.\n\n"
+ 'Respond with STRICT JSON only: {"voice_design_prompt":"","image_prompt":""}/no-think',
+ ),
+ (
+ ("silly_tavern_prompt", "concept_art_prompt"),
+ preamble
+ + "Produce exactly these two fields:\n"
+ "- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their "
+ "own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), "
+ "'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character "
+ "greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character "
+ "lines showing their manner of speech). Keep each section a few lines at most.\n"
+ "- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — "
+ "a turnaround/reference sheet with multiple views and expressions: front view, side or back view, "
+ "2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, "
+ "in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, "
+ "white background').\n\n"
+ 'Respond with STRICT JSON only: {"silly_tavern_prompt":"","concept_art_prompt":""}/no-think',
+ ),
+ ]
+
+ def _call_group(fields: tuple, system: str) -> dict:
+ payload: dict = {
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user + f"Generate the {' and '.join(fields)} now."},
+ ],
+ "temperature": 0.7,
+ "max_tokens": 2560,
+ }
+ if model:
+ payload["model"] = model
try:
- parsed = json.loads(cand)
- if isinstance(parsed, dict) and "voice_design_prompt" in parsed:
- result = parsed
- break
- except Exception:
- continue
- out = {
- "voice_design_prompt": str(result.get("voice_design_prompt") or "").strip(),
- "image_prompt": str(result.get("image_prompt") or "").strip(),
- "silly_tavern_prompt": str(result.get("silly_tavern_prompt") or "").strip(),
- "concept_art_prompt": str(result.get("concept_art_prompt") or "").strip(),
- }
+ resp = requests.post(
+ f"{llm_url}/chat/completions", json=payload,
+ headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
+ )
+ 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:
+ print(f"[character-generate-prompts] LLM call failed for {fields}: {e}")
+ return {}
+
+ content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw
+ block = _extract_json_block(content)
+ # If the model still got cut off mid-string, closing the dangling string +
+ # object often salvages the field that DID complete.
+ candidates = [content, block]
+ if block:
+ candidates.extend([block + "\"}", block + "}"])
+ for cand in candidates:
+ if not cand:
+ continue
+ try:
+ parsed = json.loads(cand)
+ if isinstance(parsed, dict) and fields[0] in parsed:
+ return parsed
+ except Exception:
+ continue
+ return {}
+
+ results = await asyncio.gather(*[
+ asyncio.to_thread(_call_group, fields, system) for fields, system in field_groups
+ ])
+ out = {}
+ for (fields, _), result in zip(field_groups, results):
+ for f in fields:
+ out[f] = str(result.get(f) or "").strip()
if not any(out.values()):
# A silent all-empty response looked identical to success in the UI —
# fail loudly so the client can show a real error instead.
diff --git a/static/index.html b/static/index.html
index 128b89a..194a12f 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) {
-
+