Refine character sheets and live output UI

This commit is contained in:
mARTin-B78 2026-07-09 19:47:16 +02:00
parent 8f3fcbae04
commit 588ba3fcb8
9 changed files with 917 additions and 152 deletions

View File

@ -9,6 +9,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.14.21] — 2026-07-09
### Added
- **A one-character refine action in the audiobook profile panel** so mixed profiles can be re-run on a single selected character instead of recasting the whole book.
### Changed
- **Character Sheets and audiobook profiles now expose the same identity fields** — first name, last name, full name, aliases, gender, title, and occupation/profession are shown explicitly so one unified sheet layout is easier to read and edit.
- **The live Passage / Live output box now has a visible drag handle even when collapsed** so the user can tell it is resizable and adjust how much of the streaming output stays visible.
## [1.14.20] — 2026-07-09
### Changed
- **Character Sheets now puts prompt generation at the bottom of each sheet** with collapsible ready-to-copy boxes for Voice Design, Character Image, SillyTavern Character, and Character Concept Art prompts.
- **Character sheet evidence is easier to audit** — source hooks now show the supporting quote snippets, and alias labels are written as “also known as” in the UI.
### Fixed
- **The cast-character flow no longer invents new profiles while refining an already cast roster** — selective recasts now focus on the existing cast and use nearby paragraph windows around each hit as context.
## [1.14.19] — 2026-07-09 ## [1.14.19] — 2026-07-09
### Changed ### Changed
@ -1639,7 +1657,9 @@ Initial feature-complete release.
--- ---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.19...HEAD [Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.21...HEAD
[1.14.21]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.20...v1.14.21
[1.14.20]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.19...v1.14.20
[1.14.19]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.18...v1.14.19 [1.14.19]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.18...v1.14.19
[1.8.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...v1.8.1 [1.8.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...v1.8.1
[1.8.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.7.0...v1.8.0 [1.8.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.7.0...v1.8.0

View File

@ -1 +1 @@
1.14.19 1.14.21

View File

@ -614,7 +614,7 @@ def _charsheets_prepare(data: dict) -> dict:
"Do not output a blank sheet just because a target is listed.\n" "Do not output a blank sheet just because a target is listed.\n"
"- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name " "- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name "
"and put only the proven alternate form in aliases/title/full_name.\n" "and put only the proven alternate form in aliases/title/full_name.\n"
"- You may add a brand-new character only when the passage clearly introduces one that is not in the target roster.\n" "- Do NOT invent brand-new profiles in this pass. Only refine the already-casted roster; ignore places, institutions, and other non-person entities.\n"
"- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n" "- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n"
) if target_mode and known else "" ) if target_mode and known else ""
@ -635,13 +635,17 @@ def _charsheets_prepare(data: dict) -> dict:
"an actor can use to immediately know how to PLAY the character.\n" "an actor can use to immediately know how to PLAY the character.\n"
"PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any " "PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any "
"NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or " "NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or "
"blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty " "blank out a field you cannot improve. In this cast-character pass, only refine the already-casted "
"if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue " "roster and do NOT invent new profiles, places, or institutions. The passage may be a focused "
"and actions when reasonable, and mark any deduced value with a trailing ' *'.\n" "evidence window around a character mention, so use nearby paragraphs as context. Leave a field "
"empty if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from "
"dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'.\n"
"For each character output these fields:\n" "For each character output these fields:\n"
"- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n" "- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n"
"- 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" "- 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" "- first_name, last_name, full_name: split the character identity when known. Leave unknown parts empty.\n"
"- title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter').\n"
"- profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest').\n"
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\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" "- 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" "- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
@ -675,7 +679,7 @@ def _charsheets_prepare(data: dict) -> dict:
"Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. " "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" "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" "Respond with STRICT JSON only:\n"
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","gender":"","physical":"","clothing":"",' '{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","profession":"","archetype":"","gender":"","physical":"","clothing":"",'
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",' '"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",' '"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",' '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
@ -1023,8 +1027,8 @@ async def character_generate_prompts(request: Request):
return str(v or "").strip() return str(v or "").strip()
profile = "\n".join(f"{label}: {v}" for label, v in [ profile = "\n".join(f"{label}: {v}" for label, v in [
("Name", name), ("Aliases/titles", _f("aliases") or _f("title")), ("Name", name), ("Aliases / noble title", _f("aliases") or _f("title")),
("Archetype", _f("archetype")), ("Physical", _f("physical")), ("Clothing", _f("clothing")), ("Occupation", _f("profession")), ("Archetype", _f("archetype")), ("Physical", _f("physical")), ("Clothing", _f("clothing")),
("Mannerisms", _f("mannerisms")), ("Voice/speech pattern", _f("voice_pattern")), ("Mannerisms", _f("mannerisms")), ("Voice/speech pattern", _f("voice_pattern")),
("Backstory", _f("backstory")), ("Motivation", _f("motivation")), ("Fears", _f("fears")), ("Backstory", _f("backstory")), ("Motivation", _f("motivation")), ("Fears", _f("fears")),
("Relationships", _f("relationships")), ("Conflict style", _f("conflict_style")), ("Relationships", _f("relationships")), ("Conflict style", _f("conflict_style")),

View File

@ -286,7 +286,8 @@ def _md_to_cast(text: str) -> dict:
_CHAR_MD_SECTIONS = [ _CHAR_MD_SECTIONS = [
("Identity", [("Aliases / also known as", "aliases"), ("Full name", "full_name"), ("Identity", [("Aliases / also known as", "aliases"), ("Full name", "full_name"),
("Title / role", "title"), ("Archetype", "archetype")]), ("Title", "title"), ("Occupation", "profession"),
("Archetype", "archetype")]),
("Appearance", [("Physical", "physical"), ("Clothing", "clothing")]), ("Appearance", [("Physical", "physical"), ("Clothing", "clothing")]),
("Personality", [("Mannerisms & habits", "mannerisms"), ("Voice & speech", "voice_pattern"), ("Personality", [("Mannerisms & habits", "mannerisms"), ("Voice & speech", "voice_pattern"),
("Motivation", "motivation"), ("Fears", "fears")]), ("Motivation", "motivation"), ("Fears", "fears")]),
@ -368,6 +369,7 @@ async def reader_list_scripts(doc_id: str):
scripts.append({ scripts.append({
"name": f.stem, "name": f.stem,
"title": data.get("title", ""), "title": data.get("title", ""),
"profession": data.get("profession", ""),
"savedAt": data.get("savedAt"), "savedAt": data.get("savedAt"),
"segments": len(data.get("segments", [])), "segments": len(data.get("segments", [])),
"roster": data.get("roster", []), "roster": data.get("roster", []),

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.14.19"> <meta name="app-version" content="1.14.21">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.14.19"> <link rel="stylesheet" href="/static/style.css?v=1.14.21">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.14.19"></script> <script src="/static/loader.js?v=1.14.21"></script>
</body> </body>
</html> </html>

View File

@ -1013,9 +1013,9 @@ function highlightText(text, names) {
return html; return html;
} }
// Small dropdown anchored to the "Recast options" caret next to "View // Small dropdown anchored to the "Character options" caret next to "View
// Characters", once a book's already been cast before — offers the existing // Characters", once a book's already been cast before — offers the existing
// full recast plus a targeted "recast just these characters" flow. // full character-definition pass plus a targeted "define just these characters" flow.
// Lightweight "working on it" overlay for operations that block the main // Lightweight "working on it" overlay for operations that block the main
// thread for a noticeable moment (e.g. rewriting + redrawing thousands of // thread for a noticeable moment (e.g. rewriting + redrawing thousands of
// segments on a character merge) — without this, a long synchronous chunk // segments on a character merge) — without this, a long synchronous chunk
@ -1045,8 +1045,8 @@ function _abOpenRecastCharsMenu(anchorEl, bookTitle, existingChars) {
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'ab-recast-menu'; menu.className = 'ab-recast-menu';
menu.innerHTML = menu.innerHTML =
'<button type="button" data-action="all"><span class="mdi mdi-refresh"></span> Recast all</button>' + '<button type="button" data-action="all"><span class="mdi mdi-refresh"></span> Define all characters</button>' +
'<button type="button" data-action="selected"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> Recast selected…</button>'; '<button type="button" data-action="selected"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> Define selected characters…</button>';
document.body.appendChild(menu); document.body.appendChild(menu);
const rect = anchorEl.getBoundingClientRect(); const rect = anchorEl.getBoundingClientRect();
// The anchor button sits in the panel's bottom action bar, so opening // The anchor button sits in the panel's bottom action bar, so opening
@ -1071,15 +1071,17 @@ function _abOpenRecastCharsMenu(anchorEl, bookTitle, existingChars) {
}); });
} }
// Checkbox picker for "recast selected" — csForReaderSelective narrows // Checkbox picker for "define selected characters" — csForReaderSelective narrows
// extraction down to just the passages mentioning the picked character(s) // extraction down to just the passages mentioning the picked character(s)
// (plus neighbouring context) instead of the whole book. // (plus neighbouring context) instead of the whole book. The helper it calls
// is alias-aware, so selecting Darrag also catches evidence windows where the
// text uses one of his known alternate names.
function _abOpenRecastSelectPopup(bookTitle, existingChars) { function _abOpenRecastSelectPopup(bookTitle, existingChars) {
const ov = document.createElement('div'); const ov = document.createElement('div');
ov.className = 'audiobook-overlay'; ov.className = 'audiobook-overlay';
ov.innerHTML = '<div class="audiobook-box">' ov.innerHTML = '<div class="audiobook-box">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Recast selected characters</div>' + '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Define selected characters</div>'
+ '<div class="audiobook-msg">Only re-reads the passages that mention the characters you pick below (plus a little surrounding context) — everyone else\'s sheet is left as-is.</div>' + '<div class="audiobook-msg">Only re-reads the passages that mention the characters you pick below, including known aliases, plus a little surrounding context — everyone else\'s sheet is left as-is.</div>'
+ '<div class="ab-recast-select-list">' + '<div class="ab-recast-select-list">'
+ existingChars.map(c => '<label><input type="checkbox" class="ab-recast-cb" value="' + escHtml(c.name) + '"> ' + escHtml(c.name) + '</label>').join('') + existingChars.map(c => '<label><input type="checkbox" class="ab-recast-cb" value="' + escHtml(c.name) + '"> ' + escHtml(c.name) + '</label>').join('')
+ '</div>' + '</div>'
@ -1087,7 +1089,7 @@ function _abOpenRecastSelectPopup(bookTitle, existingChars) {
+ '<button class="btn-secondary btn-sm" id="ab-recast-select-all">Select all</button>' + '<button class="btn-secondary btn-sm" id="ab-recast-select-all">Select all</button>'
+ '<span style="flex:1"></span>' + '<span style="flex:1"></span>'
+ '<button class="btn-secondary btn-sm" id="ab-recast-cancel">Cancel</button>' + '<button class="btn-secondary btn-sm" id="ab-recast-cancel">Cancel</button>'
+ '<button class="btn-primary btn-sm" id="ab-recast-confirm">Recast selected</button>' + '<button class="btn-primary btn-sm" id="ab-recast-confirm">Define selected</button>'
+ '</div>' + '</div>'
+ '</div>'; + '</div>';
document.body.appendChild(ov); document.body.appendChild(ov);
@ -1102,10 +1104,18 @@ function _abOpenRecastSelectPopup(bookTitle, existingChars) {
const names = [...ov.querySelectorAll('.ab-recast-cb:checked')].map(b => b.value); const names = [...ov.querySelectorAll('.ab-recast-cb:checked')].map(b => b.value);
if (!names.length) { toast('Select at least one character', 'error'); return; } if (!names.length) { toast('Select at least one character', 'error'); return; }
ov.remove(); ov.remove();
if (typeof window.csForReaderSelective === 'function') await window.csForReaderSelective(names); if (typeof window.audiobookRecastSelectedCharacters === 'function') await window.audiobookRecastSelectedCharacters(names);
else if (typeof window.csForReaderSelective === 'function') await window.csForReaderSelective(names);
}); });
} }
async function audiobookRecastSelectedCharacters(selectedNames) {
if (typeof window.csForReaderSelective === 'function') return window.csForReaderSelective(selectedNames);
toast('Character-sheet recast is unavailable right now', 'error');
return null;
}
window.audiobookRecastSelectedCharacters = audiobookRecastSelectedCharacters;
// Live casting view: a scrolling feed of attributed lines + a character roster // Live casting view: a scrolling feed of attributed lines + a character roster
// that fills up as speakers are discovered. Far clearer than a bare bar. // that fills up as speakers are discovered. Far clearer than a bare bar.
function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) { function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
@ -1198,8 +1208,8 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
<div id="ab-cv-fill-text"></div> <div id="ab-cv-fill-text"></div>
</div> </div>
<button class="btn-secondary btn-sm" id="ab-cv-cancel" style="color:var(--red); margin-right:8px;"><span class="mdi mdi-close"></span> Cancel</button> <button class="btn-secondary btn-sm" id="ab-cv-cancel" style="color:var(--red); margin-right:8px;"><span class="mdi mdi-close"></span> Cancel</button>
<button class="btn-secondary btn-sm" id="ab-cv-start-recast-unk" style="display:none; margin-right:8px; color:var(--error);" title="Re-run only Unknown segments"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button> <button class="btn-secondary btn-sm" id="ab-cv-start-recast-unk" style="display:none; margin-right:8px; color:var(--error);" title="Re-run only Unknown segments"><span class="mdi mdi-account-question-outline"></span> Define unknowns</button>
<button class="btn-secondary btn-sm" id="ab-cv-start-recast" style="display:none; margin-right:8px;" title="Re-run entire document"><span class="mdi mdi-refresh"></span> Recast all</button> <button class="btn-secondary btn-sm" id="ab-cv-start-recast" style="display:none; margin-right:8px;" title="Re-run entire document"><span class="mdi mdi-refresh"></span> Define characters</button>
<button class="btn-primary btn-sm" id="ab-cv-start-cast" style="display:none;"><span class="mdi mdi-play"></span> Cast now</button> <button class="btn-primary btn-sm" id="ab-cv-start-cast" style="display:none;"><span class="mdi mdi-play"></span> Cast now</button>
</div>`; </div>`;
@ -1398,7 +1408,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
function apply(collapsed) { function apply(collapsed) {
if (side) side.classList.toggle('is-collapsed', collapsed); if (side) side.classList.toggle('is-collapsed', collapsed);
if (body) body.classList.toggle('side-collapsed', collapsed); if (body) body.classList.toggle('side-collapsed', collapsed);
if (cBtn) cBtn.querySelector('.mdi').className = 'mdi ' + (collapsed ? 'mdi-chevron-right' : 'mdi-chevron-left'); if (cBtn) cBtn.querySelector('.mdi').className = 'mdi ' + (collapsed ? 'mdi-chevron-left' : 'mdi-chevron-right');
if (cBtn) cBtn.title = collapsed ? 'Expand character list' : 'Collapse to avatars'; if (cBtn) cBtn.title = collapsed ? 'Expand character list' : 'Collapse to avatars';
} }
let collapsed = false; let collapsed = false;
@ -1541,7 +1551,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
recastBtn.style.display = 'inline-block'; recastBtn.style.display = 'inline-block';
recastUnkBtn.addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown)); recastUnkBtn.addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown));
recastBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast)); recastBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast));
panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to recast.'; panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to define characters.';
} else { } else {
const castBtn = panel.querySelector('#ab-cv-start-cast'); const castBtn = panel.querySelector('#ab-cv-start-cast');
castBtn.style.display = 'inline-block'; castBtn.style.display = 'inline-block';
@ -1834,7 +1844,10 @@ STRIKTE FORMAT- UND TEXTREGELN:
${_abStr(sh.archetype) ? `<div class="lcd-archetype">${escHtml(_abStr(sh.archetype))}</div>` : ''} ${_abStr(sh.archetype) ? `<div class="lcd-archetype">${escHtml(_abStr(sh.archetype))}</div>` : ''}
<div class="lcd-tier-gender"><span class="lcd-tier">${tierLabel}</span></div> <div class="lcd-tier-gender"><span class="lcd-tier">${tierLabel}</span></div>
</div> </div>
<button class="ab-cd-edit-btn" title="Charakterblatt bearbeiten"><span class="mdi mdi-pencil-outline"></span></button> <div class="ab-cd-header-actions">
<button class="ab-cd-refine-btn" title="Refine this character only"><span class="mdi mdi-refresh"></span> Refine</button>
<button class="ab-cd-edit-btn" title="Charakterblatt bearbeiten"><span class="mdi mdi-pencil-outline"></span></button>
</div>
</div> </div>
<div class="lcd-body ab-cd-body"> <div class="lcd-body ab-cd-body">
<div class="lcd-voice-top"> <div class="lcd-voice-top">
@ -1845,7 +1858,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
</div> </div>
${pct != null ? `<div class="lcd-align-section"><div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div><div class="lcd-align-bar-wrap"><span class="lcd-align-label">Böse</span><div class="lcd-align-bar"><div class="lcd-align-dot" style="left:${pct}%"></div></div><span class="lcd-align-label">Gut</span></div><div class="lcd-align-arc">${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)</div></div>` : ''} ${pct != null ? `<div class="lcd-align-section"><div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div><div class="lcd-align-bar-wrap"><span class="lcd-align-label">Böse</span><div class="lcd-align-bar"><div class="lcd-align-dot" style="left:${pct}%"></div></div><span class="lcd-align-label">Gut</span></div><div class="lcd-align-arc">${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)</div></div>` : ''}
<div class="lcd-sections"> <div class="lcd-sections">
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-card-account-details-outline"></span> Identität</div>${field('Voller Name',sh.full_name,'full_name')}${field('Vorname',sh.first_name,'first_name')}${field('Nachname',sh.last_name,'last_name')}${field('Titel / Rolle',sh.title,'title')}${field('Auch bekannt als',sh.aliases,'aliases')}</div> <div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-card-account-details-outline"></span> Identität</div>${field('Voller Name',sh.full_name,'full_name')}${field('Vorname',sh.first_name,'first_name')}${field('Nachname',sh.last_name,'last_name')}${field('Geschlecht',sh.gender,'gender')}${field('Titel',sh.title,'title')}${field('Beruf / Rolle',sh.profession,'profession')}${field('Auch bekannt als',sh.aliases,'aliases')}</div>
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-account-outline"></span> Erscheinung</div>${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}</div> <div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-account-outline"></span> Erscheinung</div>${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}</div>
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-drama-masks"></span> Persönlichkeit</div>${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}</div> <div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-drama-masks"></span> Persönlichkeit</div>${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}</div>
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-book-open-outline"></span> Geschichte</div>${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}</div> <div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-book-open-outline"></span> Geschichte</div>${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}</div>
@ -1886,6 +1899,13 @@ STRIKTE FORMAT- UND TEXTREGELN:
} }
}); });
detail.querySelector('.ab-cd-online')?.addEventListener('click', () => { if (typeof _charSearchOnline === 'function') _charSearchOnline(rec); }); detail.querySelector('.ab-cd-online')?.addEventListener('click', () => { if (typeof _charSearchOnline === 'function') _charSearchOnline(rec); });
detail.querySelector('.ab-cd-refine-btn')?.addEventListener('click', async () => {
const name = String(rec.name || '').trim();
if (!name) return;
if (typeof window.audiobookRecastSelectedCharacters === 'function') return window.audiobookRecastSelectedCharacters([name]);
if (typeof window.csForReaderSelective === 'function') return window.csForReaderSelective([name]);
toast('Character refinement is unavailable right now', 'error');
});
// Pencil edit toggle // Pencil edit toggle
let _saveTimer = null; let _saveTimer = null;
@ -3291,7 +3311,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const continueBtnHtml = resumable const continueBtnHtml = resumable
? `<button class="btn-primary btn-sm" id="ab-cv-continue" style="margin-right:8px;" title="Continue casting from passage ${_audiobook.completedChunks + 1} of ${_audiobook.completedTotal}, keeping already-cast passages"><span class="mdi mdi-play-circle-outline"></span> Continue casting</button>` ? `<button class="btn-primary btn-sm" id="ab-cv-continue" style="margin-right:8px;" title="Continue casting from passage ${_audiobook.completedChunks + 1} of ${_audiobook.completedTotal}, keeping already-cast passages"><span class="mdi mdi-play-circle-outline"></span> Continue casting</button>`
: ''; : '';
foot.innerHTML = `<span style="flex:1"></span>${continueBtnHtml}<button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality run — resolves Unknown speakers with stricter context checks while preserving the existing cast"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-secondary btn-sm" id="ab-cv-cast-chars" style="margin-right:8px;" title="Open the character-sheets stage for this book"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button><button class="btn-secondary btn-sm" id="ab-cv-export-md" style="margin-right:8px;" title="Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character"><span class="mdi mdi-folder-zip-outline"></span> Export cast .zip</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Assign voices to each character and synthesise in Script Rehearser"><span class="mdi mdi-drama-masks"></span> Edit Characters</button>`; foot.innerHTML = `<span style="flex:1"></span>${continueBtnHtml}<button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Define unknowns</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Define characters</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality run — resolves Unknown speakers with stricter context checks while preserving the existing cast"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-secondary btn-sm" id="ab-cv-cast-chars" style="margin-right:8px;" title="Open the character-sheets stage for this book"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button><button class="btn-secondary btn-sm" id="ab-cv-export-md" style="margin-right:8px;" title="Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character"><span class="mdi mdi-folder-zip-outline"></span> Export cast .zip</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Assign voices to each character and synthesise in Script Rehearser"><span class="mdi mdi-drama-masks"></span> Edit Characters</button>`;
const runVerificationPass = () => { const runVerificationPass = () => {
const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern. const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern.
@ -3389,7 +3409,7 @@ ABSOLUTE REGELN:
wrap.className = 'ab-cv-castchars-group'; wrap.className = 'ab-cv-castchars-group';
wrap.innerHTML = wrap.innerHTML =
'<button class="btn-secondary btn-sm ab-cv-castchars-view" title="Open this book\'s cast in the Library"><span class="mdi mdi-account-box-multiple-outline"></span> View Cast</button>' + '<button class="btn-secondary btn-sm ab-cv-castchars-view" title="Open this book\'s cast in the Library"><span class="mdi mdi-account-box-multiple-outline"></span> View Cast</button>' +
'<button class="btn-secondary btn-sm ab-cv-castchars-menu" title="Recast options"><span class="mdi mdi-chevron-down"></span></button>'; '<button class="btn-secondary btn-sm ab-cv-castchars-menu" title="Character options"><span class="mdi mdi-chevron-down"></span></button>';
castBtn.replaceWith(wrap); castBtn.replaceWith(wrap);
wrap.querySelector('.ab-cv-castchars-view').addEventListener('click', () => { wrap.querySelector('.ab-cv-castchars-view').addEventListener('click', () => {
if (typeof navTo === 'function') navTo('s-library'); if (typeof navTo === 'function') navTo('s-library');
@ -3564,8 +3584,8 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
_audiobook.cancel = true; _audiobook.cancel = true;
_audiobook.running = false; _audiobook.running = false;
_audiobook.abort = null; _audiobook.abort = null;
view.done({ stopped: true, message: 'Recast stopped before any lines were updated.' }); view.done({ stopped: true, message: 'Character definition stopped before any lines were updated.' });
toast('Recast stopped. Existing cast preserved.', 'info'); toast('Character definition stopped. Existing cast preserved.', 'info');
return; return;
} }
} }
@ -3702,7 +3722,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
} }
view.update(_audiobook.cancel ? done : unknownIdxs.length); view.update(_audiobook.cancel ? done : unknownIdxs.length);
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') view.note('Error recasting unknown: ' + e.message); if (e.name !== 'AbortError') view.note('Error defining unknowns: ' + e.message);
} finally { } finally {
_audiobook.running = false; _audiobook.running = false;
_audiobook.abort = null; _audiobook.abort = null;
@ -3728,8 +3748,8 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
if (_audiobook.cancel) { if (_audiobook.cancel) {
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal); if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
view.done({ stopped: true, message: 'Recast stopped. Existing cast preserved.' }); view.done({ stopped: true, message: 'Character definition stopped. Existing cast preserved.' });
toast('Recast stopped. Existing cast preserved.', 'info'); toast('Character definition stopped. Existing cast preserved.', 'info');
return; return;
} }
@ -3794,7 +3814,7 @@ async function audiobookOpenCastView() {
const wasDone = draftTotal > 0 && draftDone >= draftTotal; const wasDone = draftTotal > 0 && draftDone >= draftTotal;
const canContinue = draftDone > 0 && draftTotal > 0 && draftDone < draftTotal; const canContinue = draftDone > 0 && draftTotal > 0 && draftDone < draftTotal;
const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt'; const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt';
view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${canContinue ? ' Casting wurde unterbrochen — „Continue casting" setzt an Passage ' + (draftDone + 1) + ' fort, „Recast all" für komplette Neuauswertung.' : (!wasDone ? ' Fortschritt wurde gesichert.' : '')}`); view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${canContinue ? ' Casting wurde unterbrochen — „Continue casting" setzt an Passage ' + (draftDone + 1) + ' fort, „Define characters" für komplette Neuauswertung.' : (!wasDone ? ' Fortschritt wurde gesichert.' : '')}`);
const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`; const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);

View File

@ -13,11 +13,13 @@ const _cs = { running: false, cancel: false, cache: {} };
// prefixes are still applied server-side regardless, since they depend on // prefixes are still applied server-side regardless, since they depend on
// per-request state a saved prompt can't know about. // per-request state a saved prompt can't know about.
const CS_DEFAULT_PROMPT = `You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich character sheets passage by passage as a book is read. Extract playable, action-oriented sheets an actor can use to immediately know how to PLAY the character. const CS_DEFAULT_PROMPT = `You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich character sheets passage by passage as a book is read. Extract playable, action-oriented sheets an actor can use to immediately know how to PLAY the character.
PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'. PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or blank out a field you cannot improve. In this cast-character pass, ONLY refine the already-casted roster and do NOT invent new profiles, places, or institutions. The text may be a focused evidence window around a mention, so use the nearby paragraphs as context. Leave a field empty if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'.
For each character output these fields: For each character output these fields:
- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title. - name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.
- 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. - 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.
- 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'). - first_name, last_name, full_name: split the character identity when known. Leave unknown parts empty.
- title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter').
- profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest').
- archetype: a two-word role summary (e.g. 'Ruthless Scholar') - archetype: a two-word role summary (e.g. 'Ruthless Scholar')
- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable. - gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.
- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system. - physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.
@ -46,7 +48,7 @@ For each character output these fields:
IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name. IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.
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. 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.
Respond with STRICT JSON only: Respond with STRICT JSON only:
{"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":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]} {"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","profession":"","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":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
/no-think`; /no-think`;
// Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the // Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the
@ -94,7 +96,7 @@ function csRehearserText() {
// ── Generation (chunked + merged by character) ─────────────────────────────── // ── Generation (chunked + merged by character) ───────────────────────────────
const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note', const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'profession', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
'attribute_high', 'attribute_low', 'skills', 'capabilities', 'attribute_high', 'attribute_low', 'skills', 'capabilities',
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern', 'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt', 'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt',
@ -113,6 +115,7 @@ const CS_SOURCE_FIELDS = {
voice_pattern: ['voice', 'speech', 'dialogue'], voice_pattern: ['voice', 'speech', 'dialogue'],
backstory: ['backstory', 'origin', 'history'], backstory: ['backstory', 'origin', 'history'],
alignment: ['alignment', 'ethos', 'morality'], alignment: ['alignment', 'ethos', 'morality'],
profession: ['profession', 'occupation', 'job', 'role'],
skills: ['skill', 'capability', 'ability'], skills: ['skill', 'capability', 'ability'],
capabilities: ['capability', 'ability', 'combat', 'magic'], capabilities: ['capability', 'ability', 'combat', 'magic'],
secret: ['secret', 'flaw'], secret: ['secret', 'flaw'],
@ -124,11 +127,11 @@ function csExistingSummary(map) {
if (!map.size) return ''; if (!map.size) return '';
// Keep the summary compact — just name + archetype + which key fields are still blank. // Keep the summary compact — just name + archetype + which key fields are still blank.
// Long "still needs:" lists were blowing up context windows on early passages. // Long "still needs:" lists were blowing up context windows on early passages.
const KEY_FIELDS = ['physical', 'backstory', 'motivation', 'voice_pattern', 'relationships']; const KEY_FIELDS = ['physical', 'profession', 'backstory', 'motivation', 'voice_pattern', 'relationships'];
return [...map.values()].slice(0, 40).map(s => { return [...map.values()].slice(0, 40).map(s => {
const missing = KEY_FIELDS.filter(f => !(s[f] || '').trim()); const missing = KEY_FIELDS.filter(f => !(s[f] || '').trim());
const suffix = missing.length < KEY_FIELDS.length ? ` | needs: ${missing.join(', ')}` : ' | complete'; const suffix = missing.length < KEY_FIELDS.length ? ` | needs: ${missing.join(', ')}` : ' | complete';
const aliases = [s.full_name, s.title, s.aliases].filter(Boolean).join(', '); const aliases = [s.full_name, s.title, s.profession, s.aliases].filter(Boolean).join(', ');
return `- ${s.name}${aliases ? ` aka ${aliases}` : ''}${s.archetype ? ` (${s.archetype})` : ''}${suffix}`; return `- ${s.name}${aliases ? ` aka ${aliases}` : ''}${s.archetype ? ` (${s.archetype})` : ''}${suffix}`;
}).join('\n'); }).join('\n');
} }
@ -164,6 +167,10 @@ function csCleanRoster(names) {
return out; return out;
} }
function csRosterKey(v) {
return String(v || '').trim().toLowerCase();
}
function csKnownReaderRoster() { function csKnownReaderRoster() {
const names = []; const names = [];
const add = (name) => { const add = (name) => {
@ -189,7 +196,7 @@ function csKnownReaderRoster() {
function csBlankSheet(name) { function csBlankSheet(name) {
return { return {
name, aliases: '', first_name: '', last_name: '', full_name: '', title: '', name, aliases: '', first_name: '', last_name: '', full_name: '', title: '', profession: '',
archetype: '', gender: '', physical: '', clothing: '', alignment: '', arc_note: '', archetype: '', gender: '', physical: '', clothing: '', alignment: '', arc_note: '',
attribute_high: '', attribute_low: '', skills: '', capabilities: '', attribute_high: '', attribute_low: '', skills: '', capabilities: '',
backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', voice_pattern: '', backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', voice_pattern: '',
@ -198,6 +205,36 @@ function csBlankSheet(name) {
}; };
} }
function csSeedSheet(raw) {
const name = _csStr(raw?.name).trim();
const seed = csBlankSheet(name);
if (!name) return seed;
const src = raw?.sheet && typeof raw.sheet === 'object' ? raw.sheet : raw;
CS_SCALAR_FIELDS.forEach(f => {
if (src && src[f] != null) seed[f] = _csStr(src[f]);
});
if (Array.isArray(src?.inventory)) seed.inventory = [...src.inventory];
else if (typeof src?.inventory === 'string') {
seed.inventory = src.inventory.split(',').map(x => x.trim()).filter(Boolean);
}
if (Array.isArray(src?.sources)) {
seed.sources = src.sources.map(item => item && typeof item === 'object' ? {
page: item.page != null ? item.page : null,
quote: _csStr(item.quote).slice(0, 240),
line_hint: _csStr(item.line_hint).slice(0, 60),
} : null).filter(Boolean);
}
if (src?.tier) seed.tier = String(src.tier).toLowerCase().startsWith('main') ? 'main' : 'supporting';
if (src?.moral_alignment_score != null) {
const mas = parseInt(src.moral_alignment_score, 10);
if (!Number.isNaN(mas)) seed.moral_alignment_score = Math.max(0, Math.min(100, mas));
}
if (src?.arc_direction) seed.arc_direction = src.arc_direction;
if (src?.gender) seed.gender = String(src.gender).trim().toLowerCase();
if (src?.line_count != null) seed.line_count = src.line_count;
return seed;
}
function csNameTokens(sheet) { function csNameTokens(sheet) {
const out = new Set(); const out = new Set();
for (const f of CS_IDENTITY_FIELDS) { for (const f of CS_IDENTITY_FIELDS) {
@ -207,6 +244,101 @@ function csNameTokens(sheet) {
return out; return out;
} }
function csSheetMatchesRoster(sheet, roster = []) {
const allowed = new Set((roster || []).map(csRosterKey).filter(Boolean));
if (!allowed.size) return true;
for (const token of csNameTokens(sheet)) {
for (const name of allowed) {
if (token === name || token.includes(name) || name.includes(token)) return true;
}
}
return false;
}
function csIdentityNeedles(value) {
return csSplitIdentityTokens(value, { aliases: true }).filter(v => {
const n = String(v || '').trim();
return n && n.length > 1 && !/^(die|der|das|den|dem|des|ein|eine|einer|er|sie|es|ich|du|wir|ihr)$/i.test(n);
});
}
function csRecordNeedles(rec) {
const seen = new Set();
const out = [];
const add = (value) => {
csIdentityNeedles(value).forEach(v => {
const key = v.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(v);
});
};
add(rec?.name);
add(rec?.sheet?.aliases);
add(rec?.sheet?.full_name);
add([rec?.sheet?.first_name, rec?.sheet?.last_name].filter(Boolean).join(' '));
return out;
}
function csParagraphBlocks(text) {
return String(text || '')
.replace(/\r\n/g, '\n')
.split(/\n\s*\n+/)
.map(s => s.trim())
.filter(Boolean);
}
function csReaderParagraphBlocks() {
if (readerState.mode === 'text' && readerState.docText) return csParagraphBlocks(readerState.docText);
const base = Array.isArray(readerState.baseSentences) && readerState.baseSentences.length
? readerState.baseSentences
: (Array.isArray(readerState.sentences) ? readerState.sentences : []);
if (!base.length) return csParagraphBlocks(csReaderText());
const paras = [];
let cur = '';
let curPage = null;
for (const s of base) {
const page = s?.words?.[0]?.page ?? null;
const pageChanged = readerState.mode === 'pdf' && page != null && curPage != null && page !== curPage;
if ((s?.paraStart || pageChanged) && cur.trim()) {
paras.push(cur.trim());
cur = '';
}
if (!cur && readerState.mode === 'pdf' && page != null) {
cur += `[p.${page + 1}] `;
}
cur += (cur && !/\s$/.test(cur) ? ' ' : '') + String(s?.text || '').trim();
curPage = page != null ? page : curPage;
}
if (cur.trim()) paras.push(cur.trim());
return paras;
}
function csParagraphHasNeedle(paragraph, needle) {
const p = String(paragraph || '');
const n = String(needle || '').trim();
if (!n || n.length < 2) return false;
const lowerP = p.toLowerCase();
const lowerN = n.toLowerCase();
if (lowerN.includes(' ')) return lowerP.includes(lowerN);
const esc = lowerN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[^\\p{L}\\p{N}_])${esc}(?=$|[^\\p{L}\\p{N}_])`, 'iu').test(p);
}
function csEvidenceWindowText(text, needles, before = 2, after = 2) {
const paras = Array.isArray(text) ? text.slice() : csParagraphBlocks(text);
if (!paras.length) return String(text || '').trim();
const hits = new Set();
paras.forEach((para, idx) => {
if (!(needles || []).some(n => csParagraphHasNeedle(para, n))) return;
for (let i = idx - before; i <= idx + after; i++) {
if (i >= 0 && i < paras.length) hits.add(i);
}
});
if (!hits.size) return '';
return [...hits].sort((a, b) => a - b).map(i => paras[i]).join('\n\n').trim();
}
function csFindMergeKey(map, sheet) { function csFindMergeKey(map, sheet) {
const incoming = csNameTokens(sheet); const incoming = csNameTokens(sheet);
if (!incoming.size) return (sheet.name || '').trim().toLowerCase(); if (!incoming.size) return (sheet.name || '').trim().toLowerCase();
@ -268,12 +400,12 @@ function csProgressSnapshot(map, changed = []) {
return { return {
key, key,
name: s.name || key, name: s.name || key,
alias: [s.full_name, s.title].filter(Boolean).join(' · '), alias: [s.full_name, s.title, s.profession].filter(Boolean).join(' · '),
status: changedByKey.get(key) || '', status: changedByKey.get(key) || '',
filled, filled,
main: s.tier === 'main', main: s.tier === 'main',
}; };
}).sort((a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name)).slice(0, 18); }).sort((a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name));
} }
// SSE reader for /api/character-sheets/stream, mirroring // SSE reader for /api/character-sheets/stream, mirroring
@ -343,12 +475,22 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
} }
} }
const map = new Map(); const map = new Map();
const seedSheets = Array.isArray(opts.seedSheets) ? opts.seedSheets : [];
seedSheets.forEach(seedRaw => {
const seed = csSeedSheet(seedRaw);
const name = String(seed.name || '').trim();
if (!name) return;
map.set(name.toLowerCase(), seed);
});
// Pre-seed the roster with known characters from the casting run so the LLM // Pre-seed the roster with known characters from the casting run so the LLM
// fills those profiles instead of treating the cast list as aliases to invent. // fills those profiles instead of treating the cast list as aliases to invent.
const roster = csCleanRoster(initialRoster); const roster = csCleanRoster(initialRoster);
const targetMode = roster.length > 0; const targetMode = roster.length > 0;
const knownTotal = targetMode ? roster.length : null; const knownTotal = targetMode ? roster.length : null;
roster.forEach(name => map.set(name.toLowerCase(), csBlankSheet(name))); roster.forEach(name => {
const key = name.toLowerCase();
if (!map.has(key)) map.set(key, csBlankSheet(name));
});
try { try {
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
if (_cs.cancel) break; if (_cs.cancel) break;
@ -356,7 +498,7 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
const charLabel = knownTotal const charLabel = knownTotal
? `${knownTotal} cast characters queued · ${detailed} profiles with details` ? `${knownTotal} cast characters queued · ${detailed} profiles with details`
: `${map.size} characters found`; : `${map.size} characters found`;
prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel, csProgressSnapshot(map)); prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel, csProgressSnapshot(map), [...map.values()]);
prog.startPassage(chunks[i]); prog.startPassage(chunks[i]);
try { try {
// Read fresh each passage so an edit made mid-run (via the Prompt // Read fresh each passage so an edit made mid-run (via the Prompt
@ -383,12 +525,14 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
try { data = JSON.parse(raw); } try { data = JSON.parse(raw); }
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
} }
const changed = csMerge(map, data.sheets || []); const incomingSheets = targetMode ? (data.sheets || []).filter(s => csSheetMatchesRoster(s, roster)) : (data.sheets || []);
const changed = csMerge(map, incomingSheets);
if (changed.length) prog.focus(changed[0].name);
if (!targetMode) { if (!targetMode) {
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); (data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); });
(data.sheets || []).forEach(s => { (data.sheets || []).forEach(s => {
csNameTokens(s).forEach(n => { csNameTokens(s).forEach(n => {
const display = [s.name, s.full_name, s.title, s.aliases].filter(Boolean).join(', ').split(/[,;/|]/).map(x => x.trim()).find(x => x.toLowerCase() === n) || n; const display = [s.name, s.full_name, s.title, s.profession, s.aliases].filter(Boolean).join(', ').split(/[,;/|]/).map(x => x.trim()).find(x => x.toLowerCase() === n) || n;
if (display && !roster.some(r => r.toLowerCase() === display.toLowerCase())) roster.push(display); if (display && !roster.some(r => r.toLowerCase() === display.toLowerCase())) roster.push(display);
}); });
}); });
@ -397,7 +541,7 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
const newCharLabel = knownTotal const newCharLabel = knownTotal
? `${knownTotal} cast characters queued · ${newDetailed} profiles with details` ? `${knownTotal} cast characters queued · ${newDetailed} profiles with details`
: `${map.size} characters found`; : `${map.size} characters found`;
prog.update(i + 1, `Passage ${i + 1} / ${chunks.length}…`, newCharLabel, csProgressSnapshot(map, changed)); prog.update(i + 1, `Passage ${i + 1} / ${chunks.length}…`, newCharLabel, csProgressSnapshot(map, changed), [...map.values()]);
} catch (e) { } catch (e) {
console.error('Character sheets passage', i + 1, 'failed:', e); console.error('Character sheets passage', i + 1, 'failed:', e);
toast('Passage ' + (i + 1) + ' failed: ' + (e.message || String(e)), 'error'); toast('Passage ' + (i + 1) + ' failed: ' + (e.message || String(e)), 'error');
@ -427,7 +571,7 @@ function csProgress(total, hostEl = null) {
// passage being analysed on the left, the LLM's raw JSON output filling // passage being analysed on the left, the LLM's raw JSON output filling
// in live on the right, and a character sidebar (current one highlighted) // in live on the right, and a character sidebar (current one highlighted)
// so the user can watch each sheet actually being built. // so the user can watch each sheet actually being built.
root.innerHTML = `<div class="audiobook-box cs-progress-box cs-progress-box-big"> root.innerHTML = `<div class="audiobook-box cs-progress-box cs-progress-box-big cs-page-shell">
<div class="cs-progress-head"> <div class="cs-progress-head">
<div class="audiobook-title"> <div class="audiobook-title">
<span class="mdi mdi-account-details-outline"></span> Character sheets <span class="mdi mdi-account-details-outline"></span> Character sheets
@ -447,20 +591,41 @@ function csProgress(total, hostEl = null) {
</div> </div>
<div class="reader-synth-track" style="margin-bottom:0"><div class="reader-synth-fill" id="cs-progress-fill"></div></div> <div class="reader-synth-track" style="margin-bottom:0"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
</div> </div>
<div class="cs-progress-body"> <div class="cs-progress-layout" id="cs-progress-layout">
<div class="cs-progress-main"> <div class="cs-progress-left">
<div class="cs-progress-col"> <div class="cs-progress-preview" id="cs-progress-preview">
<div class="cs-progress-col-label"><span class="mdi mdi-text-long"></span> Passage</div> <div class="cs-progress-preview-empty">Select a character to preview the sheet as it fills.</div>
<pre class="cs-progress-passage-pre" id="cs-progress-passage-pre"></pre>
</div> </div>
<div class="cs-progress-col"> <div class="cs-progress-output" id="cs-progress-output">
<div class="cs-progress-col-label"><span class="mdi mdi-code-json"></span> Live output <span class="cs-progress-live-status" id="cs-progress-live-status">watching…</span></div> <div class="cs-progress-output-head">
<pre class="cs-progress-live-pre" id="cs-progress-live-pre"></pre> <span class="cs-progress-output-title"><span class="mdi mdi-text-long"></span> Passage / Live output <span class="cs-progress-live-status" id="cs-progress-live-status">watching…</span></span>
<button class="ab-cv-side-collapse" id="cs-progress-output-collapse" type="button" title="Collapse passage and live output"><span class="mdi mdi-chevron-down"></span></button>
</div>
<div class="cs-progress-mini" id="cs-progress-mini" aria-hidden="true"><span></span></div>
<div class="cs-progress-body">
<div class="cs-progress-main">
<div class="cs-progress-col">
<div class="cs-progress-col-label">Passage</div>
<pre class="cs-progress-passage-pre" id="cs-progress-passage-pre"></pre>
</div>
<div class="cs-progress-col">
<div class="cs-progress-col-label">Live output</div>
<pre class="cs-progress-live-pre" id="cs-progress-live-pre"></pre>
</div>
</div> </div>
</div> </div>
<div class="cs-progress-side"> <button class="cs-progress-resize-handle" id="cs-progress-output-resize" type="button" title="Resize passage and live output" aria-label="Resize passage and live output"><span class="mdi mdi-drag-vertical"></span></button>
<div class="cs-progress-side-title" id="cs-progress-side-title">Characters found</div> </div>
<div class="ab-cv-side cs-progress-side" id="cs-progress-side">
<div class="ab-cv-side-head">
<span class="ab-cv-side-title" id="cs-progress-side-title">Characters found</span>
<button class="ab-cv-side-collapse" id="cs-progress-side-collapse" type="button" title="Collapse character list"><span class="mdi mdi-chevron-right"></span></button>
</div>
<div class="ab-cv-side-tools">
<input class="ab-cv-side-search" id="cs-progress-search" type="search" placeholder="Search characters...">
</div>
<div class="cs-progress-chars" id="cs-progress-chars"></div> <div class="cs-progress-chars" id="cs-progress-chars"></div>
<div class="ab-cv-side-done" id="cs-progress-side-done"></div>
</div> </div>
</div> </div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div> <div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
@ -548,17 +713,174 @@ function csProgress(total, hostEl = null) {
const msg = root.querySelector('#cs-progress-msg'); const msg = root.querySelector('#cs-progress-msg');
const chars = root.querySelector('#cs-progress-chars'); const chars = root.querySelector('#cs-progress-chars');
const sideTitle = root.querySelector('#cs-progress-side-title'); const sideTitle = root.querySelector('#cs-progress-side-title');
const sideDone = root.querySelector('#cs-progress-side-done');
const sideCollapse = root.querySelector('#cs-progress-side-collapse');
const leftPane = root.querySelector('.cs-progress-left');
const outputPanel = root.querySelector('#cs-progress-output');
const outputCollapse = root.querySelector('#cs-progress-output-collapse');
const outputResize = root.querySelector('#cs-progress-output-resize');
const preview = root.querySelector('#cs-progress-preview');
const search = root.querySelector('#cs-progress-search');
const passagePre = root.querySelector('#cs-progress-passage-pre'); const passagePre = root.querySelector('#cs-progress-passage-pre');
const livePre = root.querySelector('#cs-progress-live-pre'); const livePre = root.querySelector('#cs-progress-live-pre');
const liveStatus = root.querySelector('#cs-progress-live-status'); const liveStatus = root.querySelector('#cs-progress-live-status');
let currentRoster = [];
let currentSheets = [];
let selectedName = '';
let searchQuery = '';
const keyFor = v => String(v || '').trim().toLowerCase();
const findSheet = (name, sheets = currentSheets) => (Array.isArray(sheets) ? sheets : []).find(s => keyFor(s?.name) === keyFor(name)) || null;
let outputCollapsed = false;
let outputExpandedHeight = 0;
const clampOutputHeight = (h) => {
const min = 140;
const max = Math.max(min, Math.round(window.innerHeight * 0.82));
return Math.max(min, Math.min(max, Math.round(h)));
};
const setOutputHeight = (h, persist = true) => {
const next = clampOutputHeight(h);
outputExpandedHeight = next;
if (!outputCollapsed && outputPanel) {
outputPanel.style.height = `${next}px`;
outputPanel.style.maxHeight = `${next}px`;
outputPanel.dataset.csHeight = String(next);
}
if (persist) {
try { localStorage.setItem('ttsvc_cs_output_height', String(next)); } catch (_) {}
}
};
const applyOutputCollapse = (collapsed) => {
outputCollapsed = !!collapsed;
outputPanel?.classList.toggle('is-collapsed', outputCollapsed);
leftPane?.classList.toggle('is-output-collapsed', outputCollapsed);
if (outputCollapse) {
const icon = outputCollapse.querySelector('.mdi');
if (icon) icon.className = 'mdi ' + (outputCollapsed ? 'mdi-chevron-up' : 'mdi-chevron-down');
outputCollapse.title = outputCollapsed ? 'Expand passage and live output' : 'Collapse passage and live output';
}
if (outputPanel) {
if (outputCollapsed) {
if (!outputExpandedHeight) {
const cur = parseInt(outputPanel.dataset.csHeight || '', 10) || parseInt(outputPanel.style.height || '', 10) || outputPanel.getBoundingClientRect().height;
if (cur) outputExpandedHeight = clampOutputHeight(cur);
}
outputPanel.style.height = '44px';
outputPanel.style.maxHeight = '44px';
} else {
const h = outputExpandedHeight || parseInt(outputPanel.dataset.csHeight || '', 10) || 260;
outputPanel.style.height = `${clampOutputHeight(h)}px`;
outputPanel.style.maxHeight = `${clampOutputHeight(h)}px`;
outputPanel.dataset.csHeight = String(clampOutputHeight(h));
}
}
try { localStorage.setItem('ttsvc_cs_output_collapsed', outputCollapsed ? '1' : '0'); } catch (_) {}
};
let sideCollapsed = false;
const applySideCollapse = (collapsed) => {
sideCollapsed = !!collapsed;
root.querySelector('#cs-progress-layout')?.classList.toggle('side-collapsed', sideCollapsed);
root.querySelector('#cs-progress-side')?.classList.toggle('is-collapsed', sideCollapsed);
if (sideCollapse) {
const icon = sideCollapse.querySelector('.mdi');
if (icon) icon.className = 'mdi ' + (sideCollapsed ? 'mdi-chevron-left' : 'mdi-chevron-right');
sideCollapse.title = sideCollapsed ? 'Expand character list' : 'Collapse character list';
}
try { localStorage.setItem('ttsvc_cs_side_collapsed', sideCollapsed ? '1' : '0'); } catch (_) {}
};
try { sideCollapsed = localStorage.getItem('ttsvc_cs_side_collapsed') === '1'; } catch (_) {}
try {
const savedOutputCollapsed = localStorage.getItem('ttsvc_cs_output_collapsed');
if (savedOutputCollapsed !== null) outputCollapsed = savedOutputCollapsed === '1';
const savedOutputHeight = parseInt(localStorage.getItem('ttsvc_cs_output_height') || '', 10);
if (!Number.isNaN(savedOutputHeight) && savedOutputHeight > 0) outputExpandedHeight = clampOutputHeight(savedOutputHeight);
} catch (_) {}
applySideCollapse(sideCollapsed);
applyOutputCollapse(outputCollapsed);
sideCollapse?.addEventListener('click', () => applySideCollapse(!sideCollapsed));
outputCollapse?.addEventListener('click', () => applyOutputCollapse(!outputCollapsed));
if (outputResize) {
let dragging = false;
let startY = 0;
let startH = 0;
const onMove = (e) => {
if (!dragging) return;
if (outputCollapsed) applyOutputCollapse(false);
setOutputHeight(startH + (e.clientY - startY));
e.preventDefault();
};
const stopDrag = () => {
dragging = false;
document.body.classList.remove('cs-output-resizing');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', stopDrag);
};
outputResize.addEventListener('mousedown', (e) => {
e.preventDefault();
if (!outputPanel) return;
if (outputCollapsed) applyOutputCollapse(false);
dragging = true;
startY = e.clientY;
startH = parseInt(outputPanel.dataset.csHeight || '', 10) || outputPanel.getBoundingClientRect().height || 260;
document.body.classList.add('cs-output-resizing');
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', stopDrag);
});
}
const renderPreview = () => {
if (!preview) return;
const sheet = findSheet(selectedName) || currentSheets[0] || null;
if (!sheet) {
preview.innerHTML = '<div class="cs-progress-preview-empty">Select a character to preview the sheet as it fills.</div>';
return;
}
preview.innerHTML = csCardHtml(sheet);
preview.querySelector('.cs-card')?.classList.add('cs-progress-preview-card');
preview.querySelectorAll('.cs-head-btns, .cs-sources').forEach(el => el.remove());
preview.querySelectorAll('.cs-avatar').forEach(el => { el.style.pointerEvents = 'none'; el.style.cursor = 'default'; });
};
const renderChars = () => {
if (!chars) return;
const list = currentRoster.filter(c => {
if (!searchQuery) return true;
const name = keyFor(c.name);
const alias = keyFor(c.alias);
return name.includes(searchQuery) || alias.includes(searchQuery);
});
chars.innerHTML = list.length ? list.map(c => `
<div class="ab-char-item ${c.status ? 'is-active' : ''}" data-name="${escHtml(c.name || '')}">
<div class="cs-progress-fillmeta" aria-hidden="true" title="${escHtml(String(c.filled || 0))} profile fields filled">
<span style="width:${Math.min(100, Math.round(((c.filled || 0) / CS_DETAIL_FIELDS.length) * 100))}%"></span>
</div>
<span class="ab-char-dot"${typeof clNormalizeColor === 'function' ? ` style="background:${escHtml(clNormalizeColor(null, c.name || ''))}"` : ''}>${escHtml((c.name || '?')[0].toUpperCase())}</span>
<span class="ab-char-name" title="${escHtml(c.name || 'Unknown')}">${escHtml(c.name || 'Unknown')}</span>
<span class="ab-char-count" title="${escHtml(String(c.filled || 0))} profile fields filled">
<span class="ab-char-count-top">${escHtml(String(c.filled || 0))}</span>
<span class="ab-char-count-divider" aria-hidden="true"></span>
<span class="ab-char-count-bottom">${CS_DETAIL_FIELDS.length}</span>
</span>
</div>
`).join('') : '<div class="cs-progress-empty">reading…</div>';
chars.querySelectorAll('.ab-char-item').forEach(el => {
el.addEventListener('click', () => {
selectedName = keyFor(el.dataset.name);
renderPreview();
});
});
};
search?.addEventListener('input', () => {
searchQuery = search.value.trim().toLowerCase();
renderChars();
});
renderPreview();
return { return {
// Called once per passage before its request starts, so both panes are // Called once per passage before its request starts, so both panes are
// reset and honestly labelled before we know whether this model streams // reset and honestly labelled before we know whether this model streams
// real content or the request falls back to the blocking path. // real content or the request falls back to the blocking path.
startPassage(text) { startPassage(text) {
if (passagePre) passagePre.textContent = text || ''; if (passagePre) passagePre.textContent = text || '';
if (livePre) livePre.textContent = ''; if (livePre) livePre.textContent = 'Waiting for streamed JSON output…';
if (liveStatus) { liveStatus.textContent = 'watching…'; liveStatus.className = 'cs-progress-live-status'; } if (liveStatus) { liveStatus.textContent = 'watching…'; liveStatus.className = 'cs-progress-live-status'; }
outputPanel?.classList.add('is-working');
}, },
// Raw LLM output as it streams in — the JSON answer being written field // Raw LLM output as it streams in — the JSON answer being written field
// by field is itself the "watch it fill out the sheet" experience here, // by field is itself the "watch it fill out the sheet" experience here,
@ -566,34 +888,37 @@ function csProgress(total, hostEl = null) {
thinking(delta) { thinking(delta) {
if (!livePre || !delta) return; if (!livePre || !delta) return;
if (liveStatus) { liveStatus.textContent = 'streaming'; liveStatus.className = 'cs-progress-live-status is-live'; } if (liveStatus) { liveStatus.textContent = 'streaming'; liveStatus.className = 'cs-progress-live-status is-live'; }
outputPanel?.classList.add('is-working');
livePre.textContent = (livePre.textContent + delta).slice(-20000); livePre.textContent = (livePre.textContent + delta).slice(-20000);
livePre.scrollTop = livePre.scrollHeight; livePre.scrollTop = livePre.scrollHeight;
}, },
noStream() { noStream() {
if (liveStatus) { liveStatus.textContent = 'no live output for this model'; liveStatus.className = 'cs-progress-live-status'; } if (liveStatus) { liveStatus.textContent = 'no live output for this model'; liveStatus.className = 'cs-progress-live-status'; }
}, },
update(d, label, charLabel, roster = []) { focus(name) {
if (fill) fill.style.width = (d / total * 100) + '%'; if (name) {
if (msg && label) msg.textContent = label; selectedName = keyFor(name);
if (sideTitle) sideTitle.textContent = charLabel || 'Characters found'; renderPreview();
if (chars) {
const list = Array.isArray(roster) ? roster : [];
chars.innerHTML = list.length ? list.map(c => `
<div class="cs-progress-chip ${c.status ? 'is-' + escHtml(c.status) : ''}">
<span class="cs-progress-dot">${escHtml((c.name || '?')[0].toUpperCase())}</span>
<span class="cs-progress-meta">
<span class="cs-progress-name">${escHtml(c.name || 'Unknown')}</span>
${c.alias ? `<span class="cs-progress-alias">${escHtml(c.alias)}</span>` : ''}
</span>
<span class="cs-progress-fillmeta" title="${escHtml(String(c.filled || 0))} profile fields filled">
<span style="width:${Math.min(100, Math.round(((c.filled || 0) / CS_DETAIL_FIELDS.length) * 100))}%"></span>
</span>
${c.status ? `<span class="cs-progress-badge">${escHtml(c.status)}</span>` : ''}
</div>
`).join('') : '<div class="cs-progress-empty">reading…</div>';
} }
}, },
update(d, label, charLabel, roster = [], sheets = []) {
if (fill) fill.style.width = (d / total * 100) + '%';
if (msg && label) msg.textContent = label;
if (sideTitle) sideTitle.textContent = 'Characters found';
if (sideDone) sideDone.innerHTML = charLabel ? `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(charLabel)}</span>` : '';
outputPanel?.classList.add('is-working');
currentRoster = Array.isArray(roster) ? roster : [];
currentSheets = Array.isArray(sheets) ? sheets : [];
if (!selectedName && currentRoster.length) selectedName = keyFor((currentRoster.find(c => c.status) || currentRoster[0] || {}).name);
if (currentSheets.length && !findSheet(selectedName)) {
const changedSheet = currentRoster.find(c => c.status && findSheet(c.name, currentSheets));
if (changedSheet) selectedName = keyFor(changedSheet.name);
}
renderChars();
renderPreview();
},
done() { done() {
outputPanel?.classList.remove('is-working');
if (!usingHost) root.style.display = 'none'; if (!usingHost) root.style.display = 'none';
}, },
}; };
@ -730,12 +1055,28 @@ function csSourceBadgeHtml(s, field) {
if (!src) return ''; if (!src) return '';
const idx = (s.sources || []).findIndex(x => x === src); const idx = (s.sources || []).findIndex(x => x === src);
const n = idx >= 0 ? idx + 1 : 1; const n = idx >= 0 ? idx + 1 : 1;
return `<button class="cs-source-mark" type="button" data-page="${escHtml(String(src.page || ''))}" title="${escHtml(src.quote || 'Source')}">${n}</button>`; return `<button class="cs-source-mark" type="button" data-page="${escHtml(String(src.page || ''))}" title="${escHtml([src.line_hint, src.quote].filter(Boolean).join(' — ') || 'Source')}">${n}</button>`;
} }
function csField(label, value, sheet, field) { function csField(label, value, sheet, field) {
if (!value) return ''; if (!value) return '';
return `<div class="cs-row"><dt>${label}${sheet && field ? csSourceBadgeHtml(sheet, field) : ''}</dt><dd>${escHtml(String(value))}</dd></div>`; const badge = sheet && field ? csSourceBadgeHtml(sheet, field) : '';
return `<div class="cs-row"><dt>${label}</dt><dd>${escHtml(String(value))}${badge ? ` ${badge}` : ''}</dd></div>`;
}
function csPromptBox(label, value, sheetKey) {
const text = _csStr(value);
const has = !!text.trim();
return `<details class="lcd-prompt-box cs-prompt-box">
<summary>${escHtml(label)}${has ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>'}</summary>
<div class="lcd-prompt-body">
<div class="lcd-prompt-text cs-prompt-text lcd-field-editable" contenteditable="true" spellcheck="false" data-sheet-key="${escHtml(sheetKey)}">${escHtml(text)}</div>
<div class="lcd-prompt-actions">
<button type="button" class="btn-secondary btn-sm cs-prompt-copy" data-sheet-key="${escHtml(sheetKey)}"><span class="mdi mdi-content-copy"></span> Copy</button>
<button type="button" class="btn-secondary btn-sm cs-gen-prompt" data-sheet-key="${escHtml(sheetKey)}"><span class="mdi mdi-creation"></span> ${has ? 'Regenerate' : 'Generate'}</button>
</div>
</div>
</details>`;
} }
function csAvatarHtml(s) { function csAvatarHtml(s) {
@ -752,12 +1093,35 @@ function csAvatarHtml(s) {
</div>`; </div>`;
} }
function csIdentityTagList(s) {
const tags = [];
const add = (label, value) => {
const text = String(value || '').trim();
if (!text) return;
const key = text.toLowerCase();
if (tags.some(t => t.key === key)) return;
tags.push({ label, text, key });
};
(String(s.aliases || '').split(',') || []).map(x => x.trim()).filter(Boolean).forEach(v => add('also known as', v));
if (s.full_name && String(s.full_name).trim() && String(s.full_name).trim() !== String(s.name || '').trim()) add('full name', s.full_name);
const firstLast = [s.first_name, s.last_name].filter(Boolean).join(' ').trim();
if (firstLast && firstLast !== String(s.full_name || '').trim() && firstLast !== String(s.name || '').trim()) add('first / last', firstLast);
return tags;
}
function csCardHtml(s) { function csCardHtml(s) {
const inv = (s.inventory || []).filter(Boolean); const inv = (s.inventory || []).filter(Boolean);
const fullName = String(s.full_name || '').trim();
const metaTags = csIdentityTagList(s);
const identityHtml = [ const identityHtml = [
csField('Full name', s.full_name, s, 'full_name'), csField('Name', s.name || fullName, s, 'name'),
csField('Full Name', fullName && fullName.toLowerCase() !== String(s.name || '').toLowerCase() ? fullName : '', s, 'full_name'),
csField('First Name', s.first_name, s, 'first_name'),
csField('Last Name', s.last_name, s, 'last_name'),
csField('Also Known As', s.aliases, s, 'aliases'),
csField('Gender', s.gender, s, 'gender'),
csField('Title', s.title, s, 'title'), csField('Title', s.title, s, 'title'),
csField('First / Last', [s.first_name, s.last_name].filter(Boolean).join(' '), s, 'full_name'), csField('Occupation', s.profession, s, 'profession'),
].join(''); ].join('');
const invHtml = inv.length const invHtml = inv.length
? `<div class="cs-row"><dt>Signature Items</dt><dd><ul>${inv.map(i => `<li>${escHtml(i)}</li>`).join('')}</ul></dd></div>` ? `<div class="cs-row"><dt>Signature Items</dt><dd><ul>${inv.map(i => `<li>${escHtml(i)}</li>`).join('')}</ul></dd></div>`
@ -767,15 +1131,22 @@ function csCardHtml(s) {
: ''; : '';
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null)); const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
const srcHtml = sources.length const srcHtml = sources.length
? `<div class="cs-sources"><div class="cs-sources-title"><span class="mdi mdi-book-open-page-variant-outline"></span> Quellen / Evidence</div><div class="cs-source-list">${ ? `<div class="cs-sources"><div class="cs-sources-title"><span class="mdi mdi-book-open-page-variant-outline"></span> Sources / Evidence</div><div class="cs-source-list">${
sources.map((x, i) => sources.map((x, i) =>
`<button class="cs-source-link" type="button" data-page="${escHtml(String(x.page || ''))}" title="${escHtml(x.quote || '')}"> `<button class="cs-source-link" type="button" data-page="${escHtml(String(x.page || ''))}" title="${escHtml([x.line_hint, x.quote].filter(Boolean).join(' — ') || 'Source')}">
<b>${i + 1}</b>${x.line_hint ? `<span>${escHtml(x.line_hint)}</span>` : ''}${x.page != null ? `<em>p.${escHtml(String(x.page))}</em>` : ''} <b>${i + 1}</b>${x.line_hint ? `<span class="cs-source-hint">${escHtml(x.line_hint)}</span>` : ''}${x.quote ? `<span class="cs-source-quote">${escHtml(String(x.quote).slice(0, 140))}${String(x.quote).length > 140 ? '…' : ''}</span>` : ''}${x.page != null ? `<em>p.${escHtml(String(x.page))}</em>` : ''}
</button>` </button>`
).join('') ).join('')
}</div></div>` }</div></div>`
: ''; : '';
const promptHtml = `<div class="cs-prompts">
${csPromptBox('Voice Design Prompt', s.voice_design_prompt, 'voice_design_prompt')}
${csPromptBox('Character Image Prompt', s.image_prompt, 'image_prompt')}
${csPromptBox('SillyTavern Character Prompt', s.silly_tavern_prompt, 'silly_tavern_prompt')}
${csPromptBox('Character Concept Art Prompt', s.concept_art_prompt, 'concept_art_prompt')}
</div>`;
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}"> return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}">
<div class="cs-card-top"> <div class="cs-card-top">
${csAvatarHtml(s)} ${csAvatarHtml(s)}
@ -790,7 +1161,7 @@ function csCardHtml(s) {
<button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}" title="5-area psychological deep analysis"><span class="mdi mdi-brain"></span> Deep</button> <button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}" title="5-area psychological deep analysis"><span class="mdi mdi-brain"></span> Deep</button>
</div> </div>
</div> </div>
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''} ${metaTags.length ? `<div class="cs-meta-tags">${metaTags.map(t => `<span class="cs-meta-tag"><span class="cs-meta-tag-label">${escHtml(t.label)}</span><span class="cs-meta-tag-value">${escHtml(t.text)}</span></span>`).join('')}</div>` : ''}
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)} ${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
</div> </div>
</div> </div>
@ -808,13 +1179,12 @@ function csCardHtml(s) {
${csField('Motivation', s.motivation, s, 'motivation')} ${csField('Motivation', s.motivation, s, 'motivation')}
${csField('Fears', s.fears, s, 'fears')} ${csField('Fears', s.fears, s, 'fears')}
${csField('Mannerisms & Habits', s.mannerisms, s, 'mannerisms')} ${csField('Mannerisms & Habits', s.mannerisms, s, 'mannerisms')}
${s.voice_pattern ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice &amp; Speech${csSourceBadgeHtml(s, 'voice_pattern')}</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''} ${s.voice_pattern ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice &amp; Speech</dt><dd>${escHtml(String(s.voice_pattern))}${csSourceBadgeHtml(s, 'voice_pattern') ? ` ${csSourceBadgeHtml(s, 'voice_pattern')}` : ''}</dd></div>` : ''}
${csField('Voice Design Prompt', s.voice_design_prompt, s, 'voice_pattern')}
${csField('Image Prompt', s.image_prompt, s, 'physical')}
${csField('Dark Secret / Fatal Flaw', s.secret, s, 'secret')} ${csField('Dark Secret / Fatal Flaw', s.secret, s, 'secret')}
${csField('Conflict Style', s.conflict_style, s, 'conflict_style')} ${csField('Conflict Style', s.conflict_style, s, 'conflict_style')}
${csField('Win Condition', s.win_condition, s, 'win_condition')} ${csField('Win Condition', s.win_condition, s, 'win_condition')}
</dl> </dl>
${promptHtml}
${srcHtml} ${srcHtml}
</div>`; </div>`;
} }
@ -828,9 +1198,11 @@ function csToMarkdown(sheets) {
md += sec(tier === 'main' ? 'Main characters' : 'Supporting characters'); md += sec(tier === 'main' ? 'Main characters' : 'Supporting characters');
for (const s of list) { for (const s of list) {
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`; md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
if (s.aliases) md += `*aka ${s.aliases}*\n`; if (s.aliases) md += `*also known as ${s.aliases}*\n`;
if (s.full_name) md += `- **Full name:** ${s.full_name}\n`; if (s.full_name) md += `- **Full name:** ${s.full_name}\n`;
if (s.gender) md += `- **Gender:** ${s.gender}\n`;
if (s.title) md += `- **Title:** ${s.title}\n`; if (s.title) md += `- **Title:** ${s.title}\n`;
if (s.profession) md += `- **Occupation:** ${s.profession}\n`;
if (s.first_name || s.last_name) md += `- **Name parts:** ${[s.first_name, s.last_name].filter(Boolean).join(' ')}\n`; if (s.first_name || s.last_name) md += `- **Name parts:** ${[s.first_name, s.last_name].filter(Boolean).join(' ')}\n`;
const mas = s.moral_alignment_score ?? 50; const mas = s.moral_alignment_score ?? 50;
const masLabel = mas >= 70 ? 'Good' : mas <= 30 ? 'Evil' : 'Neutral'; const masLabel = mas >= 70 ? 'Good' : mas <= 30 ? 'Evil' : 'Neutral';
@ -846,6 +1218,8 @@ function csToMarkdown(sheets) {
+ f('Mannerisms & Habits', s.mannerisms) + f('Voice & Speech', s.voice_pattern) + f('Mannerisms & Habits', s.mannerisms) + f('Voice & Speech', s.voice_pattern)
+ f('Voice Design Prompt', s.voice_design_prompt) + f('Voice Design Prompt', s.voice_design_prompt)
+ f('Image Generation Prompt', s.image_prompt) + f('Image Generation Prompt', s.image_prompt)
+ f('SillyTavern Character Prompt', s.silly_tavern_prompt)
+ f('Character Concept Art Prompt', s.concept_art_prompt)
+ f('Signature Items', (s.inventory || []).join(', ')) + f('Signature Items', (s.inventory || []).join(', '))
+ f('Dark Secret / Fatal Flaw', s.secret) + f('Dark Secret / Fatal Flaw', s.secret)
+ f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition); + f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition);
@ -939,6 +1313,65 @@ function csWireResultInteractions(root, sheets, sourceText, book, inline = false
}); });
}); });
root.querySelectorAll('.cs-prompt-text[data-sheet-key]').forEach(el => {
el.addEventListener('input', () => {
const key = el.dataset.sheetKey;
const card = el.closest('.cs-card');
const sheet = sheets.find(s => s.name === card?.dataset.name);
if (sheet && key) sheet[key] = el.textContent || '';
});
});
root.querySelectorAll('.cs-prompt-copy').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const text = btn.closest('.lcd-prompt-body')?.querySelector('.cs-prompt-text')?.textContent.trim() || '';
if (!text) { toast('Nothing to copy yet — generate first', 'error'); return; }
if (typeof copyText === 'function') await copyText(text);
else navigator.clipboard?.writeText(text);
toast('Prompt copied', 'success');
});
});
root.querySelectorAll('.cs-gen-prompt').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const key = btn.dataset.sheetKey;
const card = btn.closest('.cs-card');
const name = card?.dataset.name;
const sheet = sheets.find(s => s.name === name);
if (!sheet || !key) return;
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
try {
const sample = [sheet.physical, sheet.backstory, sheet.motivation, sheet.relationships].filter(Boolean).join(' ');
const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : '';
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
const r = await fetch('/api/character-generate-prompts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: sheet.name, book: book || '', sheet,
language, llm_url: target.url, model: target.model, fields: [key],
}),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
if (!d[key]) throw new Error('Empty response — try again');
sheet[key] = d[key];
const textEl = btn.closest('.lcd-prompt-body')?.querySelector('.cs-prompt-text');
if (textEl) textEl.textContent = d[key];
if (typeof csSaveToLibrary === 'function') await csSaveToLibrary(book, sheets);
toast('Prompt generated', 'success');
} catch (err) {
toast('Prompt generation failed: ' + (err.message || err), 'error');
} finally {
btn.disabled = false;
btn.innerHTML = orig;
}
});
});
root.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => { root.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
@ -1051,7 +1484,14 @@ async function csForReader() {
return; return;
} }
const knownRoster = csKnownReaderRoster(); const knownRoster = csKnownReaderRoster();
const sheets = await csGenerate(text, key, knownRoster, { pageHost }); let seedSheets = [];
try {
if (typeof clGetAllByTagOrBook === 'function') {
const existing = await clGetAllByTagOrBook(book);
seedSheets = (existing || []).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim());
}
} catch (_) { seedSheets = []; }
const sheets = await csGenerate(text, key, knownRoster, { pageHost, seedSheets });
if (!sheets) return; if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; } if (!sheets.length) { toast('No characters found', 'error'); return; }
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook; const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
@ -1066,48 +1506,69 @@ async function csForReader() {
} }
// Refresh sheet data for a hand-picked subset of an already-cast book's // Refresh sheet data for a hand-picked subset of an already-cast book's
// characters, leaving everyone else's saved sheet untouched. Extraction is // characters, leaving everyone else's saved sheet untouched. Each picked
// still passage-by-passage over the whole book (any page might mention any // character now gets its own evidence window built from the paragraphs around
// character), so this takes as long as a full recast — it just discards the // every name/alias hit, so the model spends its context on the right places
// results for characters the user didn't pick, rather than skipping work. // instead of rescanning the full book for every selected profile.
async function csForReaderSelective(selectedNames) { async function csForReaderSelective(selectedNames) {
const wanted = new Set((selectedNames || []).map(n => String(n).trim().toLowerCase())); const wanted = new Set((selectedNames || []).map(n => String(n).trim().toLowerCase()));
if (!wanted.size) { toast('No characters selected', 'error'); return; } if (!wanted.size) { toast('No characters selected', 'error'); return; }
const text = csReaderText(); const text = csReaderText();
const book = readerState.title || 'Untitled book'; const book = readerState.title || 'Untitled book';
// A targeted recast only needs the passages that actually mention the if (typeof navTo === 'function') navTo('s-reader');
// picked character(s) — reading the whole book to update one person was if (typeof showReaderView === 'function') showReaderView('chars');
// pure waste. Match name + known aliases, keep one chunk of context on const pageHost = csReaderPageHost();
// either side of each hit (for pronoun/"he/she" resolution), and only fall let records = [];
// back to the full text if nothing matched at all (e.g. a name typo). try { records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; } catch (_) { records = []; }
let scanText = text; const picked = [];
try { const seen = new Set();
const records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; const matchesWanted = (rec) => {
const needles = []; const tokens = csNameTokens({
for (const rec of records || []) { name: rec?.name || '',
if (!wanted.has(String(rec.name || '').trim().toLowerCase())) continue; aliases: rec?.sheet?.aliases || '',
needles.push(rec.name); first_name: rec?.sheet?.first_name || '',
String(rec.sheet?.aliases || '').split(',').forEach(a => { if (a.trim()) needles.push(a.trim()); }); last_name: rec?.sheet?.last_name || '',
full_name: rec?.sheet?.full_name || '',
});
for (const t of tokens) {
const lower = String(t || '').toLowerCase();
for (const w of wanted) {
if (lower === w || lower.includes(w) || w.includes(lower)) return true;
}
} }
selectedNames.forEach(n => { if (!needles.some(x => x.toLowerCase() === String(n).toLowerCase())) needles.push(n); }); return false;
const pattern = needles.filter(Boolean).map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); };
if (pattern.length) { (records || []).forEach(rec => {
const re = new RegExp('\\b(?:' + pattern.join('|') + ')\\b', 'i'); if (!rec || !rec.name || !matchesWanted(rec)) return;
const chunks = splitTextIntoChunks(text, CS_CHUNK_CHARS); const key = String(rec.name || '').trim().toLowerCase();
const matched = new Set(); if (seen.has(key)) return;
chunks.forEach((c, i) => { if (re.test(c)) { matched.add(i - 1); matched.add(i); matched.add(i + 1); } }); seen.add(key);
const idxs = [...matched].filter(i => i >= 0 && i < chunks.length).sort((a, b) => a - b); picked.push(rec);
if (idxs.length) scanText = idxs.map(i => chunks[i]).join('\n\n'); });
} (selectedNames || []).forEach(name => {
} catch (_) { /* fall through to full-text scan */ } const key = String(name || '').trim().toLowerCase();
// Only the picked names go in as the target roster — passing the FULL if (!key || seen.has(key)) return;
// roster here (like csForReader does) made the progress dialog show every const fallback = { name: String(name || '').trim(), sheet: csBlankSheet(String(name || '').trim()) };
// character as "queued" even though just one or two were selected, which seen.add(key);
// read as "it's recasting everyone" even though the save step at the end picked.push(fallback);
// was already correctly filtered down to the picks. });
const sheets = await csGenerate(scanText, null, selectedNames); picked.sort((a, b) => (b?.sheet?.line_count || 0) - (a?.sheet?.line_count || 0) || String(a?.name || '').localeCompare(String(b?.name || '')));
if (!sheets) return; if (!picked.length) { toast('No matching cast characters found', 'error'); return; }
const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase())); const finalMap = new Map();
for (const rec of picked) {
const targetName = String(rec.name || '').trim();
if (!targetName) continue;
const needles = csRecordNeedles(rec);
const scanText = csEvidenceWindowText(csReaderParagraphBlocks(), needles, 2, 2) || text;
const seedSheet = csSeedSheet(rec);
const sheets = await csGenerate(scanText, null, [targetName], { pageHost, seedSheets: [seedSheet] });
if (!sheets) return;
const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase()));
if (!filtered.length) continue;
if (rec?.sheet?.line_count != null) filtered.forEach(s => { s.line_count = rec.sheet.line_count; });
csMerge(finalMap, filtered);
}
const filtered = [...finalMap.values()];
if (!filtered.length) { toast('None of the selected characters turned up in this pass — try again or pick different ones', 'error'); return; } if (!filtered.length) { toast('None of the selected characters turned up in this pass — try again or pick different ones', 'error'); return; }
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook; const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
if (ab?.roster) { if (ab?.roster) {
@ -1116,8 +1577,8 @@ async function csForReaderSelective(selectedNames) {
csAttachLineCounts(filtered, counts); csAttachLineCounts(filtered, counts);
} }
await csSaveToLibrary(book, filtered); await csSaveToLibrary(book, filtered);
csGoToLibrary(); if (pageHost) await csShow(filtered, book, text, book, pageHost);
toast(filtered.length + ' character' + (filtered.length !== 1 ? 's' : '') + ' recast — Library → Cast', 'success'); toast(filtered.length + ' character' + (filtered.length !== 1 ? 's' : '') + ' defined', 'success');
} }
async function csForRehearser() { async function csForRehearser() {
@ -1133,7 +1594,14 @@ async function csForRehearser() {
if (pageHost) await csShow(_cs.cache[key], title, text, book, pageHost); if (pageHost) await csShow(_cs.cache[key], title, text, book, pageHost);
return; return;
} }
const sheets = await csGenerate(text, key, null, { pageHost }); let seedSheets = [];
try {
if (typeof clGetAllByTagOrBook === 'function') {
const existing = await clGetAllByTagOrBook(book);
seedSheets = (existing || []).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim());
}
} catch (_) { seedSheets = []; }
const sheets = await csGenerate(text, key, null, { pageHost, seedSheets });
if (!sheets) return; if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; } if (!sheets.length) { toast('No characters found', 'error'); return; }
if (window.rehState?.lines?.length) { if (window.rehState?.lines?.length) {

View File

@ -658,7 +658,9 @@ async function _charDetailPage(rec, allChars) {
_lcdFieldEdit('Voller Name', sh.full_name, 'full_name', sourcesByField['full_name']), _lcdFieldEdit('Voller Name', sh.full_name, 'full_name', sourcesByField['full_name']),
_lcdFieldEdit('Vorname', sh.first_name, 'first_name', sourcesByField['first_name']), _lcdFieldEdit('Vorname', sh.first_name, 'first_name', sourcesByField['first_name']),
_lcdFieldEdit('Nachname', sh.last_name, 'last_name', sourcesByField['last_name']), _lcdFieldEdit('Nachname', sh.last_name, 'last_name', sourcesByField['last_name']),
_lcdFieldEdit('Titel / Rolle', sh.title, 'title', sourcesByField['title']), _lcdFieldEdit('Geschlecht', sh.gender, 'gender', sourcesByField['gender']),
_lcdFieldEdit('Titel', sh.title, 'title', sourcesByField['title']),
_lcdFieldEdit('Beruf / Rolle', sh.profession, 'profession', sourcesByField['profession']),
_lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases', sourcesByField['aliases']), _lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases', sourcesByField['aliases']),
]) ])
+ _lcdSection('mdi-account-outline', 'Erscheinung', [ + _lcdSection('mdi-account-outline', 'Erscheinung', [
@ -932,6 +934,15 @@ function _charDetailModal(rec, allChars) {
+ '</div>' + '</div>'
+ alignHtml + alignHtml
+ '<div class="lcd-sections">' + '<div class="lcd-sections">'
+ _lcdSection('mdi-card-account-details-outline', 'Identität', [
_lcdField('Voller Name', sh.full_name, true),
_lcdField('Vorname', sh.first_name, true),
_lcdField('Nachname', sh.last_name, true),
_lcdField('Geschlecht', sh.gender, true),
_lcdField('Titel', sh.title, true),
_lcdField('Beruf / Rolle', sh.profession, true),
_lcdField('Auch bekannt als', sh.aliases, true),
])
+ _lcdSection('mdi-account-outline', 'Erscheinung', [ + _lcdSection('mdi-account-outline', 'Erscheinung', [
_lcdField('Körperlich', sh.physical, true), _lcdField('Körperlich', sh.physical, true),
_lcdField('Kleidung & Aussehen', sh.clothing, true), _lcdField('Kleidung & Aussehen', sh.clothing, true),

View File

@ -5457,7 +5457,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
background: var(--surface); color: var(--text); cursor: pointer; flex-shrink: 0; background: var(--surface); color: var(--text); cursor: pointer; flex-shrink: 0;
} }
.ab-cv-side.is-collapsed .ab-cv-side-tools { display: none; } .ab-cv-side.is-collapsed .ab-cv-side-tools { display: none; }
.ab-cv-chars { display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; padding-right: 2px; } .ab-cv-chars { display: block; min-height: 0; overflow-y: auto; padding-right: 2px; }
.ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; } .ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; }
/* Collapsed sidebar: shrink to just the avatar dots, hide name/count/labels. /* Collapsed sidebar: shrink to just the avatar dots, hide name/count/labels.
The grid column itself is narrowed via .ab-cv-body.side-collapsed. */ The grid column itself is narrowed via .ab-cv-body.side-collapsed. */
@ -5520,20 +5520,43 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.ab-skel-char-row:nth-child(5) * { animation-delay:.4s; } .ab-skel-char-row:nth-child(5) * { animation-delay:.4s; }
/* Character list rows in the casting side panel */ /* Character list rows in the casting side panel */
.ab-char-item { .ab-char-item {
display:flex; align-items:center; gap:8px; padding:5px 6px; position: relative;
border-radius:6px; cursor:pointer; transition:background .12s; display:flex; align-items:center; gap:8px; padding:7px 10px;
border-radius:8px; cursor:pointer; transition:background .12s, border-color .12s;
overflow: hidden;
min-height: 44px;
} }
.ab-char-item:hover, .ab-char-item.is-active { background:var(--surface); } .ab-char-item:hover, .ab-char-item.is-active { background:var(--surface); }
.ab-char-item.is-active { border-left:2px solid var(--accent); padding-left:4px; } .ab-char-item.is-active { border-left:2px solid var(--accent); padding-left:6px; }
.ab-char-item > :not(.cs-progress-fillmeta) { position: relative; z-index: 1; }
.ab-char-dot { .ab-char-dot {
width:26px; height:26px; border-radius:50%; flex-shrink:0; width:28px; height:28px; border-radius:50%; flex-shrink:0;
display:flex; align-items:center; justify-content:center; display:flex; align-items:center; justify-content:center;
font-size:11px; font-weight:700; color:#fff; font-size:12px; font-weight:800; color:#fff;
box-shadow: 0 2px 6px rgba(0,0,0,.14), inset 0 0 0 1px rgba(255,255,255,.28);
border: 1px solid rgba(255,255,255,.28);
} }
.ab-char-dot-img { overflow: hidden; background: var(--panel); } .ab-char-dot-img { overflow: hidden; background: var(--panel); }
.ab-char-dot-img img { width: 100%; height: 100%; object-fit: cover; } .ab-char-dot-img img { width: 100%; height: 100%; object-fit: cover; }
.ab-char-name { flex:1; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width: 0; } .ab-char-name { flex:1; font-size:16px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width: 0; font-weight: 400; }
.ab-char-count { font-size:11px; color:var(--subtext); font-weight:700; flex-shrink:0; } .ab-char-count {
margin-left: auto;
flex-shrink: 0;
min-width: 24px;
display: inline-flex;
align-items: center;
justify-content: flex-end;
color: var(--subtext);
font-weight: 700;
line-height: 1;
font-size: 13px;
}
.ab-char-count-divider {
width: 16px;
height: 1px;
background: color-mix(in srgb, var(--subtext) 55%, transparent);
border-radius: 999px;
}
.ab-char-alias-btn { .ab-char-alias-btn {
flex-shrink: 0; width: 20px; height: 20px; border-radius: 4px; border: none; flex-shrink: 0; width: 20px; height: 20px; border-radius: 4px; border: none;
background: none; color: var(--subtext); cursor: pointer; display: none; background: none; color: var(--subtext); cursor: pointer; display: none;
@ -5544,6 +5567,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Sidebar's own collapse state hides the count/name anyway; keep the alias /* Sidebar's own collapse state hides the count/name anyway; keep the alias
button out of the way rather than fighting for the 44px column. */ button out of the way rather than fighting for the 44px column. */
.ab-cv-side.is-collapsed .ab-char-alias-btn { display: none !important; } .ab-cv-side.is-collapsed .ab-char-alias-btn { display: none !important; }
.ab-cv-chars .ab-char-item + .ab-char-item { margin-top: 4px; }
.ab-alias-popup { .ab-alias-popup {
position: fixed; z-index: 2002; width: 260px; position: fixed; z-index: 2002; width: 260px;
@ -5595,6 +5619,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
transition:background .15s; transition:background .15s;
} }
.ab-cd-edit-btn:hover { background:rgba(255,255,255,.28); } .ab-cd-edit-btn:hover { background:rgba(255,255,255,.28); }
.ab-cd-header-actions { position:absolute; top:10px; right:10px; display:flex; align-items:center; gap:8px; z-index:3; }
.ab-cd-refine-btn {
background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3);
border-radius:6px; cursor:pointer; color:#fff; padding:5px 10px;
font-size:12px; font-weight:700; display:flex; align-items:center; gap:6px; line-height:1;
transition:background .15s;
}
.ab-cd-refine-btn:hover { background:rgba(255,255,255,.28); }
/* editable fields: show hint border in edit mode */ /* editable fields: show hint border in edit mode */
.ab-cd-editing .ab-editable { .ab-cd-editing .ab-editable {
border-bottom:1px dashed var(--accent); border-bottom:1px dashed var(--accent);
@ -5725,6 +5757,30 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
#s-reader #reader-charsheets-panel .cs-progress-box-big { #s-reader #reader-charsheets-panel .cs-progress-box-big {
min-height: min(880px, 90vh); min-height: min(880px, 90vh);
height: auto; height: auto;
box-shadow: none;
}
.cs-progress-box-big {
box-shadow: none;
}
.cs-progress-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 14px;
margin-top: 12px;
align-items: stretch;
}
.cs-progress-layout.side-collapsed {
grid-template-columns: minmax(0, 1fr) 44px;
}
.cs-progress-left {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
} }
.cs-titlebar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .cs-titlebar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.cs-list { overflow-y: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(auto-fill, minmax(430px, 1fr)); gap: 14px; align-content: start; } .cs-list { overflow-y: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(auto-fill, minmax(430px, 1fr)); gap: 14px; align-content: start; }
@ -5740,7 +5796,38 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.cs-head-btns .btn-secondary { font-size: 10px; padding: 2px 7px; } .cs-head-btns .btn-secondary { font-size: 10px; padding: 2px 7px; }
.cs-img-btn:hover { border-color: #a78bfa; color: #a78bfa; } .cs-img-btn:hover { border-color: #a78bfa; color: #a78bfa; }
.cs-voice-btn:hover { border-color: var(--accent); color: var(--accent); } .cs-voice-btn:hover { border-color: var(--accent); color: var(--accent); }
.cs-aliases { font-size: 12px; color: var(--subtext); font-style: italic; margin: 2px 0 6px; } .cs-meta-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 6px;
}
.cs-meta-tag {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
border-radius: 999px;
padding: 3px 8px;
background: color-mix(in srgb, var(--panel) 78%, transparent);
color: var(--subtext);
font-size: 11px;
line-height: 1;
}
.cs-meta-tag-label {
text-transform: uppercase;
letter-spacing: .04em;
font-size: 9px;
font-weight: 800;
color: var(--accent);
}
.cs-meta-tag-value {
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
/* Character sheet card layout with avatar */ /* Character sheet card layout with avatar */
.cs-card-top { display: flex; gap: 14px; margin-bottom: 10px; } .cs-card-top { display: flex; gap: 14px; margin-bottom: 10px; }
@ -5806,7 +5893,70 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.cs-progress-prompt-actions { display: flex; justify-content: flex-end; margin-top: 8px; gap: 8px; align-items: center; } .cs-progress-prompt-actions { display: flex; justify-content: flex-end; margin-top: 8px; gap: 8px; align-items: center; }
.cs-progress-prompt-actions select { max-width: 200px; outline: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; font-size: 12px; background: var(--surface); color: var(--text); } .cs-progress-prompt-actions select { max-width: 200px; outline: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; font-size: 12px; background: var(--surface); color: var(--text); }
.cs-progress-prompt-actions input { width: 140px; padding: 4px 8px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--text); outline: none; } .cs-progress-prompt-actions input { width: 140px; padding: 4px 8px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--text); outline: none; }
.cs-progress-body { flex: 1; min-height: 0; display: flex; gap: 14px; margin-top: 12px; } .cs-progress-output {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 120px;
max-height: 42vh;
overflow: auto;
resize: none;
position: relative;
padding: 10px 12px 12px;
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--panel) 72%, var(--surface));
box-shadow: 0 1px 0 rgba(255,255,255,.7) inset;
}
.cs-progress-output-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext);
}
.cs-progress-output-title { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
.cs-progress-output .cs-progress-live-status { margin-left: 4px; }
.cs-progress-mini {
height: 5px;
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--panel) 70%, var(--surface));
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
opacity: .9;
transition: opacity .15s ease;
}
.cs-progress-mini span {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--accent) 55%, transparent) 50%, transparent 100%);
animation: skeletonSweep 1.15s ease-in-out infinite;
}
.cs-progress-output.is-working .cs-progress-mini { opacity: 1; }
.cs-progress-output.is-collapsed {
min-height: 44px;
max-height: 44px;
resize: none;
overflow: hidden;
padding-bottom: 8px;
}
.cs-progress-output.is-collapsed .cs-progress-body { display: none; }
.cs-progress-output.is-collapsed .cs-progress-mini { display: none; }
.cs-progress-output.is-collapsed .cs-progress-output-head { margin-top: 0; }
.cs-progress-resize-handle {
position: absolute; right: 8px; bottom: 8px;
width: 22px; height: 22px; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--border) 92%, transparent);
background: color-mix(in srgb, var(--surface) 88%, var(--panel));
color: var(--subtext);
display: flex; align-items: center; justify-content: center;
cursor: ns-resize; box-shadow: 0 2px 6px rgba(0,0,0,.08);
z-index: 3;
}
.cs-progress-output.is-collapsed .cs-progress-resize-handle { bottom: 6px; right: 6px; opacity: .95; }
.cs-progress-resize-handle:hover { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.cs-progress-resize-handle .mdi { font-size: 14px; }
.cs-output-resizing, .cs-output-resizing * { cursor: ns-resize !important; }
.cs-progress-body { flex: 1; min-height: 0; display: flex; gap: 14px; margin-top: 0; }
.cs-progress-main { flex: 1; min-width: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } .cs-progress-main { flex: 1; min-width: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.cs-progress-col { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 6px; } .cs-progress-col { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
.cs-progress-col-label { .cs-progress-col-label {
@ -5816,12 +5966,82 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.cs-progress-passage-pre, .cs-progress-live-pre { .cs-progress-passage-pre, .cs-progress-live-pre {
flex: 1; min-height: 0; margin: 0; padding: 10px 12px; overflow-y: auto; white-space: pre-wrap; flex: 1; min-height: 0; margin: 0; padding: 10px 12px; overflow-y: auto; white-space: pre-wrap;
overflow-wrap: anywhere; font-size: 12px; line-height: 1.55; color: var(--text); overflow-wrap: anywhere; font-size: 12px; line-height: 1.55; color: var(--text);
background: var(--bg); border: 1px solid var(--border); border-radius: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
}
.cs-progress-live-pre { background: rgba(37,99,235,.045); }
.cs-progress-side {
width: 100%;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
height: 100%;
}
.cs-progress-side.is-collapsed {
padding: 8px 4px;
}
.cs-progress-layout.side-collapsed .cs-progress-side {
width: 44px;
}
.cs-progress-side.is-collapsed .ab-cv-side-title,
.cs-progress-side.is-collapsed .ab-cv-side-tools,
.cs-progress-side.is-collapsed .ab-cv-side-done {
display: none;
}
.cs-progress-side.is-collapsed .cs-progress-chars {
padding-right: 0;
} }
.cs-progress-live-pre { background: rgba(37,99,235,.05); }
.cs-progress-side { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.cs-progress-side-title { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); flex-shrink: 0; } .cs-progress-side-title { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); flex-shrink: 0; }
.cs-progress-chars { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding: 2px 2px 4px; } .cs-progress-preview {
margin: 10px 0 0;
padding: 0;
min-height: 220px;
flex: 1;
overflow: auto;
}
.cs-progress-left.is-output-collapsed .cs-progress-preview { min-height: 520px; }
.cs-progress-output.is-collapsed .cs-progress-output-title { opacity: .85; }
.cs-progress-output.is-working .cs-progress-output-title { color: var(--text); }
.cs-progress-preview .cs-card {
box-shadow: none;
border-radius: 12px;
margin: 0;
background: color-mix(in srgb, var(--panel) 92%, var(--surface));
}
.cs-progress-preview .cs-head-btns,
.cs-progress-preview .cs-sources {
display: none;
}
.cs-progress-preview .cs-avatar {
pointer-events: none;
cursor: default;
}
.cs-progress-preview-empty {
font-size: 12px;
color: var(--subtext);
font-style: italic;
border: 1px dashed var(--border);
border-radius: 10px;
padding: 12px;
background: color-mix(in srgb, var(--panel) 80%, transparent);
}
.cs-progress-chars { flex: 1; min-height: 0; overflow-y: auto; display: block; padding: 2px 2px 4px; }
.cs-progress-chars .ab-char-item + .ab-char-item { margin-top: 6px; }
.cs-progress-chars .ab-char-count {
min-width: 38px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
font-size: 14px;
}
.cs-progress-chars .ab-char-count-top,
.cs-progress-chars .ab-char-count-bottom {
font-size: 16px;
line-height: 1;
}
.cs-progress-empty { font-size: 12px; color: var(--subtext); font-style: italic; text-align: center; padding: 20px 0; } .cs-progress-empty { font-size: 12px; color: var(--subtext); font-style: italic; text-align: center; padding: 20px 0; }
.cs-progress-chip { .cs-progress-chip {
display: grid; grid-template-columns: 26px minmax(0, 1fr) 44px auto; align-items: center; gap: 8px; display: grid; grid-template-columns: 26px minmax(0, 1fr) 44px auto; align-items: center; gap: 8px;
@ -5843,8 +6063,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.cs-progress-meta { min-width: 0; display: flex; flex-direction: column; gap: 1px; } .cs-progress-meta { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.cs-progress-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; } .cs-progress-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
.cs-progress-alias { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--subtext); font-size: 10px; } .cs-progress-alias { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--subtext); font-size: 10px; }
.cs-progress-fillmeta { height: 5px; border-radius: 999px; background: var(--panel); overflow: hidden; border: 1px solid var(--border); } .cs-progress-fillmeta {
.cs-progress-fillmeta span { display: block; height: 100%; background: var(--accent); } position: absolute;
inset: 0;
border-radius: inherit;
background: color-mix(in srgb, var(--panel) 90%, transparent);
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
pointer-events: none;
}
.cs-progress-fillmeta span {
display: block;
height: 100%;
background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 16%, transparent), color-mix(in srgb, var(--accent) 46%, transparent));
border-radius: inherit;
min-width: 2px;
}
.cs-progress-badge { font-size: 9px; text-transform: uppercase; color: var(--subtext); font-weight: 800; } .cs-progress-badge { font-size: 9px; text-transform: uppercase; color: var(--subtext); font-weight: 800; }
.cs-fields { margin: 0; display: grid; grid-template-columns: 1fr; gap: 6px; } .cs-fields { margin: 0; display: grid; grid-template-columns: 1fr; gap: 6px; }
.cs-row { display: grid; grid-template-columns: 136px 1fr; gap: 10px; font-size: 12.5px; padding: 6px 8px; border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); border-radius: 6px; background: color-mix(in srgb, var(--surface) 72%, transparent); } .cs-row { display: grid; grid-template-columns: 136px 1fr; gap: 10px; font-size: 12.5px; padding: 6px 8px; border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); border-radius: 6px; background: color-mix(in srgb, var(--surface) 72%, transparent); }
@ -5856,6 +6090,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent);
font-size: 9px; font-weight: 900; cursor: pointer; line-height: 1; padding: 0; font-size: 9px; font-weight: 900; cursor: pointer; line-height: 1; padding: 0;
} }
.cs-row dd .cs-source-mark { margin-left: 6px; vertical-align: middle; }
.cs-sources { margin-top: 10px; padding-top: 9px; border-top: 1px dashed var(--border); font-size: 11px; color: var(--subtext); } .cs-sources { margin-top: 10px; padding-top: 9px; border-top: 1px dashed var(--border); font-size: 11px; color: var(--subtext); }
.cs-sources-title { display:flex; align-items:center; gap:5px; font-weight:800; text-transform:uppercase; letter-spacing:.04em; margin-bottom:6px; } .cs-sources-title { display:flex; align-items:center; gap:5px; font-weight:800; text-transform:uppercase; letter-spacing:.04em; margin-bottom:6px; }
.cs-sources b { color: var(--text); } .cs-sources b { color: var(--text); }
@ -5868,9 +6103,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
} }
.cs-source-link:hover { border-color:var(--accent); color:var(--accent); } .cs-source-link:hover { border-color:var(--accent); color:var(--accent); }
.cs-source-link span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:150px; } .cs-source-link span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:150px; }
.cs-source-link .cs-source-quote { max-width: 260px; color: var(--text); }
.cs-source-link .cs-source-hint { color: var(--subtext); }
.cs-source-link em { font-style:normal; opacity:.8; } .cs-source-link em { font-style:normal; opacity:.8; }
.cs-row-voice dt .mdi { color: var(--accent); } .cs-row-voice dt .mdi { color: var(--accent); }
.cs-row-voice dd { font-style: italic; } .cs-row-voice dd { font-style: italic; }
.cs-prompts { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
.cs-prompts .lcd-prompt-box summary { border-radius: 8px; }
.cs-prompts .lcd-prompt-empty { opacity: .6; font-size: 11px; }
/* ── Character library section ─────────────────────────────────────────── */ /* ── Character library section ─────────────────────────────────────────── */
.cl-toolbar { margin-bottom: 14px; } .cl-toolbar { margin-bottom: 14px; }