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 ? ( '
' + '
Quellen im Text' + ' · Klicken zum Springen
' + '
' - + 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; }