From 5fd1660c095b0fc6c50357d24a51e7e535a01f75 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 5 Jul 2026 23:09:59 +0200 Subject: [PATCH] Add character table view, bulk voice-assign, source links, fix prompt truncation (v1.12.98) Table view: one row per character (avatar, name, sex, line count, voice language, alignment, voice, tags, prompt-availability checks), toggled next to the card grid and persisted. Bulk voice auto-assign: checkbox per character + "Auto-assign selected" per production, sequential so later picks see what earlier ones just took (avoids duplicate voice assignments). Character tags (auto-set to the book of origin) are now visible on cards - the field always existed, cards just never rendered it, so a character recurring across books had no visible link between records. Detail fields (Backstory, Motivation, etc.) now show small numbered links to their exact source citation when the sheet has one, instead of making the reader search the full "Quellen im Text" list. Added gender as an actual extracted character-sheet field - the UI already had a gender icon but the LLM was never asked for the value. Fixed SillyTavern/Concept Art prompts still coming back empty despite the earlier token-budget increase: they're the last two fields in one JSON object, so truncation always cost the same two regardless of the ceiling. Split into two independent, concurrent LLM calls instead. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 +++ VERSION | 2 +- routes/conversation.py | 173 +++++++++++++++++------------- static/index.html | 6 +- static/js/character-sheets.js | 31 +++++- static/js/characters-library.js | 2 + static/js/library-characters.js | 182 +++++++++++++++++++++++++++----- static/style.css | 35 +++++- 8 files changed, 340 insertions(+), 106 deletions(-) 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) { - + diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index 3d076d7..518e6ef 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -147,7 +147,7 @@ function csKnownReaderRoster() { function csBlankSheet(name) { return { name, aliases: '', first_name: '', last_name: '', full_name: '', title: '', - archetype: '', physical: '', clothing: '', alignment: '', arc_note: '', + archetype: '', gender: '', physical: '', clothing: '', alignment: '', arc_note: '', attribute_high: '', attribute_low: '', skills: '', capabilities: '', backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', voice_pattern: '', secret: '', conflict_style: '', win_condition: '', voice_design_prompt: '', image_prompt: '', @@ -208,6 +208,7 @@ function csMerge(map, sheets) { : s.moral_alignment_score; } if (s.arc_direction && s.arc_direction !== 'neutral') e.arc_direction = s.arc_direction; + if (s.gender && !e.gender) e.gender = s.gender; (s.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); }); (s.sources || []).forEach(src => { if (src && src.quote && e.sources.length < 12 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); @@ -744,6 +745,19 @@ function csGoToLibrary() { else if (typeof libraryRender === 'function') libraryRender('characters'); } +// Best-effort dialogue-line tally so the Library's table view can show a +// "lines" column — only meaningful right after a cast/rehearsal run is in +// memory, since line attribution itself isn't part of character-sheet +// generation. Matches case-insensitively since sheet names and roster/script +// speaker names aren't guaranteed identical casing. +function csAttachLineCounts(sheets, counts) { + if (!counts || !counts.size) return; + sheets.forEach(function (s) { + const n = counts.get(String(s.name || '').trim().toLowerCase()); + if (n != null) s.line_count = n; + }); +} + async function csForReader() { const text = csReaderText(); const book = readerState.title || 'Untitled book'; @@ -753,6 +767,12 @@ async function csForReader() { const sheets = await csGenerate(text, key, knownRoster); if (!sheets) return; if (!sheets.length) { toast('No characters found', 'error'); return; } + const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook; + if (ab?.roster) { + const counts = new Map(); + ab.roster.forEach(function (info, name) { counts.set(String(name).trim().toLowerCase(), info.count || 0); }); + csAttachLineCounts(sheets, counts); + } await csSaveToLibrary(book, sheets); csGoToLibrary(); toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success'); @@ -767,6 +787,15 @@ async function csForRehearser() { const sheets = await csGenerate(text, key); if (!sheets) return; if (!sheets.length) { toast('No characters found', 'error'); return; } + if (window.rehState?.lines?.length) { + const counts = new Map(); + rehState.lines.forEach(function (l) { + if (l.type !== 'dialog' || !l.speaker) return; + const k = String(l.speaker).trim().toLowerCase(); + counts.set(k, (counts.get(k) || 0) + 1); + }); + csAttachLineCounts(sheets, counts); + } await csSaveToLibrary(book, sheets); csGoToLibrary(); toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success'); diff --git a/static/js/characters-library.js b/static/js/characters-library.js index 08f1241..c00048e 100644 --- a/static/js/characters-library.js +++ b/static/js/characters-library.js @@ -156,6 +156,8 @@ function clMergeSheet(existing, incoming) { : incoming.moral_alignment_score; } if (incoming.arc_direction && incoming.arc_direction !== 'neutral') e.arc_direction = incoming.arc_direction; + if (incoming.gender && !e.gender) e.gender = incoming.gender; + if (incoming.line_count != null) e.line_count = incoming.line_count; e.inventory = [...(existing.inventory || [])]; (incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); }); e.sources = [...(existing.sources || [])]; diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 959d866..fe22609 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -38,13 +38,25 @@ async function libraryRenderCharacters() { container.innerHTML = ''; - // Global toolbar — import a cast from SillyTavern into a new/unsorted production + // Global toolbar — import a cast from SillyTavern into a new/unsorted production, + // plus a Cards/Table view toggle (persisted, like Script Rehearser's). + const viewMode = localStorage.getItem('ttsvc_libchars_view') === 'table' ? 'table' : 'cards'; const bar = document.createElement('div'); bar.className = 'lib-chars-toolbar'; - bar.innerHTML = ''; + bar.innerHTML = '' + + '
' + + '' + + '' + + '
'; bar.querySelector('#lib-chars-import').addEventListener('click', function () { if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); }); }); + bar.querySelectorAll('.lib-chars-view-toggle button').forEach(function (btn) { + btn.addEventListener('click', function () { + localStorage.setItem('ttsvc_libchars_view', btn.dataset.view); + libraryRenderCharacters(); + }); + }); container.appendChild(bar); const productions = document.createDocumentFragment(); @@ -67,10 +79,13 @@ async function libraryRenderCharacters() { + '' + '' + '' + + '' + '' - + '
' - + chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('') - + '
'; + + (viewMode === 'table' + ? _charsTableHtml(chars) + : '
' + + chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('') + + '
'); // Action buttons prod.querySelector('.lib-chars-casting-btn').addEventListener('click', function () { @@ -93,6 +108,38 @@ async function libraryRenderCharacters() { if (typeof stImportDialog === 'function') stImportDialog(book, function () { libraryRenderCharacters(); }); }); + const bulkBtn = prod.querySelector('.lib-chars-bulk-voice-btn'); + const bulkCount = prod.querySelector('.lib-chars-bulk-count'); + const refreshBulkBtn = function () { + const n = prod.querySelectorAll('.lib-char-select-cb:checked').length; + bulkCount.textContent = n; + bulkBtn.disabled = n === 0; + }; + prod.querySelectorAll('.lib-char-select-cb').forEach(function (cb) { + cb.addEventListener('change', refreshBulkBtn); + }); + bulkBtn.addEventListener('click', async function () { + const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; }); + if (!ids.length) return; + bulkBtn.disabled = true; + const origLabel = bulkBtn.innerHTML; + let done = 0; + for (const id of ids) { + const rec = byId.get(id); + if (!rec) continue; + bulkBtn.innerHTML = ' Assigning ' + (++done) + ' / ' + ids.length + '…'; + // Sequential, not parallel: _autoAssignVoice avoids re-using a voice + // already taken by another character in the same book by checking + // what's assigned so far — running these concurrently would have + // every call see the same "nothing assigned yet" snapshot and could + // hand out the same voice to several selected characters at once. + try { await _autoAssignVoice(rec); } catch (_) {} + } + bulkBtn.innerHTML = origLabel; + toast(`Voice assigned to ${done} character${done !== 1 ? 's' : ''}`, 'success'); + libraryRenderCharacters(); + }); + // Wire voice selectors and auto-assign buttons prod.querySelectorAll('.lib-char-card').forEach(function (card) { const charId = card.dataset.charId; @@ -226,12 +273,26 @@ function _charCardHtml(rec, allChars) { const gender = String(sh.gender || '').toLowerCase(); const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary'; const snippet = _libStr(sh.mannerisms || sh.voice_pattern || sh.motivation || sh.backstory || '').slice(0, 140); + // Every character is auto-tagged with the book it was found in (see + // clUpsert/clMergeTags) — surfacing the chips here is how you tell a + // character recurring across several books apart from a same-named one + // that's book-local, since tags (not the single `book` field) are what + // carries multi-book membership. + const tagList = String(rec.tags || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean); + const tagsHtml = tagList.length + ? '
' + tagList.map(function (t) { + return '' + escHtml(t) + ''; + }).join('') + '
' + : ''; const avatarInner = rec.image ? '' + escHtml(rec.name) + '' : escHtml((rec.name || '?')[0].toUpperCase()); return '
' + + '' + '
' + '
' + avatarInner + '
' + '' @@ -242,6 +303,7 @@ function _charCardHtml(rec, allChars) { + (_libStr(sh.aliases) ? '
aka ' + escHtml(_libStr(sh.aliases)) + '
' : '') + (sh.archetype ? '
' + escHtml(_libStr(sh.archetype)) + '
' : '') + (snippet ? '
' + escHtml(snippet) + '
' : '') + + tagsHtml + _charAlignHtml(sh) + _charRelsHtml(rec, allChars) + '
' @@ -253,6 +315,58 @@ function _charCardHtml(rec, allChars) { + '
'; } +// Compact alternative to the card grid for scanning a large cast at once — +// one row per character with the fields that matter for casting decisions. +// Rows carry the "lib-char-card" class too so they're picked up by the same +// avatar/voice-picker/auto-voice/export wiring the card grid already uses; +// only the HTML shape differs. +function _charsTableHtml(chars) { + const rows = chars.map(function (rec) { + const sh = rec.sheet || {}; + const hue = _charHue(rec.name); + const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; + const voiceLang = (rec.voice && typeof rec.voice === 'object') ? (rec.voice.language || '') : ''; + const tier = String(sh.tier || '').toLowerCase(); + const tierBadge = tier === 'main' ? 'Haupt' + : tier === 'supporting' ? 'Neben' : ''; + const gender = String(sh.gender || '').toLowerCase(); + const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : gender ? 'mdi-gender-non-binary' : ''; + const score = sh.moral_alignment_score; + const pct = score != null ? Math.max(0, Math.min(100, score)) : null; + const tagList = String(rec.tags || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean); + const avatarInner = rec.image + ? '' + escHtml(rec.name) + '' + : escHtml((rec.name || '?')[0].toUpperCase()); + const hasPrompt = function (key) { return !!(sh[key] && String(sh[key]).trim()); }; + const promptCell = function (key, title) { + return '' + + ''; + }; + return '' + + '' + + '
' + avatarInner + '
' + + '' + tierBadge + escHtml(rec.name) + '' + + '' + (genderIcon ? '' : '') + '' + + '' + (sh.line_count != null ? sh.line_count : '') + '' + + '' + (voiceLang ? escHtml(voiceLang) : '') + '' + + '' + (pct != null ? '
' : '') + '' + + '' + (voiceId ? escHtml(voiceId) : 'Keine Stimme') + + '' + + '' + tagList.map(function (t) { return '' + escHtml(t) + ''; }).join('') + '' + + '' + promptCell('silly_tavern_prompt', 'SillyTavern') + '' + + '' + promptCell('voice_design_prompt', 'TTS Voice') + '' + + '' + promptCell('image_prompt', 'Bild') + '' + + '' + + ''; + }).join(''); + return '
' + + '' + + '' + + '' + + '' + + '' + rows + '
NameZeilenSpracheGut/BöseStimmeTagsSTTTSBild
'; +} + // ── Character detail modal ──────────────────────────────────────────────────── function _lcdSourcesHtml(sources) { @@ -314,10 +428,17 @@ function _lcdPromptBox(label, value, sheetKey) { + ''; } -function _lcdFieldEdit(label, value, sheetKey) { +// sourceIdxs (optional): indices into the character's sources list — shown +// as small clickable "[N]" links next to the label so a reader who doubts a +// fact can jump straight to the exact page + quote it was extracted from, +// instead of scanning the whole "Quellen im Text" list at the bottom. +function _lcdFieldEdit(label, value, sheetKey, sourceIdxs) { const v = _libStr(value); + const links = (sourceIdxs || []).map(function (idx) { + return '' + (sourceIdxs.indexOf(idx) + 1) + ''; + }).join(''); return '
' - + (label ? '
' + escHtml(label) + '
' : '') + + (label || links ? '
' + escHtml(label) + (links ? ' ' + links + '' : '') + '
' : '') + '
' + escHtml(v) + '
' + '
'; } @@ -424,15 +545,24 @@ async function _charDetailPage(rec, allChars) { ) : ''; const sourcesList = Array.isArray(sh.sources) ? sh.sources.filter(function (s) { return s && (s.quote || s.page != null); }) : []; + // Group source indices by the sheet field they were extracted for + // (line_hint), so each detail field below can show "jump to this exact + // citation" links instead of making the reader search the full list. + const sourcesByField = {}; + sourcesList.forEach(function (s, idx) { + const key = _libStr(s.line_hint || s.hint || '').trim().toLowerCase(); + if (!key) return; + (sourcesByField[key] = sourcesByField[key] || []).push(idx); + }); const sourcesHtml = sourcesList.length ? ( '
' + '' + '
' - + sourcesList.map(function (s) { + + sourcesList.map(function (s, idx) { const page = s.page != null ? 'Seite ' + s.page : ''; const hint = _libStr(s.line_hint || s.hint || ''); - return '
' + return '
' + (page || hint ? '
' + escHtml([page, hint].filter(Boolean).join(' · ')) + '
' : '') + (s.quote ? '
„' + escHtml(_libStr(s.quote)) + '"
' : '') + '
'; @@ -485,38 +615,38 @@ async function _charDetailPage(rec, allChars) { + alignHtml + '
' + _lcdSection('mdi-card-account-details-outline', 'Identität', [ - _lcdFieldEdit('Voller Name', sh.full_name, 'full_name'), - _lcdFieldEdit('Vorname', sh.first_name, 'first_name'), - _lcdFieldEdit('Nachname', sh.last_name, 'last_name'), - _lcdFieldEdit('Titel / Rolle', sh.title, 'title'), - _lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases'), + _lcdFieldEdit('Voller Name', sh.full_name, 'full_name', sourcesByField['full_name']), + _lcdFieldEdit('Vorname', sh.first_name, 'first_name', sourcesByField['first_name']), + _lcdFieldEdit('Nachname', sh.last_name, 'last_name', sourcesByField['last_name']), + _lcdFieldEdit('Titel / Rolle', sh.title, 'title', sourcesByField['title']), + _lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases', sourcesByField['aliases']), ]) + _lcdSection('mdi-account-outline', 'Erscheinung', [ - _lcdFieldEdit('Körperlich', sh.physical, 'physical'), - _lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing'), + _lcdFieldEdit('Körperlich', sh.physical, 'physical', sourcesByField['physical']), + _lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing', sourcesByField['clothing']), ]) + _lcdSection('mdi-drama-masks', 'Persönlichkeit', [ - _lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms'), - _lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern'), + _lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms', sourcesByField['mannerisms']), + _lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern', sourcesByField['voice_pattern']), ]) + _lcdSection('mdi-book-open-outline', 'Geschichte', [ - _lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory'), - _lcdFieldEdit('Motivation', sh.motivation, 'motivation'), - _lcdFieldEdit('Ängste', sh.fears, 'fears'), + _lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory', sourcesByField['backstory']), + _lcdFieldEdit('Motivation', sh.motivation, 'motivation', sourcesByField['motivation']), + _lcdFieldEdit('Ängste', sh.fears, 'fears', sourcesByField['fears']), ]) + _lcdSection('mdi-sword', 'Fähigkeiten', [ - _lcdFieldEdit('Fertigkeiten', sh.skills, 'skills'), - _lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities'), + _lcdFieldEdit('Fertigkeiten', sh.skills, 'skills', sourcesByField['skills']), + _lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities', sourcesByField['capabilities']), _lcdFieldEdit('Stärkstes Attribut', sh.attribute_high, 'attribute_high'), _lcdFieldEdit('Schwächstes Attribut', sh.attribute_low, 'attribute_low'), ]) + _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [ - _lcdFieldEdit('', sh.relationships, 'relationships'), + _lcdFieldEdit('', sh.relationships, 'relationships', sourcesByField['relationships']), relDotsHtml, ]) + _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [ - _lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style'), - _lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition'), + _lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style', sourcesByField['conflict_style']), + _lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition', sourcesByField['win_condition']), ]) + _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [ _lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'), diff --git a/static/style.css b/static/style.css index f237c09..dc72d17 100644 --- a/static/style.css +++ b/static/style.css @@ -558,7 +558,33 @@ audio { width: 100%; } .lib-chars-loading { padding:32px; text-align:center; color:var(--subtext); font-size:13px; } /* ── Characters / Cast view ─────────────────────────────────────────────────── */ -.lib-chars-toolbar { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; } +.lib-chars-toolbar { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; align-items:center; } +.lib-chars-view-toggle { display:flex; gap:2px; margin-left:auto; } +.lib-chars-view-toggle button.is-active { background:var(--accent); color:#fff; border-color:var(--accent); } + +/* Table view — dense alternative to the card grid for scanning a large cast */ +.lib-chars-tbl-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:8px; } +.lib-chars-tbl { width:100%; border-collapse:collapse; font-size:12.5px; white-space:nowrap; } +.lib-chars-tbl thead th { + text-align:left; padding:8px 10px; font-size:10.5px; font-weight:800; text-transform:uppercase; + letter-spacing:.04em; color:var(--subtext); border-bottom:1px solid var(--border); background:var(--panel); + position:sticky; top:0; z-index:1; +} +.lib-chars-tbl td { padding:6px 10px; border-bottom:1px solid var(--border); vertical-align:middle; } +.lib-chars-tbl-row { cursor:pointer; } +.lib-chars-tbl-row:hover { background:var(--panel); } +.lib-chars-tbl-avatar { width:28px; height:28px; font-size:12px; border:none; box-shadow:none; } +.lib-chars-tbl-row .lib-char-export { position:static; opacity:1; width:auto; height:auto; border:0; background:none; color:var(--subtext); padding:2px; } +.lib-chars-tbl-row .lib-char-export:hover { color:var(--accent); background:none; border:0; } +.lib-chars-tbl-name { font-weight:600; white-space:normal; min-width:120px; } +.lib-chars-tbl-voice { display:flex; align-items:center; gap:6px; } +.lib-chars-tbl-voice button { padding:2px 7px; font-size:11px; } +.lib-chars-tbl-tags { white-space:normal; display:flex; flex-wrap:wrap; gap:3px; max-width:220px; } +.lib-chars-tbl-dash { opacity:.4; } +.lib-chars-tbl-check { display:inline-flex; } +.lib-chars-tbl-check.is-yes { color:#4caf50; } +.lib-chars-tbl-check.is-no { color:var(--subtext); opacity:.35; } +.lib-chars-tbl td .lib-char-align-bar { width:60px; flex:none; display:inline-block; } .lib-chars-production { margin-bottom:28px; } .lib-char-card .lib-char-export { position:absolute; top:6px; right:6px; z-index:2; @@ -597,6 +623,12 @@ audio { width: 100%; } box-shadow: 0 2px 6px rgba(0,0,0,.08); } .lib-char-card:hover { transform:translateY(-3px); box-shadow:0 8px 22px rgba(0,0,0,.16); } +.lib-char-select { + position:absolute; top:8px; left:8px; z-index:3; cursor:pointer; + width:20px; height:20px; display:flex; align-items:center; justify-content:center; + background:rgba(0,0,0,.35); border-radius:5px; +} +.lib-char-select-cb { width:15px; height:15px; cursor:pointer; } /* Coloured banner at top of each card */ .lib-char-card-banner { height:90px; display:flex; align-items:center; justify-content:center; position:relative; @@ -926,6 +958,7 @@ audio { width: 100%; } color:var(--subtext); font-size:11px; font-weight:500; } .cl-tag-chip .mdi { font-size:12px; opacity:.7; } +.lib-char-tags { display:flex; flex-wrap:wrap; gap:4px; margin:4px 0; } /* Page card head */ .s-page-head { border-bottom:1px solid var(--border); padding-bottom:14px; }