tts-voice-creator-clone-and.../static/js/character-sheets.js
mARTin-B78 ea50267c30 Add unified Studio casting workflow and fix voice/casting pipeline bugs
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 02:03:56 +02:00

2413 lines
131 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Character sheets ────────────────────────────────────────────────────────
//
// Actor-facing, RPG-style character sheets extracted by the user's LLM from a
// document (Read Aloud) or a script (Script Rehearser). Each claim cites a
// source (page + short quote + category hint). Shared by both sections via one overlay.
const CS_CHUNK_CHARS = 4000;
const _cs = { running: false, cancel: false, cache: {} };
// Mirrors the hardcoded extraction-instructions/schema body in
// routes/conversation.py's _charsheets_prepare(). Editable via the "Prompt"
// panel (same pattern as audiobook casting) — the language/cast-target-mode
// prefixes are still applied server-side regardless, since they depend on
// 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.
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:
- 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. 'the Executioner, Bloodfang, Marcus the Executioner'). Leave empty when uncertain.
- first_name, last_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').
- age_estimate: estimated age or age range, if inferable
- race_species: species, race, or kind (human, elf, ork, vampire, etc.) if relevant
- languages: spoken languages / dialects / tongues, comma-separated if multiple
- nationality_background: homeland, culture, origin, or social background if known
- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.)
- 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.
- physical: height, weight, build, hair, eyes, skin, posture, gait, distinguishing features, physical disabilities, fantasy-specific extras, and any other bodily appearance details. Use metric units when size/weight is known.
- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look
- alignment: strict moral code + the one line they will never cross
- moral_alignment_score: integer 0100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous
- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'
- arc_note: one sentence explaining the arc or moral position visible so far
- attribute_high / attribute_low: highest and lowest natural attribute (Charisma, Intelligence, Wisdom, Agility…)
- skills: what they are demonstrably good at in the story
- capabilities: combat, magic, social, technical, or other demonstrated abilities
- backstory: origin, formative background and history revealed in the text
- relationships: key allies, family, rivals and enemies — name them and how they relate
- motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition)
- fears: their deepest fears, phobias or dread
- mannerisms: habitual gestures, tics, body language, habits and quirks
- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.
- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)
- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.
- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.
- inventory: 1-3 defining items/props/clothing (array of short strings)
- secret: dark secret or fatal flaw
- conflict_style: fight, flight, or manipulate — how they act when cornered
- win_condition: the specific event that would make them feel they have won
- reputation: how other characters or society see them
- religious_beliefs: faith, religion, worship, or lack of belief if the text shows it
- notes: brief catch-all notes for useful details that do not fit elsewhere
- tier: 'main' or 'supporting'
- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.
IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Marcus', 'the Executioner', and 'Bloodfang' 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 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.
Respond with STRICT JSON only:
{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","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":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
/no-think`;
// Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the
// app's loaded settings. Crucially, NEVER fall back to the hardcoded
// localhost:11434 default — if settings aren't loaded yet, return '' so the
// SERVER uses its own configured llm_url instead of a dead localhost address
// (which caused "Connection refused" character-sheet failures).
function csLlmUrl() {
const explicit = $('reh-llm-url')?.value.trim();
if (explicit) return explicit;
const fromSettings = (typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_url) ? _appSettings.llm_url : '';
if (fromSettings && !/localhost:11434|127\.0\.0\.1:11434/.test(fromSettings)) return fromSettings;
return '';
}
function csLlmModel() { return $('reh-llm-model')?.value || ''; }
function csLang() { return $('reh-design-lang')?.value || ''; }
function csReaderPageHost() { return $('reader-charsheets-panel'); }
// Build page-annotated text from the current reader scope ([p.N] at page changes).
function csReaderText() {
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
let out = '', lastPage = -1;
for (const i of readerScopeIndices()) {
const u = readerState.sentences[i];
const pg = u.words?.[0]?.page;
if (readerState.mode === 'pdf' && pg != null && pg !== lastPage) { out += `\n[p.${pg + 1}] `; lastPage = pg; }
out += u.text + ' ';
}
return out.trim();
}
// Build text from the rehearser script (narration + "SPEAKER: line"), page markers at page breaks.
function csRehearserText() {
if (!window.rehState || !(rehState.lines || []).length) return '';
let out = '', page = 1, started = false;
for (const l of rehState.lines) {
if (l.type === 'pagebreak') { page++; out += `\n[p.${page}] `; continue; }
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(l.text || '') : (l.text || '')).trim();
if (!t) continue;
if (!started) { out += '[p.1] '; started = true; }
out += (l.type === 'dialog' && l.speaker ? l.speaker + ': ' : '') + t + '\n';
}
return out.trim();
}
// ── Generation (chunked + merged by character) ───────────────────────────────
const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'profession', 'age_estimate', 'race_species', 'languages', 'nationality_background', 'social_class', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
'attribute_high', 'attribute_low', 'skills', 'capabilities',
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'communication_style', 'voice_pattern',
'secret', 'conflict_style', 'win_condition', 'reputation', 'religious_beliefs', 'notes', 'voice_design_prompt', 'image_prompt',
'silly_tavern_prompt', 'concept_art_prompt'];
const CS_DETAIL_FIELDS = CS_SCALAR_FIELDS.filter(f => !['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(f));
// Used to decide whether two generated sheets are the SAME character and
// should be merged (csFindMergeKey) or whether an incoming sheet matches an
// expected roster name (csSheetMatchesRoster). Must stay limited to genuine
// name/alias fields — it used to also include title/profession/age/race/
// languages/nationality/social class, all purely descriptive text that
// different characters routinely share verbatim (e.g. several villains here
// share the exact title "Verweser der von den Orks eroberten
// Reichsprovinzen"). Treating a shared job title as proof of shared identity
// merged unrelated characters' sheets into one, corrupting both.
const CS_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name'];
const CS_ALIAS_MAX_TOKENS = 12;
const CS_ALIAS_MAX_CHARS = 500;
const CS_SOURCE_FIELDS = {
physical: ['physical', 'appearance', 'body', 'look'],
clothing: ['clothing', 'appearance', 'armour', 'armor', 'item'],
age_estimate: ['age', 'older', 'young', 'teen', 'adult'],
race_species: ['race', 'species', 'kind', 'orc', 'elf', 'human', 'vampire'],
languages: ['language', 'languages', 'tongue', 'dialect', 'speech'],
nationality_background: ['nationality', 'background', 'origin', 'homeland', 'culture'],
social_class: ['class', 'social', 'status', 'rank'],
relationships: ['relationship', 'ally', 'rival', 'enemy', 'family'],
motivation: ['motivation', 'intention', 'goal', 'desire'],
fears: ['fear', 'dread'],
mannerisms: ['mannerism', 'habit', 'gesture', 'voice'],
communication_style: ['communication', 'communicate', 'demeanor', 'style'],
voice_pattern: ['voice', 'speech', 'dialogue'],
backstory: ['backstory', 'origin', 'history'],
alignment: ['alignment', 'ethos', 'morality'],
profession: ['profession', 'occupation', 'job', 'role'],
skills: ['skill', 'capability', 'ability'],
capabilities: ['capability', 'ability', 'combat', 'magic'],
reputation: ['reputation', 'known as', 'status', 'perceived'],
religious_beliefs: ['religion', 'faith', 'belief', 'priest', 'god', 'gods', 'temple'],
notes: ['note', 'misc', 'miscellaneous'],
secret: ['secret', 'flaw'],
conflict_style: ['conflict', 'fight', 'flight', 'manipulate'],
win_condition: ['win', 'goal'],
};
function csExistingSummary(map) {
if (!map.size) return '';
// 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.
const KEY_FIELDS = ['age_estimate', 'race_species', 'languages', 'nationality_background', 'social_class', 'profession', 'physical', 'clothing', 'communication_style', 'voice_pattern', 'backstory', 'motivation', 'relationships', 'reputation', 'religious_beliefs', 'notes'];
return [...map.values()].slice(0, 40).map(s => {
const missing = KEY_FIELDS.filter(f => !(s[f] || '').trim());
const suffix = missing.length < KEY_FIELDS.length ? ` | needs: ${missing.join(', ')}` : ' | complete';
const aliases = [s.title, s.profession, s.aliases].filter(Boolean).join(', ');
return `- ${s.name}${aliases ? ` aka ${aliases}` : ''}${s.archetype ? ` (${s.archetype})` : ''}${suffix}`;
}).join('\n');
}
function _csStr(v) {
if (v == null) return '';
if (typeof v === 'string') return v;
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
return JSON.stringify(v);
}
function csSplitIdentityTokens(v, opts = {}) {
const raw = _csStr(v);
const parts = raw
.split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i)
.map(s => s.trim())
.filter(Boolean)
.filter(s => s.length <= 80 && !/^needs?:/i.test(s) && !/^complete$/i.test(s));
if (opts.aliases && (raw.length > CS_ALIAS_MAX_CHARS || parts.length > CS_ALIAS_MAX_TOKENS)) return [];
return parts.slice(0, opts.aliases ? CS_ALIAS_MAX_TOKENS : undefined);
}
function csCleanRoster(names) {
const out = [];
const seen = new Set();
(names || []).forEach(n => {
const name = _csStr(n).trim();
const key = name.toLowerCase();
if (!name || seen.has(key) || key === 'narrator' || /^unknown|unbekannt$/i.test(name)) return;
seen.add(key);
out.push(name);
});
return out;
}
function csRosterKey(v) {
return String(v || '').trim().toLowerCase();
}
function csKnownReaderRoster() {
const names = [];
const add = (name) => {
name = _csStr(name).trim();
const key = name.toLowerCase();
if (!name || key === 'narrator' || /^unknown|unbekannt$/i.test(name)) return;
if (!names.some(n => n.toLowerCase() === key)) names.push(name);
};
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
if (ab) {
(ab.roster || []).forEach(add);
[ab.segments, ab.liveSegments].forEach(segs => {
(segs || []).forEach(s => {
if (s?.type === 'dialogue') add(s.speaker);
});
});
}
document.querySelectorAll('#ab-cv-chars .ab-char-item[data-name], #audiobook-roster option[value]').forEach(el => {
add(el.dataset.name || el.value || el.textContent);
});
return csCleanRoster(names);
}
function csBlankSheet(name) {
return {
name, aliases: '', first_name: '', last_name: '', full_name: '', title: '', profession: '',
age_estimate: '', race_species: '', languages: '', nationality_background: '', social_class: '',
archetype: '', gender: '', physical: '', clothing: '', alignment: '', arc_note: '',
attribute_high: '', attribute_low: '', skills: '', capabilities: '',
backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', communication_style: '', voice_pattern: '',
secret: '', conflict_style: '', win_condition: '', reputation: '', religious_beliefs: '', notes: '', voice_design_prompt: '', image_prompt: '',
inventory: [], sources: [], moral_alignment_score: 50, arc_direction: 'neutral', tier: 'supporting',
};
}
function csSeedSheet(raw) {
const name = _csStr(raw?.name).trim();
const seed = csBlankSheet(name);
if (!name) return seed;
// A character with an already-saved profile picture (rec.image, set at the
// top level of the library record, not inside .sheet) used to always show
// the plain letter placeholder in this live view — nothing carried the
// image forward from the seed into the in-memory sheet used here, even
// though the picture was sitting right there in the saved record.
if (raw?.image) seed._image = raw.image;
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) {
const out = new Set();
for (const f of CS_IDENTITY_FIELDS) {
const raw = f === 'name' ? sheet?.name : sheet?.[f];
csSplitIdentityTokens(raw, { aliases: f === 'aliases' }).forEach(s => out.add(s.toLowerCase()));
}
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?.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) {
const incoming = csNameTokens(sheet);
if (!incoming.size) return (sheet.name || '').trim().toLowerCase();
for (const [key, existing] of map.entries()) {
const existingNames = csNameTokens(existing);
for (const n of incoming) if (existingNames.has(n)) return key;
}
return (sheet.name || '').trim().toLowerCase();
}
function csMergeAliases(existing, incoming) {
const names = new Map();
const add = (v) => csSplitIdentityTokens(v, { aliases: true }).forEach(s => names.set(s.toLowerCase(), s));
add(existing.aliases);
add(incoming.aliases);
if (incoming.name && incoming.name !== existing.name) add(incoming.name);
return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', ');
}
function csMerge(map, sheets) {
const changed = [];
for (const s of sheets) {
const name = (s.name || '').trim(); if (!name) continue;
s.aliases = csMergeAliases({ name, aliases: '' }, s);
const key = csFindMergeKey(map, s);
if (!map.has(key)) {
const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] };
CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); });
map.set(key, copy);
changed.push({ key, name: copy.name, status: 'new' });
continue;
}
const e = map.get(key);
const before = JSON.stringify(e);
const oldAliases = e.aliases;
CS_SCALAR_FIELDS.forEach(f => { const sv = _csStr(s[f]); if (sv.length > (e[f] || '').length) e[f] = sv; });
e.aliases = csMergeAliases({ ...e, aliases: oldAliases }, s);
if (s.tier === 'main') e.tier = 'main';
if (s.moral_alignment_score != null) {
e.moral_alignment_score = e.moral_alignment_score != null
? Math.round((e.moral_alignment_score + s.moral_alignment_score) / 2)
: s.moral_alignment_score;
}
if (s.arc_direction && s.arc_direction !== 'neutral') e.arc_direction = s.arc_direction;
if (s.gender && !e.gender) e.gender = s.gender;
(s.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
(s.sources || []).forEach(src => {
if (src && src.quote && e.sources.length < 12 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
});
if (JSON.stringify(e) !== before) changed.push({ key, name: e.name, status: 'updated' });
}
return changed;
}
// Both the casting roster (Identify Characters) and this generation pass can
// independently pre-seed the SAME person under two different names — e.g. a
// pre-reveal descriptive alias like "der Fremde" alongside an already-
// established dialogue-attribution name like "Zerwas" (its own roster entry
// with its own line count). The incremental per-passage merge above only ever
// checks an INCOMING sheet's identity tokens against the map, never checks
// two ALREADY-SEEDED map entries against each other — so once the LLM proves
// (via its own aliases field) that two pre-seeded entries are one person,
// nothing reconciles them, and both persist as permanently separate library
// records. Runs once at the end of a full generation pass: whenever an
// entry's aliases name another entry still in the map, folds the two
// together — keeping whichever name was ALREADY an established record before
// this run (settled by Identify Characters or an earlier cast pass) as
// canonical, with the other name demoted to an alias — rather than letting
// the character-sheet step invent a competing identity.
function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated) {
let changed = true;
while (changed) {
changed = false;
for (const [key, s] of map) {
const aliasKeys = csSplitIdentityTokens(s.aliases, { aliases: true }).map(a => a.toLowerCase());
const dupKey = aliasKeys.find(a => a !== key && map.has(a));
if (!dupKey) continue;
const other = map.get(dupKey);
// Established (pre-existing library record) beats a same-run stub. If
// both (or neither) qualify, prefer whichever is more detailed; if
// that's also tied (e.g. both still blank stubs from the same seeding
// pass), fall back to whichever record is OLDER — the one Identify
// Characters / the original cast settled on first.
const sEst = establishedKeys.has(key), oEst = establishedKeys.has(dupKey);
let keepKey = key, dropKey = dupKey;
if (oEst && !sEst) { keepKey = dupKey; dropKey = key; }
else if (sEst === oEst) {
const filled = (sh) => CS_DETAIL_FIELDS.filter(f => _csStr(sh[f]).trim()).length;
const sFilled = filled(s), oFilled = filled(other);
if (oFilled > sFilled) { keepKey = dupKey; dropKey = key; }
else if (oFilled === sFilled && establishedCreated) {
const sT = establishedCreated.get(key), oT = establishedCreated.get(dupKey);
if (sT != null && oT != null && oT < sT) { keepKey = dupKey; dropKey = key; }
}
}
const keep = map.get(keepKey), drop = map.get(dropKey);
const oldAliases = keep.aliases;
CS_SCALAR_FIELDS.forEach(f => { const dv = _csStr(drop[f]); if (dv.length > (keep[f] || '').length) keep[f] = dv; });
keep.aliases = csMergeAliases({ ...keep, aliases: oldAliases }, drop);
if (drop.tier === 'main') keep.tier = 'main';
if (drop.line_count != null) keep.line_count = Math.max(keep.line_count || 0, drop.line_count);
(drop.inventory || []).forEach(it => { if (it && !keep.inventory.includes(it) && keep.inventory.length < 3) keep.inventory.push(it); });
(drop.sources || []).forEach(src => {
if (src && src.quote && keep.sources.length < 12 && !keep.sources.some(x => x.quote === src.quote)) keep.sources.push(src);
});
map.set(keepKey, keep);
map.delete(dropKey);
changed = true;
break; // map mutated — restart the scan
}
}
}
function csProgressSnapshot(map, changed = []) {
const changedByKey = new Map(changed.map(x => [x.key, x.status]));
return [...map.entries()].map(([key, s]) => {
const filled = CS_DETAIL_FIELDS.filter(f => _csStr(s[f]).trim()).length;
return {
key,
name: s.name || key,
alias: [s.title, s.profession].filter(Boolean).join(' · '),
status: changedByKey.get(key) || '',
filled,
main: s.tier === 'main',
gender: s.gender || '',
lineCount: Number(s.line_count) || 0,
image: s._image || '',
};
}).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
// audiobookAttributeStream — idle-timeout-based abort (re-armed on every
// received chunk) rather than one fixed overall timeout, since generation
// can legitimately take a while but a truly stalled stream should still
// give up. Falls back to the blocking endpoint on any failure.
//
// Confirmed live this endpoint never delivers a single visible delta for
// character-sheet generation, however long you wait (reproduced identically
// at 60s/300s/560s) — some models only flush content once reasoning is
// done, so there's nothing to gain from a long idle-timeout here; it only
// delays falling through to the (working) blocking endpoint below. Kept
// short so that fallback happens quickly rather than as a rare last resort.
async function csGenerateStream(body, onDelta, outerSignal, idleTimeoutMs = 60000) {
const ctl = new AbortController();
const onAbort = () => ctl.abort();
if (outerSignal) {
if (outerSignal.aborted) ctl.abort();
else outerSignal.addEventListener('abort', onAbort, { once: true });
}
let idleTimer = null;
const armIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs); };
try {
armIdle();
const r = await fetch('/api/character-sheets/stream', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ctl.signal, body: JSON.stringify(body),
});
if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '', result = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
armIdle();
buf += dec.decode(value, { stream: true });
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const line = buf.slice(0, at).trim();
buf = buf.slice(at + 2);
if (!line.startsWith('data:')) continue;
let d;
try { d = JSON.parse(line.slice(5)); } catch (_) { continue; }
if (d.t && onDelta) onDelta(d.t);
if (d.error) throw new Error(d.error);
if (d.done) result = d.result || null;
}
}
if (!result) throw new Error('stream ended without result');
return result;
} finally {
clearTimeout(idleTimer);
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
}
}
async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
if (_cs.running) return null;
if (!text) { toast('Nothing to analyse', 'error'); return null; }
const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text];
_cs.running = true; _cs.cancel = false;
// Everything below used to run outside any try/finally that resets
// _cs.running — a thrown error anywhere in setup (progress overlay
// creation, DOM lookups, etc.) left _cs.running stuck true forever, so
// every future recast attempt (in this tab, indefinitely) silently
// returned null at the guard above with no error shown at all. Wrapping
// the whole body guarantees the flag always clears and any crash is at
// least visible instead of a silent no-op.
try {
const prog = csProgress(chunks.length, opts.pageHost || null);
const llm_url = csLlmUrl(), model = csLlmModel();
const selectedLanguage = csLang();
let language = selectedLanguage;
if (typeof detectLang === 'function') {
const detected = detectLang(text);
if (detected && (!language || /^auto$/i.test(language) || (language.toLowerCase() === 'english' && detected.toLowerCase() !== 'english'))) {
language = detected;
const sel = $('reh-design-lang');
if (sel) { const o = [...sel.options].find(x => x.value.toLowerCase() === detected.toLowerCase() || x.textContent.toLowerCase() === detected.toLowerCase()); if (o) sel.value = o.value; }
}
}
const map = new Map();
// Line counts come from the already-cast dialogue, known before generation
// even starts — attach them at seed time so "Lines" shows immediately in
// the live preview instead of staying blank until the whole run finishes.
const lineCounts = opts.lineCounts instanceof Map ? opts.lineCounts : new Map();
const seedSheets = Array.isArray(opts.seedSheets) ? opts.seedSheets : [];
// Names that already had a saved library record BEFORE this run — the
// identity Identify Characters (casting) and any earlier cast pass already
// settled on. Tracked so a later identity-merge discovery (the LLM proving
// two roster names are the same person, e.g. a pre-reveal alias like "der
// Fremde" for an already-established "Zerwas") always folds the OTHER name
// in as an alias on this established record, instead of leaving two
// permanently separate character-library entries for one person.
const establishedKeys = new Set();
// When two records are BOTH already established (this book's very first
// recast can seed several roster names as separate saved stubs before any
// of them have detail), fall back to whichever was created earliest —
// that's the one Identify Characters / the original cast settled on first.
const establishedCreated = new Map();
seedSheets.forEach(seedRaw => {
const seed = csSeedSheet(seedRaw);
const name = String(seed.name || '').trim();
if (!name) return;
const lc = lineCounts.get(name.toLowerCase());
if (lc != null) seed.line_count = lc;
map.set(name.toLowerCase(), seed);
establishedKeys.add(name.toLowerCase());
const created = seedRaw?.created ? new Date(seedRaw.created).getTime() : NaN;
if (!isNaN(created)) establishedCreated.set(name.toLowerCase(), created);
});
// 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.
const roster = csCleanRoster(initialRoster);
const targetMode = roster.length > 0;
const knownTotal = targetMode ? roster.length : null;
roster.forEach(name => {
const key = name.toLowerCase();
if (!map.has(key)) {
const blank = csBlankSheet(name);
const lc = lineCounts.get(key);
if (lc != null) blank.line_count = lc;
map.set(key, blank);
}
});
try {
for (let i = 0; i < chunks.length; i++) {
if (_cs.cancel) break;
const detailed = [...map.values()].filter(s => CS_DETAIL_FIELDS.some(f => (s[f] || '').trim())).length;
const charLabel = knownTotal
? `${knownTotal} cast characters queued · ${detailed} profiles with details`
: `${map.size} characters found`;
prog.update(i, `Passage ${i + 1} / ${chunks.length}`, charLabel, csProgressSnapshot(map), [...map.values()]);
prog.startPassage(chunks[i]);
// Rate-limit (429) errors used to permanently drop that passage's
// data — the loop just logged it and moved on, so a burst of
// requests hitting the provider's per-minute cap could silently
// leave large chunks of the book with no character detail. Retry
// with backoff specifically for rate-limit errors (a handful of
// attempts, growing delay) before giving up; other error types
// still fail fast as before, since retrying a genuinely broken
// request just wastes time.
const maxAttempts = 4;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
// Read fresh each passage so an edit made mid-run (via the Prompt
// panel) takes effect starting from the next request, not just on
// the next full run.
const character_sheets_prompt = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT;
const body = { text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model, character_sheets_prompt };
let data = null;
let sawDelta = false;
try {
data = await csGenerateStream(body, (delta) => { sawDelta = true; prog.thinking(delta); });
// The streaming request can succeed (no exception, valid final
// result) while still never having delivered a single visible
// delta — some models only send control/reasoning frames over SSE
// and buffer the whole answer into the final "done" frame. That
// used to leave the "Waiting for streamed JSON output…" text stuck
// forever even though passages kept completing normally, because
// noStream() was only ever called from the catch/fallback branch.
if (!sawDelta) prog.noStream();
} catch (streamErr) {
if (!sawDelta) prog.noStream();
// Deliberately NO client-side timeout on this request. Confirmed
// live (three attempts at 60s/300s/560s) that aborting it does
// NOT save any time — the server's upstream call sits in a
// blocking socket read a dropped connection can't interrupt (see
// routes/conversation.py's `_watchdog_close`), so an abort only
// orphans a thread that keeps holding the shared LLM lock until
// its own ~600s watchdog force-closes it, and the very next
// attempt then queues up behind THAT thread instead of making
// progress — a self-inflicted stall, not a recovery. This
// request reliably completes in a couple of minutes when left
// alone (confirmed live), same as the old Read Aloud page's
// still-working "arrives at once when each passage finishes"
// behavior for the same non-streaming model.
const r = await fetch('/api/character-sheets', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
let detail = `HTTP ${r.status}`;
try { const e = await r.json(); detail = e.detail || e.error || detail; } catch (_) {}
throw new Error(detail);
}
const raw = await r.text();
try { data = JSON.parse(raw); }
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
}
const incomingSheets = targetMode ? (data.sheets || []).filter(s => csSheetMatchesRoster(s, roster)) : (data.sheets || []);
// Confirmed live: a passage featuring two characters together can
// make the model write one character's real name into ANOTHER
// character's first_name/last_name field (e.g. a "Darrag" sheet
// with first_name/last_name both set to "Riedmar" — a completely
// different, separately-cast character, not a revealed alias).
// Riedmar's own record was blank at the time, so this wasn't a
// merge bug — the model itself confused whose name belonged where.
// A character's real name being IDENTICAL to a DIFFERENT known
// character's name is a strong contamination signal, so that
// field gets dropped rather than trusted.
incomingSheets.forEach(s => {
const ownName = String(s.name || '').trim().toLowerCase();
['first_name', 'last_name', 'full_name'].forEach(f => {
const v = String(s[f] || '').trim().toLowerCase();
if (v && v !== ownName && roster.some(n => n.toLowerCase() === v)) {
console.warn(`[character sheets] dropped ${f}="${s[f]}" from "${s.name}" — matches a different known character`);
s[f] = '';
}
});
});
const changed = csMerge(map, incomingSheets);
if (changed.length) prog.focus(changed[0].name);
if (!targetMode) {
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); });
(data.sheets || []).forEach(s => {
csNameTokens(s).forEach(n => {
const display = [s.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);
});
});
}
const newDetailed = [...map.values()].filter(s => CS_DETAIL_FIELDS.some(f => (s[f] || '').trim())).length;
const newCharLabel = knownTotal
? `${knownTotal} cast characters queued · ${newDetailed} profiles with details`
: `${map.size} characters found`;
prog.update(i + 1, `Passage ${i + 1} / ${chunks.length}`, newCharLabel, csProgressSnapshot(map, changed), [...map.values()]);
break;
} catch (e) {
const msg = e.message || String(e);
const isRateLimit = /429|too many requests|rate.?limit/i.test(msg);
// "Failed to fetch" (a dropped connection, a momentary proxy/DNS
// blip, the backend restarting) used to fail this passage
// permanently on the very first hit — no retry at all, unlike
// rate limits — silently leaving that passage's characters with
// whatever detail they already had. These are exactly the kind of
// transient error retrying helps with, so they now get the same
// backoff-and-retry treatment (just a shorter wait, since a
// network blip usually clears faster than a rate-limit window).
const isNetworkError = /failed to fetch|network\s*error|load failed|timed?\s*out|timeout|\b50[234]\b|econnreset|econnrefused|socket hang up|connection (refused|reset|closed)/i.test(msg);
if ((isRateLimit || isNetworkError) && attempt < maxAttempts) {
const waitMs = attempt * (isRateLimit ? 8000 : 4000);
toast(`Passage ${i + 1}${isRateLimit ? 'rate-limited' : 'network error'} — retrying in ${waitMs / 1000}s (attempt ${attempt + 1}/${maxAttempts})…`, 'info');
await new Promise(res => setTimeout(res, waitMs));
continue;
}
console.error('Character sheets passage', i + 1, 'failed:', e);
toast('Passage ' + (i + 1) + ' failed: ' + msg, 'error');
break;
}
}
}
} finally { prog.done(); }
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated);
const sheets = [...map.values()];
if (cacheKey) _cs.cache[cacheKey] = sheets;
return sheets;
} catch (e) {
console.error('[character sheets] generation crashed:', e);
toast('Character sheet generation crashed: ' + (e && e.message ? e.message : String(e)), 'error');
return null;
} finally {
_cs.running = false;
}
}
function csProgress(total, hostEl = null) {
const usingHost = !!hostEl;
let root = usingHost ? hostEl : document.getElementById('cs-progress');
if (!root) {
if (usingHost) return null;
root = document.createElement('div');
root.id = 'cs-progress';
root.className = 'audiobook-overlay';
document.body.appendChild(root);
}
const needsShell = !root.querySelector('#cs-progress-fill');
if (needsShell) {
if (usingHost) root.innerHTML = '';
// Big two-pane layout, mirroring the audiobook casting live view: the
// 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)
// so the user can watch each sheet actually being built.
root.innerHTML = `<div class="audiobook-box cs-progress-box cs-progress-box-big cs-page-shell">
<div class="cs-progress-head">
<div class="audiobook-title">
<span class="mdi mdi-account-details-outline"></span> Character sheets
<button class="ab-castpanel-btn" id="cs-prompt-btn" title="Edit character-sheet extraction prompt" style="margin-left:auto; display:flex; align-items:center; gap:4px; padding:4px 8px; font-weight:600; font-size:12px; width:auto; height:auto; min-height:28px;"><span class="mdi mdi-text-box-edit-outline"></span> Prompt <span class="mdi mdi-chevron-down" id="cs-prompt-chevron"></span></button>
</div>
<div class="audiobook-msg" id="cs-progress-msg">Analysing…</div>
<div class="cs-progress-prompt" id="cs-prompt-panel" hidden>
<textarea id="cs-prompt-text" rows="8" placeholder="Character-sheet extraction instructions..."></textarea>
<div class="cs-progress-prompt-actions">
<select id="cs-prompt-lib" class="btn-secondary btn-sm" aria-label="Load a saved prompt">
<option value="">— Load saved prompt —</option>
</select>
<button class="btn-secondary btn-sm" id="cs-prompt-del" title="Delete selected prompt" style="display:none; color:var(--error);"><span class="mdi mdi-delete-outline"></span></button>
<input type="text" id="cs-prompt-name" placeholder="Preset name...">
<button class="btn-primary btn-sm" id="cs-prompt-save" title="Save current prompt as a preset"><span class="mdi mdi-content-save"></span> Save preset</button>
</div>
</div>
</div>
<div class="cs-progress-layout" id="cs-progress-layout">
<div class="cs-progress-left">
<div class="cs-progress-preview" id="cs-progress-preview">
<div class="cs-progress-preview-empty">Select a character to preview the sheet as it fills.</div>
</div>
<div class="cs-progress-output" id="cs-progress-output">
<button class="cs-progress-resize-handle" id="cs-progress-output-resize" type="button" title="Drag to resize passage and live output" aria-label="Resize passage and live output"></button>
<div class="cs-progress-output-head">
<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-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="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...">
<select class="ab-cv-side-sort" id="cs-progress-sort" title="Sort">
<option value="progress">Progress</option>
<option value="alpha">AZ</option>
<option value="lines">Lines</option>
<option value="gender">Gender</option>
</select>
</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 class="cs-progress-footer">
<div class="reader-synth-track cs-progress-footer-track"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
<div class="audiobook-actions cs-progress-footer-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
</div>
</div>`;
root.querySelector('#cs-progress-cancel')?.addEventListener('click', () => { _cs.cancel = true; });
// Prompt panel — mirrors the audiobook casting "Prompt" button: toggle
// visibility, load/save/delete named presets (localStorage), and persist
// the active text as the settings default so csGenerate() picks it up.
const promptBtn = root.querySelector('#cs-prompt-btn');
const promptPanel = root.querySelector('#cs-prompt-panel');
const promptText = root.querySelector('#cs-prompt-text');
if (promptText) promptText.value = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT;
promptBtn?.addEventListener('click', () => {
if (!promptPanel) return;
promptPanel.hidden = !promptPanel.hidden;
const chevron = root.querySelector('#cs-prompt-chevron');
if (chevron) chevron.className = promptPanel.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up';
});
let savedCsPrompts = [];
try { savedCsPrompts = JSON.parse(localStorage.getItem('ttsvc_cs_prompts') || '[]'); } catch (_) { savedCsPrompts = []; }
const libSelect = root.querySelector('#cs-prompt-lib');
const delBtn = root.querySelector('#cs-prompt-del');
const nameInput = root.querySelector('#cs-prompt-name');
const renderCsPromptLib = (selectedIdx = -1) => {
if (!libSelect || !delBtn) return;
libSelect.innerHTML = '<option value="">— Load saved prompt —</option>' +
savedCsPrompts.map((p, i) => `<option value="${i}">${escHtml(p.name)}</option>`).join('');
if (selectedIdx >= 0) { libSelect.value = selectedIdx; delBtn.style.display = 'inline-flex'; }
else { libSelect.value = ''; delBtn.style.display = 'none'; }
};
renderCsPromptLib();
libSelect?.addEventListener('change', () => {
const idx = parseInt(libSelect.value, 10);
if (!isNaN(idx) && savedCsPrompts[idx]) {
if (promptText) promptText.value = savedCsPrompts[idx].prompt;
if (nameInput) nameInput.value = savedCsPrompts[idx].name;
delBtn.style.display = 'inline-flex';
} else {
if (nameInput) nameInput.value = '';
delBtn.style.display = 'none';
}
});
delBtn?.addEventListener('click', () => {
const idx = parseInt(libSelect.value, 10);
if (isNaN(idx)) return;
if (confirm('Delete this saved prompt preset?')) {
savedCsPrompts.splice(idx, 1);
localStorage.setItem('ttsvc_cs_prompts', JSON.stringify(savedCsPrompts));
renderCsPromptLib();
toast('Prompt deleted', 'success');
}
});
root.querySelector('#cs-prompt-save')?.addEventListener('click', async () => {
const val = promptText?.value.trim() || '';
if (!val) { toast('Prompt is empty', 'error'); return; }
const name = nameInput?.value.trim() || 'Custom Prompt ' + (savedCsPrompts.length + 1);
let targetIdx = parseInt(libSelect?.value || '', 10);
if (!isNaN(targetIdx) && savedCsPrompts[targetIdx] && savedCsPrompts[targetIdx].name === name) {
savedCsPrompts[targetIdx].prompt = val;
} else {
savedCsPrompts.push({ name, prompt: val });
targetIdx = savedCsPrompts.length - 1;
}
localStorage.setItem('ttsvc_cs_prompts', JSON.stringify(savedCsPrompts));
renderCsPromptLib(targetIdx);
toast('Prompt preset saved', 'success');
});
// Persist to settings on every edit (debounced) so csGenerate()'s
// per-passage prompt lookup — and the next time this panel opens —
// always reflect the latest text, including mid-run edits.
let promptSaveTimer = null;
promptText?.addEventListener('input', () => {
clearTimeout(promptSaveTimer);
promptSaveTimer = setTimeout(() => {
const val = promptText.value;
if (typeof _appSettings !== 'undefined') _appSettings.character_sheets_prompt = val;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ character_sheets_prompt: val }) }).catch(() => {});
}, 500);
});
}
if (!usingHost) root.style.display = 'flex';
const fill = root.querySelector('#cs-progress-fill');
const msg = root.querySelector('#cs-progress-msg');
const chars = root.querySelector('#cs-progress-chars');
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 livePre = root.querySelector('#cs-progress-live-pre');
const liveStatus = root.querySelector('#cs-progress-live-status');
let currentRoster = [];
let currentSheets = [];
let selectedName = '';
let searchQuery = '';
// Default to most-lines-first — main characters (the ones worth seeing
// fleshed out first) naturally have the most dialogue, so this surfaces
// them at the top as generation starts instead of whatever's alphabetically
// first or happens to have just been touched.
let rosterSort = (() => { try { return localStorage.getItem('ttsvc_cs_side_sort') || 'lines'; } catch (_) { return 'lines'; } })();
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);
}
const collapsedH = 138;
outputPanel.style.height = `${collapsedH}px`;
outputPanel.style.maxHeight = `${collapsedH}px`;
} 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);
// The handle sits on the panel's TOP edge (moved there from the old
// bottom-right corner button) — dragging up should grow the panel,
// dragging down should shrink it, the opposite sign from when the
// handle tracked the bottom edge.
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, { voice: sheet.voice, line_count: sheet.line_count });
preview.querySelector('.cs-card')?.classList.add('cs-progress-preview-card');
preview.querySelectorAll('.cs-head-btns, .cs-header-actions, .cs-sources').forEach(el => el.remove());
preview.querySelectorAll('.cs-avatar').forEach(el => { el.style.pointerEvents = 'none'; el.style.cursor = 'default'; });
// Voice-picking and the highlight-color swatch both need a persisted
// Library record (voice/color live there, not on the plain sheet) — this
// preview can show sheets that haven't been saved yet (generation still
// running), so create/reuse the record on first interaction rather than
// eagerly for every character as it streams in.
const book = (typeof readerState !== 'undefined' && readerState.title) || '';
const ensureRecord = async () => (typeof clUpsert === 'function') ? await clUpsert(book, sheet) : null;
preview.querySelector('.lcd-pick-voice')?.addEventListener('click', async () => {
const rec = await ensureRecord();
if (rec && typeof _openVoicePicker === 'function') _openVoicePicker(preview.querySelector('.lcd-voice-top'), rec, () => renderPreview());
});
preview.querySelector('.lcd-auto-voice')?.addEventListener('click', async () => {
const rec = await ensureRecord();
if (rec && typeof _autoAssignVoice === 'function') { await _autoAssignVoice(rec); renderPreview(); }
});
preview.querySelector('.lcd-online-voice')?.addEventListener('click', async () => {
const rec = await ensureRecord();
if (rec && typeof _charSearchOnline === 'function') _charSearchOnline(rec);
});
preview.querySelector('.lcd-gen-voice')?.addEventListener('click', async () => {
const rec = await ensureRecord();
if (rec && typeof _charDesignVoice === 'function') _charDesignVoice(rec);
});
preview.querySelector('.cs-header-color input')?.addEventListener('input', async (e) => {
const rec = await ensureRecord();
if (!rec) return;
sheet.color = e.target.value;
rec.color = e.target.value;
if (typeof clPut === 'function') await clPut(rec);
});
// Each field's little numbered mark links back to the exact sentence the
// LLM drew that value from — showing it here (instead of only in a hover
// tooltip) makes it easy to spot a misread/hallucinated field at a glance.
const sourceBox = preview.querySelector('.cs-source-preview');
preview.querySelectorAll('.cs-source-mark:not(.cs-source-mark-sample)').forEach(btn => {
btn.addEventListener('click', () => {
if (!sourceBox) return;
preview.querySelectorAll('.cs-source-mark.is-active').forEach(b => b.classList.remove('is-active'));
btn.classList.add('is-active');
const quote = btn.dataset.quote || '';
const hint = btn.dataset.lineHint || '';
const page = btn.dataset.page || '';
sourceBox.innerHTML = `<div class="cs-source-preview-head"><span class="mdi mdi-book-open-page-variant-outline"></span> ${hint ? escHtml(hint) : 'Source sentence'}${page ? ` <em>· p.${escHtml(page)}</em>` : ''}</div>
<div class="cs-source-preview-quote">${quote ? escHtml(quote) : '<span style="opacity:.6">No exact sentence was saved for this field.</span>'}</div>`;
sourceBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
});
};
const rosterSorters = {
// Default: unsaved/just-changed entries first, then most-filled, then
// name — keeps whatever the model is actively working on near the top.
progress: (a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name),
alpha: (a, b) => a.name.localeCompare(b.name),
lines: (a, b) => (b.lineCount || 0) - (a.lineCount || 0) || a.name.localeCompare(b.name),
gender: (a, b) => (a.gender || '￿').localeCompare(b.gender || '￿') || a.name.localeCompare(b.name),
};
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);
}).slice().sort(rosterSorters[rosterSort] || rosterSorters.progress);
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>
${c.image
? `<span class="ab-char-dot ab-char-dot-img"><img src="${escHtml(c.image)}" alt=""></span>`
: `<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-meta" title="${escHtml(String(c.lineCount || 0))} lines in the book · ${escHtml(String(c.filled || 0))} profile fields filled">
<span class="ab-char-lines">${c.lineCount ? escHtml(String(c.lineCount)) + ' lines' : '—'}</span>
<span class="ab-char-meta-sep" aria-hidden="true">|</span>
<span class="ab-char-count">${escHtml(String(c.filled || 0))}/${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();
});
const sortSel = root.querySelector('#cs-progress-sort');
if (sortSel) {
sortSel.value = rosterSort;
sortSel.addEventListener('change', () => {
rosterSort = sortSel.value;
try { localStorage.setItem('ttsvc_cs_side_sort', rosterSort); } catch (_) {}
renderChars();
});
}
renderPreview();
return {
// Called once per passage before its request starts, so both panes are
// reset and honestly labelled before we know whether this model streams
// real content or the request falls back to the blocking path.
startPassage(text) {
if (passagePre) passagePre.textContent = text || '';
if (livePre) livePre.textContent = 'Waiting for streamed JSON output…';
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
// by field is itself the "watch it fill out the sheet" experience here,
// there's no separate reasoning channel worth hiding it behind.
thinking(delta) {
if (!livePre || !delta) return;
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.scrollTop = livePre.scrollHeight;
},
noStream() {
if (liveStatus) { liveStatus.textContent = 'no live output for this model'; liveStatus.className = 'cs-progress-live-status'; }
// Otherwise this stays on "Waiting for streamed JSON output…" forever
// for models that never emit deltas, even though passages are still
// completing normally in the background — reads as a hung/frozen UI.
if (livePre && livePre.textContent === 'Waiting for streamed JSON output…') {
livePre.textContent = 'This model doesn\'t stream incremental output — the full result arrives at once when each passage finishes.';
}
},
focus(name) {
if (name) {
selectedName = keyFor(name);
renderPreview();
}
},
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() {
outputPanel?.classList.remove('is-working');
if (!usingHost) root.style.display = 'none';
},
};
}
// ── Alignment bar ─────────────────────────────────────────────────────────────
function csAlignmentBar(score, arcDirection, arcNote) {
if (score == null) return '';
const pct = Math.max(0, Math.min(100, score));
const arcMap = {
'stable-good': { arrow: '→', label: 'Stable Good', color: '#66bb6a' },
'stable-bad': { arrow: '→', label: 'Stable Evil', color: '#aaa' },
'neutral': { arrow: '→', label: 'Neutral', color: '#aaa' },
'good-to-bad': { arrow: '↘', label: 'Descends toward evil', color: '#ff7043' },
'bad-to-good': { arrow: '↗', label: 'Redeems toward good', color: '#66bb6a' },
'complex': { arrow: '↕', label: 'Complex arc', color: '#ab47bc' },
};
const arc = arcMap[arcDirection] || arcMap['neutral'];
const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Neutral/Ambiguous';
const showArrow = arcDirection && arcDirection !== 'neutral';
return `<div class="cs-alignment-wrap">
<div class="cs-alignment-labels"><span>Evil</span><span>Good</span></div>
<div class="cs-alignment-gradient" title="${label} (${pct}/100)${arcNote ? ' — ' + arcNote : ''}">
<div class="cs-alignment-dot" style="left:${pct}%">
${showArrow ? `<span class="cs-alignment-arc-arrow" style="color:${arc.color}" title="${escHtml(arc.label)}">${arc.arrow}</span>` : ''}
</div>
</div>
${arcNote ? `<div class="cs-arc-note">${escHtml(arc.label)} · <em>${escHtml(arcNote)}</em></div>` : `<div class="cs-arc-note" style="color:${arc.color}">${escHtml(arc.label)}</div>`}
</div>`;
}
// ── Image & Voice prompt generators ──────────────────────────────────────────
// Fallback only — the LLM-generated image_prompt (routes/conversation.py
// character_generate_prompts) is preferred and asks for the same turnaround-
// sheet format; this covers the case where that hasn't been generated yet.
function csBuildImagePrompt(s) {
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
const parts = [];
if (s.archetype) parts.push(s.archetype);
if (s.physical) parts.push(s.physical);
if (s.clothing) parts.push(s.clothing);
if (s.alignment) parts.push(s.alignment);
const pct = s.moral_alignment_score ?? 50;
parts.push(pct >= 70 ? 'benevolent expression' : pct <= 30 ? 'dark and menacing presence' : 'ambiguous expression');
if (s.arc_direction === 'bad-to-good') parts.push('redemptive aura');
if (s.arc_direction === 'good-to-bad') parts.push('ominous aura, turning to darkness');
return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. `
+ `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — `
+ `do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" `
+ `or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual `
+ `choice should fit the world implied by the description, not the real one, unless the description itself is `
+ `explicitly modern/contemporary. `
+ `Include a full-body front view as the anchor, a turnaround panel (side and back views), an expression sheet `
+ `with 3-5 headshots matching their personality, a color palette swatch for hair/eyes/outfit, and labeled callouts `
+ `for signature props or clothing details. Clean production concept-art layout, plain neutral background, `
+ `original character not based on any copyrighted character.`;
}
function csBuildVoicePrompt(s) {
if (_csStr(s.voice_design_prompt).trim()) return _csStr(s.voice_design_prompt).trim();
const parts = [];
if (s.voice_pattern) parts.push(s.voice_pattern);
if (s.mannerisms) parts.push(s.mannerisms);
if (s.archetype) parts.push(`archetype: ${s.archetype}`);
const pct = s.moral_alignment_score ?? 50;
if (pct >= 70) parts.push('warm, trustworthy tone');
else if (pct <= 30) parts.push('cold, threatening or sinister tone');
else parts.push('neutral, measured tone');
return parts.filter(Boolean).join('. ');
}
// ── Deep analysis modal ───────────────────────────────────────────────────────
async function csDeepAnalysis(sheet, sourceText) {
const llm_url = csLlmUrl(), model = csLlmModel(), language = csLang();
const modal = document.createElement('div');
modal.className = 'audiobook-overlay';
modal.innerHTML = `<div class="audiobook-box cs-deep-box">
<div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-brain"></span> Deep Analysis — ${escHtml(sheet.name)}</span>
<span style="flex:1"></span>
<button class="btn-secondary btn-sm" id="cs-deep-close">Close</button>
</div>
<div class="cs-deep-body" id="cs-deep-body">
<div style="text-align:center;padding:40px;color:var(--subtext)">
<span class="mdi mdi-loading mdi-spin" style="font-size:28px"></span><br>Running deep psychological analysis…
</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.querySelector('#cs-deep-close').addEventListener('click', () => modal.remove());
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
try {
const summary = [sheet.archetype, sheet.alignment, sheet.secret, sheet.win_condition, sheet.conflict_style]
.filter(Boolean).join('; ');
const r = await fetch('/api/character-deep-analysis', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: sheet.name, role: sheet.tier, goal: sheet.win_condition,
summary, text_excerpt: (sourceText || '').slice(0, 8000), language, llm_url, model,
}),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const data = await r.json();
const a = data.analysis || {};
const section = (icon, title, content) => content
? `<div class="cs-deep-section">
<div class="cs-deep-section-head"><span class="mdi ${icon}"></span> ${title}</div>
<div class="cs-deep-section-body">${escHtml(String(content))}</div>
</div>` : '';
modal.querySelector('#cs-deep-body').innerHTML = `
<div class="cs-deep-header">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<span class="cs-name" style="font-size:20px">${escHtml(sheet.name)}</span>
${sheet.archetype ? `<span class="cs-archetype">${escHtml(sheet.archetype)}</span>` : ''}
</div>
${csAlignmentBar(sheet.moral_alignment_score, sheet.arc_direction, sheet.arc_note)}
</div>
${section('mdi-heart-broken-outline', '1 — Core Flaw & Desire', a.core_flaw)}
${section('mdi-run-fast', '2 — Agency & Passivity', a.agency)}
${section('mdi-comment-quote-outline', '3 — Dialogue & Voice', a.dialogue_voice)}
${section('mdi-timeline-outline', '4 — Narrative Arc', a.narrative_arc)}
${section('mdi-infinity', '5 — Paradox & Depth', a.paradox)}
`;
} catch (err) {
modal.querySelector('#cs-deep-body').innerHTML =
`<div style="color:var(--error);padding:20px">Error: ${escHtml(String(err.message))}</div>`;
}
}
// ── Card renderer ─────────────────────────────────────────────────────────────
function csSourcesForField(s, field) {
const keys = (CS_SOURCE_FIELDS[field] || [field]).map(x => x.toLowerCase());
return (s.sources || []).filter(src => {
const hint = _csStr(src?.line_hint).toLowerCase();
return hint && keys.some(k => hint.includes(k));
});
}
function csSourceBadgeHtml(s, field) {
// A field can legitimately be backed by several quotes (e.g. a
// relationship mentioned across multiple passages) — used to only ever
// show the FIRST matching source, silently hiding the rest with no
// indication more existed. Now renders one small numbered mark per
// matching source, each numbered to match its position in the full
// "Sources / Evidence" list below so the two can be cross-referenced.
const all = s.sources || [];
const matches = csSourcesForField(s, field);
if (!matches.length) return '';
return matches.map(src => {
const idx = all.findIndex(x => x === src);
const n = idx >= 0 ? idx + 1 : 1;
return `<button class="cs-source-mark" type="button" data-page="${escHtml(String(src.page || ''))}" data-quote="${escHtml(String(src.quote || ''))}" data-line-hint="${escHtml(String(src.line_hint || ''))}" title="Click to see the source sentence this was drawn from">${n}</button>`;
}).join('');
}
function csField(label, value, sheet, field) {
if (!value) return '';
const badge = sheet && field ? csSourceBadgeHtml(sheet, field) : '';
const key = field ? ` data-sheet-key="${escHtml(field)}"` : '';
// Value text lives in its own span, separate from the source-citation
// badge buttons — the whole <dd> used to be the only option, which would
// have made the badges themselves editable/deletable text once this
// became contenteditable.
return `<div class="cs-row"><dt>${label}</dt><dd><span class="cs-field-editable"${key}>${escHtml(String(value))}</span>${badge ? ` ${badge}` : ''}</dd></div>`;
}
function csIdentityCell(label, value, sheet, field) {
if (!value) return '';
const badge = sheet && field ? csSourceBadgeHtml(sheet, field) : '';
const key = field ? ` data-sheet-key="${escHtml(field)}"` : '';
return `<div class="cs-id-cell"><dt>${label}</dt><dd><span class="cs-field-editable"${key}>${escHtml(String(value))}</span>${badge ? ` ${badge}` : ''}</dd></div>`;
}
function csPromptBox(label, value, sheetKey, emptyNote, extraImage) {
const text = _csStr(value);
const has = !!text.trim();
// Copy/Regenerate only ever acted on the PROMPT TEXT itself — actually
// using it (designing a voice, generating a portrait) meant copying it
// out and pasting it somewhere else by hand. One click now does that
// directly with whatever prompt is already sitting in the box.
const actionBtn = !has ? '' : sheetKey === 'voice_design_prompt'
? `<button type="button" class="btn-primary btn-sm cs-prompt-act" data-act="voice"><span class="mdi mdi-account-voice"></span> Design Voice</button>`
: sheetKey === 'image_prompt'
? `<button type="button" class="btn-primary btn-sm cs-prompt-act" data-act="image"><span class="mdi mdi-image-outline"></span> Generate Profile Image</button>`
: sheetKey === 'concept_art_prompt'
? `<button type="button" class="btn-primary btn-sm cs-prompt-act" data-act="concept_art"><span class="mdi mdi-image-multiple-outline"></span> ${extraImage ? 'Regenerate' : 'Generate'} Concept Art</button>`
: '';
const imageHtml = extraImage
? `<div class="cs-concept-art-preview"><img src="${escHtml(extraImage)}" alt="Concept art"></div>`
: '';
return `<details class="lcd-prompt-box cs-prompt-box">
<summary>${escHtml(label)}${has ? '' : ' <span class="lcd-prompt-empty">— ' + escHtml(emptyNote || 'not generated yet') + '</span>'}</summary>
<div class="lcd-prompt-body">
${imageHtml}
<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>
${actionBtn}
</div>
</div>
</details>`;
}
function csAvatarHtml(s) {
const hue = Math.abs((s.name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0));
if (s._image) {
return `<div class="cs-avatar cs-avatar-img" data-name="${escHtml(s.name)}" title="Click to change profile picture">
<img src="${s._image}" alt="${escHtml(s.name)}">
<div class="cs-avatar-overlay"><span class="mdi mdi-camera"></span></div>
</div>`;
}
return `<div class="cs-avatar cs-avatar-letter" style="background:hsl(${hue},50%,35%)" data-name="${escHtml(s.name)}" title="Click to upload profile picture">
<span>${escHtml((s.name || '?')[0].toUpperCase())}</span>
<div class="cs-avatar-overlay"><span class="mdi mdi-camera"></span></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 });
};
const firstLast = [s.first_name, s.last_name].filter(Boolean).join(' ').trim();
if (firstLast && firstLast !== String(s.name || '').trim()) add('first / last', firstLast);
return tags;
}
function csNameHue(name) {
return Math.abs((name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0));
}
function csHeaderPill(label, value, icon = '') {
const text = String(value || '').trim();
if (!text) return '';
return `<span class="cs-header-pill">${icon ? `<span class="mdi mdi-${escHtml(icon)}"></span>` : ''}<span class="cs-header-pill-label">${escHtml(label)}</span><span class="cs-header-pill-value">${escHtml(text)}</span></span>`;
}
function csProfileHeaderHtml(s, meta = {}, actionsHtml = '') {
const hue = csNameHue(s.name);
const accent2 = `hsl(${(hue + 38) % 360}, 58%, 34%)`;
// Occupation/archetype/aliases used to appear TWICE — once as plain text
// right under the name, again as pills further down. Keep only the pill
// style (the one that reads better at a glance) and put it where the
// plain-text lines used to be, instead of duplicating in a second row.
const primaryPills = [
csHeaderPill('Occupation', s.profession, 'briefcase-outline'),
csHeaderPill('Archetype', s.archetype, 'shape-outline'),
csHeaderPill('Also known as', s.aliases, 'tag-multiple-outline'),
].filter(Boolean).join('');
const chips = [
csHeaderPill('Lines', meta.line_count ?? s.line_count, 'format-list-numbered'),
csHeaderPill('Voice', meta.voice, 'account-voice'),
csHeaderPill('Book', meta.book, 'book-open-page-variant-outline'),
csHeaderPill('Tags', meta.tags, 'tag-outline'),
csHeaderPill('Gender', s.gender, 'human-male-female'),
].filter(Boolean).join('');
const color = typeof clNormalizeColor === 'function' ? clNormalizeColor(s.color, s.name) : `hsl(${hue},58%,42%)`;
return `<div class="lcd-header cs-header" style="--lc1:hsl(${hue},58%,42%);--lc2:${accent2}; position:relative;">
${csAvatarHtml(s)}
<div class="lcd-header-body">
<div class="lcd-name">${escHtml(s.name || 'Unnamed')}</div>
${primaryPills ? `<div class="cs-header-pills cs-header-pills-primary">${primaryPills}</div>` : ''}
<div class="lcd-tier-gender">${s.tier ? `<span class="lcd-tier">${escHtml(String(s.tier).toLowerCase() === 'main' ? 'Hauptcharakter' : 'Nebencharakter')}</span>` : ''}${chips ? `<div class="cs-header-pills">${chips}</div>` : ''}</div>
</div>
<label class="ab-cd-color cs-header-color" title="Change this character's highlight color"><span class="mdi mdi-palette-outline"></span><input type="color" value="${escHtml(color)}" data-sheet-key="color" aria-label="Character highlight color"></label>
<button type="button" class="ab-cd-color cs-edit-toggle" title="Edit character sheet" aria-label="Edit character sheet"><span class="mdi mdi-pencil-outline"></span></button>
${actionsHtml ? `<div class="lcd-header-btns cs-header-actions">${actionsHtml}</div>` : ''}
</div>`;
}
function csDetailActionsHtml(s) {
return `
<button class="btn-secondary btn-sm cs-auto-refine-btn" data-name="${escHtml(s.name)}" title="Refine this character only"><span class="mdi mdi-refresh"></span> Auto refine</button>
<button class="btn-secondary btn-sm cs-edit-btn" data-name="${escHtml(s.name)}" title="Open character editing"><span class="mdi mdi-pencil-outline"></span> Edit</button>
`;
}
// Deliberately minimal — this is a browsing card (click it to open the full
// profile), not the profile itself. Only: full name, occupation, archetype,
// main/side, lines, gender, voice (explicitly "Not assigned" rather than
// omitted), and the good/evil rating. Everything else lives behind a click.
function csOverviewCardHtml(rec) {
const s = rec.sheet || {};
const score = s.moral_alignment_score != null ? Math.max(0, Math.min(100, parseInt(s.moral_alignment_score, 10) || 50)) : 50;
const scoreLabel = score >= 70 ? 'Good' : score <= 30 ? 'Evil' : 'Neutral';
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
const fullName = s.full_name || rec.name || '';
const tierLabel = String(s.tier || '').toLowerCase() === 'main' ? 'Main character' : 'Side character';
const facts = [
['Occupation', s.profession],
['Archetype', s.archetype],
['Gender', s.gender],
['Lines', s.line_count],
['Voice', voiceId || 'Not assigned'],
];
const hue = csNameHue(rec.name);
return `<div class="cs-card cs-overview cs-${s.tier === 'main' ? 'main' : 'supp'}" data-id="${escHtml(rec.id || '')}" data-name="${escHtml(rec.name || '')}" role="button" tabindex="0" title="Open ${escHtml(rec.name || 'character')}'s full profile">
<div class="cs-overview-head" style="--lc1:hsl(${hue},58%,42%);--lc2:hsl(${(hue + 38) % 360},58%,32%)">
${csAvatarHtml(s.name ? s : { ...s, name: rec.name, _image: rec.image })}
<div class="cs-overview-head-body">
<div class="cs-overview-name">${escHtml(fullName)}</div>
<span class="cs-overview-tier">${escHtml(tierLabel)}</span>
</div>
</div>
<div class="cs-overview-meta">
<div class="cs-alignment-wrap cs-overview-alignment">
<div class="cs-alignment-labels"><span>Evil</span><span>${scoreLabel} · ${score}/100</span><span>Good</span></div>
<div class="cs-alignment-gradient"><div class="cs-alignment-dot" style="left:${score}%"></div></div>
</div>
<div class="cs-overview-facts">
${facts.filter(([,v]) => String(v || '').trim()).map(([label, value]) => `<div class="cs-overview-fact"><span>${escHtml(label)}</span><b>${escHtml(String(value))}</b></div>`).join('')}
</div>
</div>
</div>`;
}
// Field/section helpers matching the richer 2-column "lcd-*" layout (same
// classes the audiobook casting Profil panel and the full Library character
// editor use), instead of csCardHtml's old flat <dl> of rows.
function csLcdField(label, value, sheet, field) {
if (!value) return '';
const badge = sheet && field ? csSourceBadgeHtml(sheet, field) : '';
// A compact list row (label left, value right, thin separator) instead of
// the stacked label-above-value block — reads as a scannable list, closer
// to the old flat field list, while still living inside the grouped
// 2-column sections.
if (!label) return `<div class="lcd-row lcd-row-full"><dd>${escHtml(String(value))}${badge ? ` ${badge}` : ''}</dd></div>`;
return `<div class="lcd-row"><dt>${escHtml(label)}</dt><dd>${escHtml(String(value))}${badge ? ` ${badge}` : ''}</dd></div>`;
}
function csLcdSection(icon, label, fieldsHtml, full = false) {
const inner = (fieldsHtml || []).filter(Boolean).join('');
if (!inner) return '';
return `<div class="${full ? 'lcd-section-full' : 'lcd-section'}"><div class="lcd-section-label"><span class="mdi ${icon}"></span> ${escHtml(label)}</div>${inner}</div>`;
}
function csCardHtml(s, meta = {}) {
const inv = (s.inventory || []).filter(Boolean);
const invHtml = inv.length
? `<div class="lcd-field"><div class="lcd-field-label">Signature Items</div><div class="lcd-field-value">${inv.map(escHtml).join(', ')}</div></div>`
: '';
const attrs = (s.attribute_high || s.attribute_low)
? `<div class="lcd-field"><div class="lcd-field-label">Core Attributes</div><div class="lcd-field-value">▲ ${escHtml(s.attribute_high || '—')} &nbsp;·&nbsp; ▼ ${escHtml(s.attribute_low || '—')}</div></div>`
: '';
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null || x.line_hint));
const srcHtml = sources.length
? `<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) =>
`<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 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>`
).join('')
}</div></div>`
: '';
// Voice Design / Image Prompt are filled in live, passage by passage — a
// wall of "not generated yet" placeholders before they've had a chance to
// populate reads as broken, so those two stay hidden until non-empty.
// SillyTavern / Concept Art only ever run as a background pass AFTER the
// whole recast finishes (they need the character's complete profile, not
// a partial mid-scan one) — hiding those the same way looked like they'd
// silently vanished, so they always show, with a note explaining why
// they're empty during an active run instead of the generic message.
const laterNote = _cs.running ? 'generates after casting finishes' : 'not generated yet';
const promptParts = [
csPromptBox('Voice Design Prompt', s.voice_design_prompt, 'voice_design_prompt'),
csPromptBox('Character Image Prompt', s.image_prompt, 'image_prompt'),
].filter((_, i) => [s.voice_design_prompt, s.image_prompt][i]);
promptParts.push(
csPromptBox('SillyTavern Character Prompt', s.silly_tavern_prompt, 'silly_tavern_prompt', laterNote),
csPromptBox('Character Concept Art Prompt', s.concept_art_prompt, 'concept_art_prompt', laterNote, s.concept_art_image)
);
const promptHtml = promptParts.length ? `<div class="cs-prompts">${promptParts.join('')}</div>` : '';
const voiceId = meta.voice || s.voice || '';
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}">
${csProfileHeaderHtml(s, meta, csDetailActionsHtml(s))}
<div class="lcd-body ab-cd-body">
<div class="lcd-voice-top">
<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>
<span class="lcd-voice-label">${voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme</span>'}</span>
<button class="lcd-pick-voice">Auswählen</button>
<button class="lcd-auto-voice">Automatisch</button>
<button class="lcd-online-voice">Online suchen</button>
<button class="lcd-gen-voice">Generieren</button>
</div>
<div class="cs-detail-hero-note">${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}</div>
<div class="lcd-sections">
${(() => {
const sections = [
csLcdSection('mdi-card-account-details-outline', 'Identität', [
csLcdField('Voller Name', s.full_name, s, 'full_name'),
csLcdField('Vorname', s.first_name, s, 'first_name'),
csLcdField('Nachname', s.last_name, s, 'last_name'),
csLcdField('Alter', s.age_estimate, s, 'age_estimate'),
csLcdField('Geschlecht', s.gender, s, 'gender'),
csLcdField('Rasse / Spezies', s.race_species, s, 'race_species'),
csLcdField('Sprachen', s.languages, s, 'languages'),
csLcdField('Titel', s.title, s, 'title'),
csLcdField('Beruf / Rolle', s.profession, s, 'profession'),
csLcdField('Herkunft', s.nationality_background, s, 'nationality_background'),
csLcdField('Sozialer Stand', s.social_class, s, 'social_class'),
csLcdField('Religion', s.religious_beliefs, s, 'religious_beliefs'),
csLcdField('Ruf', s.reputation, s, 'reputation'),
]),
csLcdSection('mdi-account-outline', 'Erscheinung', [
csLcdField('Körperlich', s.physical, s, 'physical'),
csLcdField('Kleidung', s.clothing, s, 'clothing'),
]),
csLcdSection('mdi-drama-masks', 'Persönlichkeit', [
csLcdField('Eigenheiten', s.mannerisms, s, 'mannerisms'),
csLcdField('Kommunikationsstil', s.communication_style, s, 'communication_style'),
csLcdField('Stimme & Sprache', s.voice_pattern, s, 'voice_pattern'),
]),
csLcdSection('mdi-book-open-outline', 'Geschichte', [
csLcdField('Hintergrund', s.backstory, s, 'backstory'),
csLcdField('Motivation', s.motivation, s, 'motivation'),
csLcdField('Ängste', s.fears, s, 'fears'),
]),
csLcdSection('mdi-sword', 'Fähigkeiten', [
csLcdField('Fertigkeiten', s.skills, s, 'skills'),
csLcdField('Besondere Fähigkeiten', s.capabilities, s, 'capabilities'),
attrs,
invHtml,
]),
csLcdSection('mdi-shield-sword-outline', 'Konflikt', [
csLcdField('Geheimnis / Fataler Fehler', s.secret, s, 'secret'),
csLcdField('Konfliktstil', s.conflict_style, s, 'conflict_style'),
csLcdField('Siegbedingung', s.win_condition, s, 'win_condition'),
]),
csLcdSection('mdi-account-group-outline', 'Beziehungen', [csLcdField('', s.relationships, s, 'relationships')], true),
csLcdSection('mdi-note-text-outline', 'Notizen', [csLcdField('', s.notes, s, 'notes')], true),
].filter(Boolean);
// Nothing filled in yet (this character's turn in the passage
// sequence hasn't come up) — a bare empty gap here reads as broken
// ("did my data disappear?"), so say plainly that it's still coming.
return sections.length ? sections.join('') : `<div class="lcd-section-full lcd-empty-note"><span class="mdi mdi-progress-clock"></span> This character's profile hasn't been generated yet — it fills in as the passage containing their scenes is processed.</div>`;
})()}
</div>
</div>
${srcHtml}
${promptHtml}
<div class="cs-source-preview">
<div class="cs-source-preview-empty">Click a <span class="cs-source-mark cs-source-mark-sample">i</span> mark next to a field above to see the exact sentence it was drawn from.</div>
</div>
</div>`;
}
function csToMarkdown(sheets) {
const sec = t => `\n## ${t}\n`;
let md = '# Character Sheets\n';
for (const tier of ['main', 'supp']) {
const list = sheets.filter(s => (s.tier === 'main') === (tier === 'main'));
if (!list.length) continue;
md += sec(tier === 'main' ? 'Main characters' : 'Supporting characters');
for (const s of list) {
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
if (s.aliases) md += `*also known as ${s.aliases}*\n`;
if (s.gender) md += `- **Gender:** ${s.gender}\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`;
const mas = s.moral_alignment_score ?? 50;
const masLabel = mas >= 70 ? 'Good' : mas <= 30 ? 'Evil' : 'Neutral';
md += `- **Moral alignment:** ${mas}/100 (${masLabel}) — Arc: ${s.arc_direction || 'neutral'}\n`;
if (s.arc_note) md += ` *${s.arc_note}*\n`;
const f = (l, v) => v ? `- **${l}:** ${v}\n` : '';
md += f('Physical', s.physical) + f('Clothing', s.clothing)
+ f('Alignment & Ethos', s.alignment)
+ f('Core Attributes', [s.attribute_high && '▲ ' + s.attribute_high, s.attribute_low && '▼ ' + s.attribute_low].filter(Boolean).join(' · '))
+ f('Trained Skills', s.skills) + f('Capabilities', s.capabilities)
+ f('Backstory & Origin', s.backstory) + f('Relationships', s.relationships)
+ f('Motivation', s.motivation) + f('Fears', s.fears)
+ f('Mannerisms & Habits', s.mannerisms) + f('Voice & Speech', s.voice_pattern)
+ f('Voice Design Prompt', s.voice_design_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('Dark Secret / Fatal Flaw', s.secret)
+ f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition);
const src = (s.sources || []).filter(x => x && x.quote)
.map(x => `${x.line_hint ? '[' + x.line_hint + '] ' : ''}${x.page != null ? 'p.' + x.page + ' ' : ''}"${x.quote}"`).join('; ');
if (src) md += `- *Sources:* ${src}\n`;
}
}
return md.trim();
}
function csWireResultInteractions(root, sheets, sourceText, book, inline = false) {
const copyBtn = root.querySelector('#cs-copy');
copyBtn?.addEventListener('click', () => {
navigator.clipboard?.writeText(csToMarkdown(sheets))
.then(() => toast('Copied as Markdown', 'success'), () => toast('Copy failed', 'error'));
});
const closeBtn = root.querySelector('#cs-close');
closeBtn?.addEventListener('click', () => {
if (inline) {
if (typeof navLibraryView === 'function') navLibraryView('characters');
} else {
document.getElementById('cs-overlay')?.remove();
}
});
root.querySelector('#cs-open-cast')?.addEventListener('click', () => csGoToLibrary());
root.querySelector('#cs-back-cast-audio')?.addEventListener('click', () => {
if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('cast');
});
const bkCtx = book || '';
// Used to just pop a bare file picker — no way to paste an image URL or
// regenerate one from the character's own image_prompt without leaving
// this view entirely. Opens the same full lightbox (pick from disk / URL /
// AI-regenerate) already used everywhere else in the app instead.
root.querySelectorAll('.cs-avatar').forEach(av => {
av.addEventListener('click', async e => {
e.stopPropagation();
const name = av.dataset.name;
const s = sheets.find(x => x.name === name);
if (!s) return;
const id = `${bkCtx}::${name}`.toLowerCase();
let rec = (typeof clGet === 'function') ? await clGet(id).catch(() => null) : null;
if (!rec && typeof clUpsert === 'function') rec = await clUpsert(bkCtx, s).catch(() => null);
if (!rec || typeof _openAvatarLightbox !== 'function') {
toast('Avatar editor is unavailable right now', 'error');
return;
}
_openAvatarLightbox(rec, async (updatedRec) => {
s._image = updatedRec?.image || rec.image || '';
const card = root.querySelector(`.cs-card[data-name="${CSS.escape(name)}"]`);
if (card) {
const oldAv = card.querySelector('.cs-avatar');
if (oldAv) oldAv.outerHTML = csAvatarHtml(s);
}
});
});
});
root.querySelectorAll('.cs-deep-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (sheet) csDeepAnalysis(sheet, sourceText);
});
});
root.querySelectorAll('.cs-auto-refine-btn').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const name = String(btn.dataset.name || '').trim();
if (!name) return;
if (typeof window.csForReaderSelective === 'function') {
await window.csForReaderSelective([name]);
return;
}
toast('Character refinement is unavailable right now', 'error');
});
});
root.querySelectorAll('.cs-edit-btn').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const name = String(btn.dataset.name || '').trim();
if (!name || typeof clEdit !== 'function') return;
try {
const all = typeof clGetAll === 'function' ? await clGetAll() : [];
const wantBook = String(book || '').trim().toLowerCase();
const rec = all.find(r => {
const recName = String(r?.name || r?.sheet?.name || '').trim().toLowerCase();
const recBook = String(r?.book || '').trim().toLowerCase();
return recName === name.toLowerCase() && (!wantBook || recBook === wantBook);
}) || all.find(r => String(r?.name || r?.sheet?.name || '').trim().toLowerCase() === name.toLowerCase());
if (!rec) { toast('Character record not found in library', 'error'); return; }
clEdit(rec.id);
} catch (err) {
toast('Could not open editor', 'error');
}
});
});
root.querySelectorAll('.cs-img-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (!sheet) return;
navigator.clipboard?.writeText(csBuildImagePrompt(sheet))
.then(() => toast('Image prompt copied', 'success'), () => toast('Copy failed', 'error'));
});
});
root.querySelectorAll('.cs-voice-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (!sheet) return;
navigator.clipboard?.writeText(csBuildVoicePrompt(sheet))
.then(() => toast('Voice prompt copied — paste into Design a Voice', 'success'), () => toast('Copy failed', 'error'));
});
});
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-prompt-act').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const card = btn.closest('.cs-card');
const name = card?.dataset.name;
const sheet = sheets.find(s => s.name === name);
if (!sheet) return;
const act = btn.dataset.act;
let rec = (typeof clUpsert === 'function') ? await clUpsert(book || '', sheet).catch(() => null) : null;
if (!rec) { toast('Could not save this character yet — try again', 'error'); return; }
if (act === 'voice') {
if (typeof _charDesignVoice === 'function') _charDesignVoice(rec);
return;
}
if (act === 'image') {
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
try {
await _charAutoGenerateImage(rec);
sheet._image = rec.image;
const av = card?.querySelector('.cs-avatar');
if (av) av.outerHTML = csAvatarHtml(sheet);
toast('Profile image generated', 'success');
} catch (err) {
toast('Image generation failed: ' + (err.message || err), 'error');
} finally {
btn.disabled = false;
btn.innerHTML = orig;
}
}
if (act === 'concept_art') {
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
try {
const img = await _charAutoGenerateConceptArt(rec);
sheet.concept_art_image = img;
const box = btn.closest('.cs-prompt-box');
const body = box?.querySelector('.lcd-prompt-body');
const existingPreview = body?.querySelector('.cs-concept-art-preview');
if (existingPreview) existingPreview.querySelector('img').src = img;
else if (body) body.insertAdjacentHTML('afterbegin', `<div class="cs-concept-art-preview"><img src="${escHtml(img)}" alt="Concept art"></div>`);
btn.innerHTML = '<span class="mdi mdi-image-multiple-outline"></span> Regenerate Concept Art';
toast('Concept art generated', 'success');
} catch (err) {
toast('Concept art generation failed: ' + (err.message || err), 'error');
btn.innerHTML = orig;
} finally {
btn.disabled = false;
}
}
});
});
// Pencil toggle — makes this card's own field values editable in place,
// saving each field on its own debounce timer (one Map per card, so
// editing two fields within 900ms can't cancel each other's still-pending
// save the way a single shared timer did elsewhere in this app earlier
// this session). Fields that are currently empty aren't rendered at all
// (csField/csIdentityCell skip blank values), so adding a brand-new value
// to a field that has nothing yet still needs the full Library profile
// page — this only covers correcting a value that's already shown.
root.querySelectorAll('.cs-edit-toggle').forEach(btn => {
const card = btn.closest('.cs-card');
if (!card) return;
const saveTimers = new Map();
btn.addEventListener('click', e => {
e.stopPropagation();
const editing = card.classList.toggle('cs-editing');
btn.innerHTML = editing ? '<span class="mdi mdi-check"></span>' : '<span class="mdi mdi-pencil-outline"></span>';
btn.title = editing ? 'Done editing' : 'Edit character sheet';
card.querySelectorAll('.cs-field-editable[data-sheet-key]').forEach(el => {
el.contentEditable = editing ? 'true' : 'false';
if (editing && !el.dataset.wired) {
el.dataset.wired = '1';
el.addEventListener('input', () => {
const key = el.dataset.sheetKey;
const name = card.dataset.name;
const sheet = sheets.find(s => s.name === name);
if (!sheet || !key) return;
clearTimeout(saveTimers.get(key));
saveTimers.set(key, setTimeout(async () => {
sheet[key] = el.textContent.trim();
if (typeof csSaveToLibrary === 'function') await csSaveToLibrary(book, [sheet]);
}, 900));
});
}
});
});
});
root.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const pg = parseInt(btn.dataset.page, 10);
const quote = (btn.dataset.quote || '').trim();
if (!pg) { toast('No page reference saved for this source', 'error'); return; }
if (typeof window.readerJumpToPage !== 'function') { toast(`Source: page ${pg}`, 'info'); return; }
await window.readerJumpToPage(pg);
// readerJumpToPage only scrolls to the TOP of the page — searching for
// the actual quoted sentence afterward highlights and scrolls to the
// precise line, so there's real surrounding context to read instead of
// "somewhere on this page, go find it yourself."
if (quote && typeof readerSearchApply === 'function') {
const term = quote.length > 60 ? quote.slice(0, 60) : quote;
readerSearchApply(term, { jump: true });
const inp = document.getElementById('reader-search');
if (inp) inp.value = term;
}
});
});
}
async function csShow(sheets, title, sourceText, book, hostEl = null) {
const inline = !!hostEl;
document.getElementById('cs-overlay')?.remove();
// Enrich sheets with stored profile images from the character library.
if (typeof clGetAll === 'function') {
try {
const bk = book || title || '';
const all = await clGetAll();
const imgMap = new Map(all.filter(r => r.image).map(r => [r.id, r.image]));
sheets.forEach(s => {
const id = `${bk}::${s.name}`.toLowerCase();
if (imgMap.has(id)) s._image = imgMap.get(id);
});
} catch (_) {}
}
// Browsing grid, not the profile itself — use the SAME compact card the
// Library's own character grid uses (click one to open the full profile)
// instead of a wall of full detail cards, which was unreadable and
// diverged from what every other cast grid in the app looks like.
// csSaveToLibrary (called by every caller before csShow) has already
// persisted these sheets, so the real records exist to look up.
const bkCtx = book || title || '';
let allRecs = [];
try { allRecs = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(bkCtx) : []; } catch (_) { allRecs = []; }
const recByName = new Map(allRecs.map(r => [String(r.name || '').toLowerCase(), r]));
const recFor = (s) => recByName.get(String(s.name || '').toLowerCase())
|| { id: '', name: s.name, sheet: s, voice: s.voice, book: bkCtx, tags: '' };
const recsById = new Map();
const cardsFor = (list) => list.map(s => {
const rec = recFor(s);
if (rec.id) recsById.set(rec.id, rec);
return (typeof _charCardHtml === 'function') ? _charCardHtml(rec, allRecs) : csCardHtml(s);
}).join('');
const main = sheets.filter(s => s.tier === 'main');
const supp = sheets.filter(s => s.tier !== 'main');
const group = (label, list) => list.length
? `<div class="cs-group-label">${label}</div>` + cardsFor(list)
: '';
const ov = inline ? hostEl : document.createElement('div');
if (!inline) {
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box cs-box">
<div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span>
${escHtml(title || 'Character sheets')}
<span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span>
</span>
<span style="flex:1"></span>
<button class="btn-secondary btn-sm" id="cs-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
</div>
<div class="cs-list cs-list-compact">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
</div>`;
document.body.appendChild(ov);
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
} else {
ov.innerHTML = `<div class="audiobook-box cs-box cs-page-shell" style="width:100%; max-width:none; max-height:none; height:auto;">
<div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span>
${escHtml(title || 'Character sheets')}
<span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span>
</span>
<span style="flex:1"></span>
<button class="btn-secondary btn-sm" id="cs-back-cast-audio"><span class="mdi mdi-arrow-left"></span> Cast Audiobook</button>
<button class="btn-secondary btn-sm" id="cs-open-cast"><span class="mdi mdi-folder-account-outline"></span> Open Cast</button>
<button class="btn-secondary btn-sm" id="cs-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
</div>
<div class="card-subtitle" style="margin:0 0 12px 0;">Generated sheets are saved into the cast library. Use the buttons above to jump back to the audiobook cast or continue to the roster stage.</div>
<div class="cs-list cs-list-compact">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
</div>`;
}
csWireResultInteractions(ov, sheets, sourceText, book, inline);
if (typeof _wireCharCards === 'function') {
// _charDetailPage defaults to the Library page's own #lib-chars-list
// container, which doesn't exist here (this grid lives on the Read
// Aloud "Character sheets" results screen, or in the standalone
// overlay) — without an explicit target, clicking a card silently did
// nothing. Point it at this grid's own list element instead, and wire
// "back" to redraw this same results grid rather than the Library.
const listEl = ov.querySelector('.cs-list') || ov;
_wireCharCards(ov, recsById, allRecs, null, {
container: listEl,
onBack: () => csShow(sheets, title, sourceText, book, hostEl),
});
}
}
// ── Entry points (reader + rehearser) ────────────────────────────────────────
// Persist a fresh batch of sheets into the Character Library (auto-save).
async function csSaveToLibrary(book, sheets) {
if (typeof clUpsertMany !== 'function') return;
try {
const n = await clUpsertMany(book, sheets);
if (n) toast(`${n} character${n !== 1 ? 's' : ''} saved to library`, 'success');
} catch (e) { /* non-fatal — the overlay still works */ }
}
// Wrap up a recast by filling the two "external tool" prompts (SillyTavern
// card + concept-art turnaround sheet) that aren't part of the passage-by-
// passage extraction schema — those need the character's COMPLETE profile in
// one shot, which only exists once the whole book has been scanned, so this
// runs as a background pass right after the main recast finishes rather than
// blocking it. Fire-and-forget from the caller's point of view: it shows its
// own progress/result toast and re-saves in place once done.
async function csAutoGenerateExternalPrompts(book, sheets) {
if (!Array.isArray(sheets) || !sheets.length) return;
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
let done = 0, failed = 0, repeatMsg = '', repeatCount = 0;
toast(`Generating SillyTavern + Concept Art prompts for ${sheets.length} character${sheets.length !== 1 ? 's' : ''}`, 'info');
for (const s of sheets) {
if (!s || !s.name) continue;
try {
const sample = [s.physical, s.backstory, s.motivation].filter(Boolean).join(' ');
const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : '';
const r = await fetch('/api/character-generate-prompts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: s.name, book, sheet: s, language,
llm_url: target.url, model: target.model,
fields: ['silly_tavern_prompt', 'concept_art_prompt'],
}),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
if (d.silly_tavern_prompt) s.silly_tavern_prompt = d.silly_tavern_prompt;
if (d.concept_art_prompt) s.concept_art_prompt = d.concept_art_prompt;
done++; repeatCount = 0;
} catch (e) {
failed++;
const msg = (e && e.message) ? e.message : String(e);
console.error('[auto external prompts]', s.name, e);
// Same guard as the Library's bulk actions: 3 identical failures in a
// row means something systemic (LLM down, bad config), not a
// per-character fluke — stop instead of grinding through everyone.
if (msg === repeatMsg) { repeatCount++; } else { repeatMsg = msg; repeatCount = 1; }
if (repeatCount >= 3) break;
}
}
try { await clUpsertMany(book, sheets); } catch (_) {}
const suffix = failed ? ` (${failed} failed${repeatMsg ? ': ' + repeatMsg.slice(0, 160) : ''})` : '';
toast(`External prompts generated for ${done} character${done !== 1 ? 's' : ''}${suffix}`, failed && !done ? 'error' : 'success');
// Concept art IMAGES, not just the prompt text — runs after clUpsertMany
// above so each character's record already exists to attach the image to
// (_charAutoGenerateConceptArt fetches-then-writes by id via clGet/clPut).
// Previously this prompt only ever produced text that a user had to notice
// and click "Generate Concept Art" on manually, per character.
if (typeof _charAutoGenerateConceptArt !== 'function') return;
const withPrompt = sheets.filter(s => s && s.name && _libStr(s.concept_art_prompt).trim());
if (!withPrompt.length) return;
let imgDone = 0, imgFailed = 0, imgRepeatMsg = '', imgRepeatCount = 0;
toast(`Generating concept art for ${withPrompt.length} character${withPrompt.length !== 1 ? 's' : ''}`, 'info');
for (const s of withPrompt) {
try {
await _charAutoGenerateConceptArt({ id: clKey(book, s.name), sheet: s });
imgDone++; imgRepeatCount = 0;
} catch (e) {
imgFailed++;
const msg = (e && e.message) ? e.message : String(e);
console.error('[auto concept art]', s.name, e);
if (msg === imgRepeatMsg) { imgRepeatCount++; } else { imgRepeatMsg = msg; imgRepeatCount = 1; }
if (imgRepeatCount >= 3) break;
}
}
const imgSuffix = imgFailed ? ` (${imgFailed} failed${imgRepeatMsg ? ': ' + imgRepeatMsg.slice(0, 160) : ''})` : '';
toast(`Concept art generated for ${imgDone} character${imgDone !== 1 ? 's' : ''}${imgSuffix}`, imgFailed && !imgDone ? 'error' : 'success');
}
function csGoToLibrary() {
if (typeof navTo === 'function') navTo('s-library');
if (typeof navLibraryView === 'function') navLibraryView('characters');
else if (typeof libraryRender === 'function') libraryRender('characters');
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('castlib');
}
// Best-effort dialogue-line tally so the Library's table view can show a
// "lines" column — only meaningful right after a cast/rehearsal run is in
// memory, since line attribution itself isn't part of character-sheet
// generation. Matches case-insensitively since sheet names and roster/script
// speaker names aren't guaranteed identical casing.
function csAttachLineCounts(sheets, counts) {
if (!counts || !counts.size) return;
sheets.forEach(function (s) {
const n = counts.get(String(s.name || '').trim().toLowerCase());
if (n != null) s.line_count = n;
});
}
async function csForReader(opts = {}) {
// fresh: "new recast" — discards every existing character SHEET (details,
// not the dialogue cast itself) and regenerates every profile from a blank
// slate, instead of the normal path which reuses/refreshes what's already
// saved. Used when the sheets have drifted enough that patching them isn't
// enough anymore.
const fresh = !!opts.fresh;
const text = csReaderText();
const book = readerState.title || 'Untitled book';
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('chars');
// navTo('s-reader') triggers the reader's own un-awaited async setup
// (window.readerOnShow — refreshes the backend/voice selects, re-renders
// the saved-books library, and can apply a pending window._readerStartView
// once that settles). If any of that resolves AFTER this view switch, it
// can silently flip the view back to the main reading screen a moment
// later — confirmed live: clicking "Cast from Reader" sometimes landed
// back on the plain Source view with a "resuming at sentence…" toast
// instead of starting the cast. Re-asserting the view shortly after wins
// that race regardless of which async step caused it.
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
const pageHost = csReaderPageHost();
if (!fresh && _cs.cache[key]) {
await csSaveToLibrary(book, _cs.cache[key]);
if (pageHost) await csShow(_cs.cache[key], book, text, book, pageHost);
return;
}
const knownRoster = csKnownReaderRoster();
let seedSheets = [];
if (!fresh) try {
if (typeof clGetAllByTagOrBook === 'function') {
const existing = await clGetAllByTagOrBook(book);
// The Library keeps every character record ever saved for this book,
// including ones from earlier, less accurate casting attempts (before a
// recast/merge/quality run cleaned things up) — without filtering, that
// stale history leaks back in here as "known" characters even though
// the CURRENT cast (knownRoster, from the live audiobook roster) no
// longer has them. Keep only saved records that still match a name in
// the current cast, so this list stays in sync with what step 2 shows.
const rosterSet = new Set(knownRoster.map(n => n.toLowerCase()));
const stillCast = rec => !rosterSet.size || rosterSet.has(String(rec?.name || '').trim().toLowerCase());
seedSheets = (existing || []).filter(stillCast).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim());
}
} catch (_) { seedSheets = []; }
// Computed up front (not just after the whole run finishes) so "Lines"
// shows immediately in the live preview instead of staying blank until
// generation completes.
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
const lineCounts = new Map();
if (ab?.segments?.length) {
for (const s of ab.segments) {
if (s?.type !== 'dialogue' || !s.speaker) continue;
const k = String(s.speaker).trim().toLowerCase();
lineCounts.set(k, (lineCounts.get(k) || 0) + 1);
}
}
const sheets = await csGenerate(text, key, knownRoster, { pageHost, seedSheets, lineCounts });
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
if (lineCounts.size) {
csAttachLineCounts(sheets, lineCounts);
}
await csSaveToLibrary(book, sheets);
if (pageHost) await csShow(sheets, book, text, book, pageHost);
toast(sheets.length + ' character sheets saved — Library → Cast', 'success');
csAutoGenerateExternalPrompts(book, sheets);
}
// Refresh sheet data for a hand-picked subset of an already-cast book's
// characters, leaving everyone else's saved sheet untouched. Each picked
// character now gets its own evidence window built from the paragraphs around
// every name/alias hit, so the model spends its context on the right places
// instead of rescanning the full book for every selected profile.
async function csForReaderSelective(selectedNames) {
const wanted = new Set((selectedNames || []).map(n => String(n).trim().toLowerCase()));
if (!wanted.size) { toast('No characters selected', 'error'); return; }
const text = csReaderText();
const book = readerState.title || 'Untitled book';
if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('chars');
// navTo('s-reader') triggers the reader's own un-awaited async setup
// (window.readerOnShow — refreshes the backend/voice selects, re-renders
// the saved-books library, and can apply a pending window._readerStartView
// once that settles). If any of that resolves AFTER this view switch, it
// can silently flip the view back to the main reading screen a moment
// later — confirmed live: clicking "Cast from Reader" sometimes landed
// back on the plain Source view with a "resuming at sentence…" toast
// instead of starting the cast. Re-asserting the view shortly after wins
// that race regardless of which async step caused it.
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
const pageHost = csReaderPageHost();
let records = [];
try { records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; } catch (_) { records = []; }
const picked = [];
const seen = new Set();
const matchesWanted = (rec) => {
const tokens = csNameTokens({
name: rec?.name || '',
aliases: rec?.sheet?.aliases || '',
first_name: rec?.sheet?.first_name || '',
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;
}
}
return false;
};
(records || []).forEach(rec => {
if (!rec || !rec.name || !matchesWanted(rec)) return;
const key = String(rec.name || '').trim().toLowerCase();
if (seen.has(key)) return;
seen.add(key);
picked.push(rec);
});
(selectedNames || []).forEach(name => {
const key = String(name || '').trim().toLowerCase();
if (!key || seen.has(key)) return;
const fallback = { name: String(name || '').trim(), sheet: csBlankSheet(String(name || '').trim()) };
seen.add(key);
picked.push(fallback);
});
picked.sort((a, b) => (b?.sheet?.line_count || 0) - (a?.sheet?.line_count || 0) || String(a?.name || '').localeCompare(String(b?.name || '')));
if (!picked.length) { toast('No matching cast characters found', 'error'); return; }
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; }
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
if (ab?.roster) {
const counts = new Map();
ab.roster.forEach(function (info, name) { counts.set(String(name).trim().toLowerCase(), info.count || 0); });
csAttachLineCounts(filtered, counts);
}
await csSaveToLibrary(book, filtered);
if (pageHost) await csShow(filtered, book, text, book, pageHost);
toast(filtered.length + ' character' + (filtered.length !== 1 ? 's' : '') + ' defined', 'success');
}
async function csForRehearser() {
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
const book = $('reh-script-title')?.value.trim() || 'Untitled script';
const text = csRehearserText();
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('chars');
// navTo('s-reader') triggers the reader's own un-awaited async setup
// (window.readerOnShow — refreshes the backend/voice selects, re-renders
// the saved-books library, and can apply a pending window._readerStartView
// once that settles). If any of that resolves AFTER this view switch, it
// can silently flip the view back to the main reading screen a moment
// later — confirmed live: clicking "Cast from Reader" sometimes landed
// back on the plain Source view with a "resuming at sentence…" toast
// instead of starting the cast. Re-asserting the view shortly after wins
// that race regardless of which async step caused it.
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
const pageHost = csReaderPageHost();
if (_cs.cache[key]) {
await csSaveToLibrary(book, _cs.cache[key]);
if (pageHost) await csShow(_cs.cache[key], title, text, book, pageHost);
return;
}
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.length) { toast('No characters found', 'error'); return; }
if (window.rehState?.lines?.length) {
const counts = new Map();
rehState.lines.forEach(function (l) {
if (l.type !== 'dialog' || !l.speaker) return;
const k = String(l.speaker).trim().toLowerCase();
counts.set(k, (counts.get(k) || 0) + 1);
});
csAttachLineCounts(sheets, counts);
}
await csSaveToLibrary(book, sheets);
if (pageHost) await csShow(sheets, title, text, book, pageHost);
toast(sheets.length + ' character sheets saved — Library → Cast', 'success');
}
window.csForReader = csForReader;
window.csForReaderSelective = csForReaderSelective;
window.csForRehearser = csForRehearser;
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser);