tts-voice-creator-clone-and.../static/js/character-sheets.js
mARTin-B78 5fd1660c09 Add character table view, bulk voice-assign, source links, fix prompt truncation (v1.12.98)
Table view: one row per character (avatar, name, sex, line count, voice
language, alignment, voice, tags, prompt-availability checks), toggled
next to the card grid and persisted.

Bulk voice auto-assign: checkbox per character + "Auto-assign selected"
per production, sequential so later picks see what earlier ones just
took (avoids duplicate voice assignments).

Character tags (auto-set to the book of origin) are now visible on
cards - the field always existed, cards just never rendered it, so a
character recurring across books had no visible link between records.

Detail fields (Backstory, Motivation, etc.) now show small numbered
links to their exact source citation when the sheet has one, instead
of making the reader search the full "Quellen im Text" list.

Added gender as an actual extracted character-sheet field - the UI
already had a gender icon but the LLM was never asked for the value.

Fixed SillyTavern/Concept Art prompts still coming back empty despite
the earlier token-budget increase: they're the last two fields in one
JSON object, so truncation always cost the same two regardless of the
ceiling. Split into two independent, concurrent LLM calls instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:09:59 +02:00

808 lines
39 KiB
JavaScript

// ── 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);
}
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));
try {
const r = await fetch('/api/character-sheets', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model }),
});
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();
let data;
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';
ov.innerHTML = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>
<div class="audiobook-msg" id="cs-progress-msg">Analysing…</div>
<div class="reader-synth-track" style="margin-bottom:4px"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
<div class="cs-progress-chars" id="cs-progress-chars"></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
</div>`;
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');
return {
update(d, label, charLabel, roster = []) {
if (fill) fill.style.width = (d / total * 100) + '%';
if (msg && label) msg.textContent = label;
if (chars) {
const list = Array.isArray(roster) ? roster : [];
chars.innerHTML = `
<div class="cs-progress-summary">${escHtml(charLabel || '')}</div>
${list.length ? `<div class="cs-progress-list">
${list.map(c => `
<div class="cs-progress-chip ${c.status ? 'is-' + escHtml(c.status) : ''}">
<span class="cs-progress-dot">${escHtml((c.name || '?')[0].toUpperCase())}</span>
<span class="cs-progress-meta">
<span class="cs-progress-name">${escHtml(c.name || 'Unknown')}</span>
${c.alias ? `<span class="cs-progress-alias">${escHtml(c.alias)}</span>` : ''}
</span>
<span class="cs-progress-fillmeta" title="${escHtml(String(c.filled || 0))} profile fields filled">
<span style="width:${Math.min(100, Math.round(((c.filled || 0) / CS_DETAIL_FIELDS.length) * 100))}%"></span>
</span>
${c.status ? `<span class="cs-progress-badge">${escHtml(c.status)}</span>` : ''}
</div>
`).join('')}
</div>` : ''}
`;
}
},
done() { ov.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 ──────────────────────────────────────────
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 = `<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) {
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 `<button class="cs-source-mark" type="button" data-page="${escHtml(String(src.page || ''))}" title="${escHtml(src.quote || 'Source')}">${n}</button>`;
}
function csField(label, value, sheet, field) {
if (!value) return '';
return `<div class="cs-row"><dt>${label}${sheet && field ? csSourceBadgeHtml(sheet, field) : ''}</dt><dd>${escHtml(String(value))}</dd></div>`;
}
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 csCardHtml(s) {
const inv = (s.inventory || []).filter(Boolean);
const identityHtml = [
csField('Full name', s.full_name, s, 'full_name'),
csField('Title', s.title, s, 'title'),
csField('First / Last', [s.first_name, s.last_name].filter(Boolean).join(' '), s, 'full_name'),
].join('');
const invHtml = inv.length
? `<div class="cs-row"><dt>Signature Items</dt><dd><ul>${inv.map(i => `<li>${escHtml(i)}</li>`).join('')}</ul></dd></div>`
: '';
const attrs = (s.attribute_high || s.attribute_low)
? `<div class="cs-row"><dt>Core Attributes</dt><dd>▲ ${escHtml(s.attribute_high || '—')} &nbsp;·&nbsp; ▼ ${escHtml(s.attribute_low || '—')}</dd></div>`
: '';
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
const srcHtml = sources.length
? `<div class="cs-sources"><div class="cs-sources-title"><span class="mdi mdi-book-open-page-variant-outline"></span> Quellen / Evidence</div><div class="cs-source-list">${
sources.map((x, i) =>
`<button class="cs-source-link" type="button" data-page="${escHtml(String(x.page || ''))}" title="${escHtml(x.quote || '')}">
<b>${i + 1}</b>${x.line_hint ? `<span>${escHtml(x.line_hint)}</span>` : ''}${x.page != null ? `<em>p.${escHtml(String(x.page))}</em>` : ''}
</button>`
).join('')
}</div></div>`
: '';
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}">
<div class="cs-card-top">
${csAvatarHtml(s)}
<div class="cs-card-top-body">
<div class="cs-head">
<span class="cs-name">${escHtml(s.name)}</span>
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
<span class="cs-tier">${s.tier === 'main' ? 'Main' : 'Supporting'}</span>
<div class="cs-head-btns">
<button class="btn-secondary btn-sm cs-img-btn" data-name="${escHtml(s.name)}" title="Copy image-gen prompt for ComfyUI / SD"><span class="mdi mdi-image-outline"></span> Img prompt</button>
<button class="btn-secondary btn-sm cs-voice-btn" data-name="${escHtml(s.name)}" title="Copy voice design prompt"><span class="mdi mdi-account-voice"></span> Voice prompt</button>
<button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}" title="5-area psychological deep analysis"><span class="mdi mdi-brain"></span> Deep</button>
</div>
</div>
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
</div>
</div>
<dl class="cs-fields">
${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 ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice &amp; Speech${csSourceBadgeHtml(s, 'voice_pattern')}</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''}
${csField('Voice Design Prompt', s.voice_design_prompt, s, 'voice_pattern')}
${csField('Image Prompt', s.image_prompt, s, 'physical')}
${csField('Dark Secret / Fatal Flaw', s.secret, s, 'secret')}
${csField('Conflict Style', s.conflict_style, s, 'conflict_style')}
${csField('Win Condition', s.win_condition, s, 'win_condition')}
</dl>
${srcHtml}
</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 += `*aka ${s.aliases}*\n`;
if (s.full_name) md += `- **Full name:** ${s.full_name}\n`;
if (s.title) md += `- **Title:** ${s.title}\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('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();
}
async function csShow(sheets, title, sourceText, book) {
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 ov = document.createElement('div');
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
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>` + list.map(csCardHtml).join('')
: '';
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">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
</div>`;
document.body.appendChild(ov);
ov.querySelector('#cs-close').addEventListener('click', () => ov.remove());
ov.querySelector('#cs-copy').addEventListener('click', () => {
navigator.clipboard?.writeText(csToMarkdown(sheets))
.then(() => toast('Copied as Markdown', 'success'), () => toast('Copy failed', 'error'));
});
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
// Wire avatar upload (click on avatar → file picker → save to library)
const bkCtx = book || title || '';
ov.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);
// Update in-memory sheet and re-render avatar
const s = sheets.find(x => x.name === name);
if (s) s._image = dataUrl;
const card = ov.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();
});
});
// Wire Deep Analysis buttons
ov.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);
});
});
// Wire Image prompt buttons
ov.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'));
});
});
// Wire Voice prompt buttons
ov.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'));
});
});
ov.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');
});
});
}
// ── 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');
}
// 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 (_cs.cache[key]) { csGoToLibrary(); return; }
const knownRoster = csKnownReaderRoster();
const sheets = await csGenerate(text, key, knownRoster);
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
if (ab?.roster) {
const counts = new Map();
ab.roster.forEach(function (info, name) { counts.set(String(name).trim().toLowerCase(), info.count || 0); });
csAttachLineCounts(sheets, counts);
}
await csSaveToLibrary(book, sheets);
csGoToLibrary();
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
}
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 (_cs.cache[key]) { csGoToLibrary(); return; }
const sheets = await csGenerate(text, key);
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
if (window.rehState?.lines?.length) {
const counts = new Map();
rehState.lines.forEach(function (l) {
if (l.type !== 'dialog' || !l.speaker) return;
const k = String(l.speaker).trim().toLowerCase();
counts.set(k, (counts.get(k) || 0) + 1);
});
csAttachLineCounts(sheets, counts);
}
await csSaveToLibrary(book, sheets);
csGoToLibrary();
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
}
window.csForReader = csForReader;
window.csForRehearser = csForRehearser;
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser);