// ── 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. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.
- first_name, last_name, full_name: split the character identity when known. Leave unknown parts empty.
- title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter').
- profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest').
- archetype: a two-word role summary (e.g. 'Ruthless Scholar')
- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.
- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.
- clothing: distinctive clothing, armour, accessories — as observed in the text
- alignment: strict moral code + the one line they will never cross
- moral_alignment_score: integer 0–100. 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
- 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
- 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: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.
Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.
Respond with STRICT JSON only:
{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","profession":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
/no-think`;
// 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', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
'attribute_high', 'attribute_low', 'skills', 'capabilities',
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt',
'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));
const CS_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title'];
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'],
relationships: ['relationship', 'ally', 'rival', 'enemy', 'family'],
motivation: ['motivation', 'intention', 'goal', 'desire'],
fears: ['fear', 'dread'],
mannerisms: ['mannerism', 'habit', 'gesture', 'voice'],
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'],
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 = ['physical', 'profession', 'backstory', 'motivation', 'voice_pattern', 'relationships'];
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.full_name, 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: '',
archetype: '', gender: '', physical: '', clothing: '', alignment: '', arc_note: '',
attribute_high: '', attribute_low: '', skills: '', capabilities: '',
backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', voice_pattern: '',
secret: '', conflict_style: '', win_condition: '', voice_design_prompt: '', image_prompt: '',
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;
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?.full_name);
add([rec?.sheet?.first_name, rec?.sheet?.last_name].filter(Boolean).join(' '));
return out;
}
function csParagraphBlocks(text) {
return String(text || '')
.replace(/\r\n/g, '\n')
.split(/\n\s*\n+/)
.map(s => s.trim())
.filter(Boolean);
}
function csReaderParagraphBlocks() {
if (readerState.mode === 'text' && readerState.docText) return csParagraphBlocks(readerState.docText);
const base = Array.isArray(readerState.baseSentences) && readerState.baseSentences.length
? readerState.baseSentences
: (Array.isArray(readerState.sentences) ? readerState.sentences : []);
if (!base.length) return csParagraphBlocks(csReaderText());
const paras = [];
let cur = '';
let curPage = null;
for (const s of base) {
const page = s?.words?.[0]?.page ?? null;
const pageChanged = readerState.mode === 'pdf' && page != null && curPage != null && page !== curPage;
if ((s?.paraStart || pageChanged) && cur.trim()) {
paras.push(cur.trim());
cur = '';
}
if (!cur && readerState.mode === 'pdf' && page != null) {
cur += `[p.${page + 1}] `;
}
cur += (cur && !/\s$/.test(cur) ? ' ' : '') + String(s?.text || '').trim();
curPage = page != null ? page : curPage;
}
if (cur.trim()) paras.push(cur.trim());
return paras;
}
function csParagraphHasNeedle(paragraph, needle) {
const p = String(paragraph || '');
const n = String(needle || '').trim();
if (!n || n.length < 2) return false;
const lowerP = p.toLowerCase();
const lowerN = n.toLowerCase();
if (lowerN.includes(' ')) return lowerP.includes(lowerN);
const esc = lowerN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[^\\p{L}\\p{N}_])${esc}(?=$|[^\\p{L}\\p{N}_])`, 'iu').test(p);
}
function csEvidenceWindowText(text, needles, before = 2, after = 2) {
const paras = Array.isArray(text) ? text.slice() : csParagraphBlocks(text);
if (!paras.length) return String(text || '').trim();
const hits = new Set();
paras.forEach((para, idx) => {
if (!(needles || []).some(n => csParagraphHasNeedle(para, n))) return;
for (let i = idx - before; i <= idx + after; i++) {
if (i >= 0 && i < paras.length) hits.add(i);
}
});
if (!hits.size) return '';
return [...hits].sort((a, b) => a - b).map(i => paras[i]).join('\n\n').trim();
}
function csFindMergeKey(map, sheet) {
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;
}
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.full_name, s.title, s.profession].filter(Boolean).join(' · '),
status: changedByKey.get(key) || '',
filled,
main: s.tier === 'main',
};
}).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.
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;
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();
const seedSheets = Array.isArray(opts.seedSheets) ? opts.seedSheets : [];
seedSheets.forEach(seedRaw => {
const seed = csSeedSheet(seedRaw);
const name = String(seed.name || '').trim();
if (!name) return;
map.set(name.toLowerCase(), seed);
});
// Pre-seed the roster with known characters from the casting run so the LLM
// 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)) map.set(key, csBlankSheet(name));
});
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]);
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); });
} catch (streamErr) {
if (!sawDelta) prog.noStream();
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 || []);
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.full_name, s.title, s.profession, s.aliases].filter(Boolean).join(', ').split(/[,;/|]/).map(x => x.trim()).find(x => x.toLowerCase() === n) || n;
if (display && !roster.some(r => r.toLowerCase() === display.toLowerCase())) roster.push(display);
});
});
}
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()]);
} catch (e) {
console.error('Character sheets passage', i + 1, 'failed:', e);
toast('Passage ' + (i + 1) + ' failed: ' + (e.message || String(e)), 'error');
}
}
} finally { prog.done(); _cs.running = false; }
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
const sheets = [...map.values()];
if (cacheKey) _cs.cache[cacheKey] = sheets;
return sheets;
}
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 = `
Character sheets
Analysing…
Select a character to preview the sheet as it fills.
Passage / Live output watching…
`;
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 = '
' +
savedCsPrompts.map((p, i) => `
`).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 = '';
const keyFor = v => String(v || '').trim().toLowerCase();
const findSheet = (name, sheets = currentSheets) => (Array.isArray(sheets) ? sheets : []).find(s => keyFor(s?.name) === keyFor(name)) || null;
let outputCollapsed = false;
let outputExpandedHeight = 0;
const clampOutputHeight = (h) => {
const min = 140;
const max = Math.max(min, Math.round(window.innerHeight * 0.82));
return Math.max(min, Math.min(max, Math.round(h)));
};
const setOutputHeight = (h, persist = true) => {
const next = clampOutputHeight(h);
outputExpandedHeight = next;
if (!outputCollapsed && outputPanel) {
outputPanel.style.height = `${next}px`;
outputPanel.style.maxHeight = `${next}px`;
outputPanel.dataset.csHeight = String(next);
}
if (persist) {
try { localStorage.setItem('ttsvc_cs_output_height', String(next)); } catch (_) {}
}
};
const applyOutputCollapse = (collapsed) => {
outputCollapsed = !!collapsed;
outputPanel?.classList.toggle('is-collapsed', outputCollapsed);
leftPane?.classList.toggle('is-output-collapsed', outputCollapsed);
if (outputCollapse) {
const icon = outputCollapse.querySelector('.mdi');
if (icon) icon.className = 'mdi ' + (outputCollapsed ? 'mdi-chevron-up' : 'mdi-chevron-down');
outputCollapse.title = outputCollapsed ? 'Expand passage and live output' : 'Collapse passage and live output';
}
if (outputPanel) {
if (outputCollapsed) {
if (!outputExpandedHeight) {
const cur = parseInt(outputPanel.dataset.csHeight || '', 10) || parseInt(outputPanel.style.height || '', 10) || outputPanel.getBoundingClientRect().height;
if (cur) outputExpandedHeight = clampOutputHeight(cur);
}
outputPanel.style.height = '44px';
outputPanel.style.maxHeight = '44px';
} else {
const h = outputExpandedHeight || parseInt(outputPanel.dataset.csHeight || '', 10) || 260;
outputPanel.style.height = `${clampOutputHeight(h)}px`;
outputPanel.style.maxHeight = `${clampOutputHeight(h)}px`;
outputPanel.dataset.csHeight = String(clampOutputHeight(h));
}
}
try { localStorage.setItem('ttsvc_cs_output_collapsed', outputCollapsed ? '1' : '0'); } catch (_) {}
};
let sideCollapsed = false;
const applySideCollapse = (collapsed) => {
sideCollapsed = !!collapsed;
root.querySelector('#cs-progress-layout')?.classList.toggle('side-collapsed', sideCollapsed);
root.querySelector('#cs-progress-side')?.classList.toggle('is-collapsed', sideCollapsed);
if (sideCollapse) {
const icon = sideCollapse.querySelector('.mdi');
if (icon) icon.className = 'mdi ' + (sideCollapsed ? 'mdi-chevron-left' : 'mdi-chevron-right');
sideCollapse.title = sideCollapsed ? 'Expand character list' : 'Collapse character list';
}
try { localStorage.setItem('ttsvc_cs_side_collapsed', sideCollapsed ? '1' : '0'); } catch (_) {}
};
try { sideCollapsed = localStorage.getItem('ttsvc_cs_side_collapsed') === '1'; } catch (_) {}
try {
const savedOutputCollapsed = localStorage.getItem('ttsvc_cs_output_collapsed');
if (savedOutputCollapsed !== null) outputCollapsed = savedOutputCollapsed === '1';
const savedOutputHeight = parseInt(localStorage.getItem('ttsvc_cs_output_height') || '', 10);
if (!Number.isNaN(savedOutputHeight) && savedOutputHeight > 0) outputExpandedHeight = clampOutputHeight(savedOutputHeight);
} catch (_) {}
applySideCollapse(sideCollapsed);
applyOutputCollapse(outputCollapsed);
sideCollapse?.addEventListener('click', () => applySideCollapse(!sideCollapsed));
outputCollapse?.addEventListener('click', () => applyOutputCollapse(!outputCollapsed));
if (outputResize) {
let dragging = false;
let startY = 0;
let startH = 0;
const onMove = (e) => {
if (!dragging) return;
if (outputCollapsed) applyOutputCollapse(false);
setOutputHeight(startH + (e.clientY - startY));
e.preventDefault();
};
const stopDrag = () => {
dragging = false;
document.body.classList.remove('cs-output-resizing');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', stopDrag);
};
outputResize.addEventListener('mousedown', (e) => {
e.preventDefault();
if (!outputPanel) return;
if (outputCollapsed) applyOutputCollapse(false);
dragging = true;
startY = e.clientY;
startH = parseInt(outputPanel.dataset.csHeight || '', 10) || outputPanel.getBoundingClientRect().height || 260;
document.body.classList.add('cs-output-resizing');
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', stopDrag);
});
}
const renderPreview = () => {
if (!preview) return;
const sheet = findSheet(selectedName) || currentSheets[0] || null;
if (!sheet) {
preview.innerHTML = '
Select a character to preview the sheet as it fills.
';
return;
}
preview.innerHTML = csCardHtml(sheet);
preview.querySelector('.cs-card')?.classList.add('cs-progress-preview-card');
preview.querySelectorAll('.cs-head-btns, .cs-sources').forEach(el => el.remove());
preview.querySelectorAll('.cs-avatar').forEach(el => { el.style.pointerEvents = 'none'; el.style.cursor = 'default'; });
};
const renderChars = () => {
if (!chars) return;
const list = currentRoster.filter(c => {
if (!searchQuery) return true;
const name = keyFor(c.name);
const alias = keyFor(c.alias);
return name.includes(searchQuery) || alias.includes(searchQuery);
});
chars.innerHTML = list.length ? list.map(c => `
${escHtml((c.name || '?')[0].toUpperCase())}
${escHtml(c.name || 'Unknown')}
${escHtml(String(c.filled || 0))}
${CS_DETAIL_FIELDS.length}
`).join('') : '
reading…
';
chars.querySelectorAll('.ab-char-item').forEach(el => {
el.addEventListener('click', () => {
selectedName = keyFor(el.dataset.name);
renderPreview();
});
});
};
search?.addEventListener('input', () => {
searchQuery = search.value.trim().toLowerCase();
renderChars();
});
renderPreview();
return {
// 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'; }
},
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 ? `
${escHtml(charLabel)}` : '';
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 `
EvilGood
${showArrow ? `${arc.arrow}` : ''}
${arcNote ? `
${escHtml(arc.label)} · ${escHtml(arcNote)}
` : `
${escHtml(arc.label)}
`}
`;
}
// ── Image & Voice prompt generators ──────────────────────────────────────────
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 `Portrait of ${s.name}, ${parts.filter(Boolean).join(', ')}, fantasy character art, detailed face, dramatic lighting, high detail.`;
}
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 = `
Deep Analysis — ${escHtml(sheet.name)}
Running deep psychological analysis…
`;
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
? `
${title}
${escHtml(String(content))}
` : '';
modal.querySelector('#cs-deep-body').innerHTML = `
${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 =
`
Error: ${escHtml(String(err.message))}
`;
}
}
// ── 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) {
const src = csSourcesForField(s, field)[0];
if (!src) return '';
const idx = (s.sources || []).findIndex(x => x === src);
const n = idx >= 0 ? idx + 1 : 1;
return `
`;
}
function csField(label, value, sheet, field) {
if (!value) return '';
const badge = sheet && field ? csSourceBadgeHtml(sheet, field) : '';
return `
${label}${escHtml(String(value))}${badge ? ` ${badge}` : ''}`;
}
function csPromptBox(label, value, sheetKey) {
const text = _csStr(value);
const has = !!text.trim();
return `
${escHtml(label)}${has ? '' : ' — not generated yet'}
${escHtml(text)}
`;
}
function csAvatarHtml(s) {
const hue = Math.abs((s.name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0));
if (s._image) {
return `
`;
}
return `
${escHtml((s.name || '?')[0].toUpperCase())}
`;
}
function csIdentityTagList(s) {
const tags = [];
const add = (label, value) => {
const text = String(value || '').trim();
if (!text) return;
const key = text.toLowerCase();
if (tags.some(t => t.key === key)) return;
tags.push({ label, text, key });
};
(String(s.aliases || '').split(',') || []).map(x => x.trim()).filter(Boolean).forEach(v => add('also known as', v));
if (s.full_name && String(s.full_name).trim() && String(s.full_name).trim() !== String(s.name || '').trim()) add('full name', s.full_name);
const firstLast = [s.first_name, s.last_name].filter(Boolean).join(' ').trim();
if (firstLast && firstLast !== String(s.full_name || '').trim() && firstLast !== String(s.name || '').trim()) add('first / last', firstLast);
return tags;
}
function csCardHtml(s) {
const inv = (s.inventory || []).filter(Boolean);
const fullName = String(s.full_name || '').trim();
const metaTags = csIdentityTagList(s);
const identityHtml = [
csField('Name', s.name || fullName, s, 'name'),
csField('Full Name', fullName && fullName.toLowerCase() !== String(s.name || '').toLowerCase() ? fullName : '', s, 'full_name'),
csField('First Name', s.first_name, s, 'first_name'),
csField('Last Name', s.last_name, s, 'last_name'),
csField('Also Known As', s.aliases, s, 'aliases'),
csField('Gender', s.gender, s, 'gender'),
csField('Title', s.title, s, 'title'),
csField('Occupation', s.profession, s, 'profession'),
].join('');
const invHtml = inv.length
? `
Signature Items${inv.map(i => `- ${escHtml(i)}
`).join('')}
`
: '';
const attrs = (s.attribute_high || s.attribute_low)
? `
Core Attributes▲ ${escHtml(s.attribute_high || '—')} · ▼ ${escHtml(s.attribute_low || '—')}`
: '';
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
const srcHtml = sources.length
? `
Sources / Evidence
${
sources.map((x, i) =>
``
).join('')
}
`
: '';
const promptHtml = `
${csPromptBox('Voice Design Prompt', s.voice_design_prompt, 'voice_design_prompt')}
${csPromptBox('Character Image Prompt', s.image_prompt, 'image_prompt')}
${csPromptBox('SillyTavern Character Prompt', s.silly_tavern_prompt, 'silly_tavern_prompt')}
${csPromptBox('Character Concept Art Prompt', s.concept_art_prompt, 'concept_art_prompt')}
`;
return `
${csAvatarHtml(s)}
${escHtml(s.name)}
${s.archetype ? `
${escHtml(s.archetype)}` : ''}
${s.tier === 'main' ? 'Main' : 'Supporting'}
${metaTags.length ? `
${metaTags.map(t => `${escHtml(t.label)}${escHtml(t.text)}`).join('')}
` : ''}
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
${identityHtml}
${csField('Physical', s.physical, s, 'physical')}
${csField('Clothing & Appearance', s.clothing, s, 'clothing')}
${csField('Alignment & Ethos', s.alignment, s, 'alignment')}
${attrs}
${csField('Trained Skills', s.skills, s, 'skills')}
${csField('Capabilities', s.capabilities, s, 'capabilities')}
${invHtml}
${csField('Backstory & Origin', s.backstory, s, 'backstory')}
${csField('Relationships', s.relationships, s, 'relationships')}
${csField('Motivation', s.motivation, s, 'motivation')}
${csField('Fears', s.fears, s, 'fears')}
${csField('Mannerisms & Habits', s.mannerisms, s, 'mannerisms')}
${s.voice_pattern ? `- Voice & Speech
- ${escHtml(String(s.voice_pattern))}${csSourceBadgeHtml(s, 'voice_pattern') ? ` ${csSourceBadgeHtml(s, 'voice_pattern')}` : ''}
` : ''}
${csField('Dark Secret / Fatal Flaw', s.secret, s, 'secret')}
${csField('Conflict Style', s.conflict_style, s, 'conflict_style')}
${csField('Win Condition', s.win_condition, s, 'win_condition')}
${promptHtml}
${srcHtml}
`;
}
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.full_name) md += `- **Full name:** ${s.full_name}\n`;
if (s.gender) md += `- **Gender:** ${s.gender}\n`;
if (s.title) md += `- **Title:** ${s.title}\n`;
if (s.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 || '';
root.querySelectorAll('.cs-avatar').forEach(av => {
av.addEventListener('click', e => {
e.stopPropagation();
const name = av.dataset.name;
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*';
inp.onchange = async () => {
const file = inp.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = async ev => {
const dataUrl = ev.target.result;
const id = `${bkCtx}::${name}`.toLowerCase();
if (typeof clSetImage === 'function') await clSetImage(id, dataUrl);
const s = sheets.find(x => x.name === name);
if (s) s._image = dataUrl;
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);
card.querySelector('.cs-avatar')?.addEventListener('click', av.onclick);
}
}
toast('Profile picture saved', 'success');
};
reader.readAsDataURL(file);
};
inp.click();
});
});
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-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 = '
Generating…';
try {
const sample = [sheet.physical, sheet.backstory, sheet.motivation, sheet.relationships].filter(Boolean).join(' ');
const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : '';
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
const r = await fetch('/api/character-generate-prompts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: sheet.name, book: book || '', sheet,
language, llm_url: target.url, model: target.model, fields: [key],
}),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
if (!d[key]) throw new Error('Empty response — try again');
sheet[key] = d[key];
const textEl = btn.closest('.lcd-prompt-body')?.querySelector('.cs-prompt-text');
if (textEl) textEl.textContent = d[key];
if (typeof csSaveToLibrary === 'function') await csSaveToLibrary(book, sheets);
toast('Prompt generated', 'success');
} catch (err) {
toast('Prompt generation failed: ' + (err.message || err), 'error');
} finally {
btn.disabled = false;
btn.innerHTML = orig;
}
});
});
root.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const pg = parseInt(btn.dataset.page, 10);
if (pg && typeof window.readerJumpToPage === 'function') window.readerJumpToPage(pg);
else if (pg && typeof toast === 'function') toast(`Source: page ${pg}`, 'info');
});
});
}
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 (_) {}
}
const main = sheets.filter(s => s.tier === 'main');
const supp = sheets.filter(s => s.tier !== 'main');
const group = (label, list) => list.length
? `
${label}
` + list.map(csCardHtml).join('')
: '';
const ov = inline ? hostEl : document.createElement('div');
if (!inline) {
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
ov.innerHTML = `
${escHtml(title || 'Character sheets')}
${sheets.length} character${sheets.length !== 1 ? 's' : ''}
${group('Main characters', main)}${group('Supporting characters', supp)}
`;
document.body.appendChild(ov);
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
} else {
ov.innerHTML = `
${escHtml(title || 'Character sheets')}
${sheets.length} character${sheets.length !== 1 ? 's' : ''}
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.
${group('Main characters', main)}${group('Supporting characters', supp)}
`;
}
csWireResultInteractions(ov, sheets, sourceText, book, inline);
}
// ── 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 */ }
}
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() {
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');
const pageHost = csReaderPageHost();
if (_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 = [];
try {
if (typeof clGetAllByTagOrBook === 'function') {
const existing = await clGetAllByTagOrBook(book);
seedSheets = (existing || []).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim());
}
} catch (_) { seedSheets = []; }
const sheets = await csGenerate(text, key, knownRoster, { pageHost, seedSheets });
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
if (ab?.roster) {
const counts = new Map();
ab.roster.forEach(function (info, name) { counts.set(String(name).trim().toLowerCase(), info.count || 0); });
csAttachLineCounts(sheets, counts);
}
await csSaveToLibrary(book, sheets);
if (pageHost) await csShow(sheets, book, text, book, pageHost);
toast(sheets.length + ' character sheets saved — Library → Cast', 'success');
}
// 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');
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');
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);