// ── 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: {} };
// 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 || ''; }
// 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', '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'],
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', '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.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 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: '',
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 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 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].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)).slice(0, 18);
}
// 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) {
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);
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();
// 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 => map.set(name.toLowerCase(), 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));
prog.startPassage(chunks[i]);
try {
const body = { text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model };
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 changed = csMerge(map, data.sheets || []);
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.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));
} 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) {
let ov = document.getElementById('cs-progress');
if (!ov) {
ov = document.createElement('div');
ov.id = 'cs-progress'; ov.className = 'audiobook-overlay';
// 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.
ov.innerHTML = `
Character sheets
Analysing…
Passage
Live output watching…
Characters found
`;
document.body.appendChild(ov);
ov.querySelector('#cs-progress-cancel').addEventListener('click', () => { _cs.cancel = true; });
}
ov.style.display = 'flex';
const fill = ov.querySelector('#cs-progress-fill');
const msg = ov.querySelector('#cs-progress-msg');
const chars = ov.querySelector('#cs-progress-chars');
const sideTitle = ov.querySelector('#cs-progress-side-title');
const passagePre = ov.querySelector('#cs-progress-passage-pre');
const livePre = ov.querySelector('#cs-progress-live-pre');
const liveStatus = ov.querySelector('#cs-progress-live-status');
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 = '';
if (liveStatus) { liveStatus.textContent = 'watching…'; liveStatus.className = 'cs-progress-live-status'; }
},
// 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'; }
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'; }
},
update(d, label, charLabel, roster = []) {
if (fill) fill.style.width = (d / total * 100) + '%';
if (msg && label) msg.textContent = label;
if (sideTitle) sideTitle.textContent = charLabel || 'Characters found';
if (chars) {
const list = Array.isArray(roster) ? roster : [];
chars.innerHTML = list.length ? list.map(c => `