tts-voice-creator-clone-and.../static/js/character-sheets.js
mARTin-B78 e62abbcd1d Fix character-sheet LLM URL + audiobook preview; Stage font controls (v1.9.1)
- Character sheets: stop sending the dead localhost:11434 default; fall back to
  the server's configured llm_url so extraction uses the same working LLM.
- Audiobook casting: hoist highlightText to module scope so the "Review & cast"
  manual-correction preview renders its segment rows again.
- Stage: unify narrator/dialog font size, add A-/A+ play text-size control
  (scales A4 + paginated views), and make the cast chip list collapsible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:44:30 +02:00

407 lines
20 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', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
'attribute_high', 'attribute_low', 'skills', 'capabilities',
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
'secret', 'conflict_style', 'win_condition'];
function csExistingSummary(map) {
if (!map.size) return '';
return [...map.values()].slice(0, 30).map(s => {
const missing = CS_SCALAR_FIELDS.filter(f => !(s[f] || '').trim());
return `- ${s.name}${s.aliases ? ` (${s.aliases})` : ''}${s.archetype ? `${s.archetype}` : ''}`
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
}).join('\n');
}
function csMerge(map, sheets) {
for (const s of sheets) {
const name = (s.name || '').trim(); if (!name) continue;
const key = name.toLowerCase();
if (!map.has(key)) {
map.set(key, { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] });
continue;
}
const e = map.get(key);
CS_SCALAR_FIELDS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
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;
(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 < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
});
}
}
async function csGenerate(text, cacheKey) {
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(), language = csLang();
const map = new Map();
const roster = [];
try {
for (let i = 0; i < chunks.length; i++) {
if (_cs.cancel) break;
prog.update(i, `Reading characters · passage ${i + 1} / ${chunks.length}`);
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(-40), existing: csExistingSummary(map), language, llm_url, model }),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const data = await r.json();
csMerge(map, data.sheets || []);
(data.characters || []).forEach(n => { if (!roster.includes(n)) roster.push(n); });
} catch (e) {
toast('Passage ' + (i + 1) + ' failed: ' + (e.message || 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"><div class="reader-synth-fill" id="cs-progress-fill"></div></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.hidden = false;
const fill = ov.querySelector('#cs-progress-fill'), msg = ov.querySelector('#cs-progress-msg');
return {
update(d, label) { if (fill) fill.style.width = (d / total * 100) + '%'; if (msg && label) msg.textContent = label; },
done() { ov.hidden = true; },
};
}
// ── Alignment bar ─────────────────────────────────────────────────────────────
function csAlignmentBar(score, arcDirection, arcNote) {
const pct = Math.max(0, Math.min(100, score ?? 50));
const arcMap = {
'stable-good': { arrow: '→', label: 'Stable Good', color: '#c8e6c9' },
'stable-bad': { arrow: '→', label: 'Stable Evil', color: '#777' },
'neutral': { arrow: '→', label: 'Neutral', color: '#aaa' },
'good-to-bad': { arrow: '↘', label: 'Descends', color: '#ff7043' },
'bad-to-good': { arrow: '↗', label: 'Redeems', 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';
return `<div class="cs-alignment-wrap" title="${escHtml(arcNote || '')}">
<div class="cs-alignment-labels"><span>◼ Evil</span><span>Good ◻</span></div>
<div class="cs-alignment-bar">
<div class="cs-alignment-track"></div>
<div class="cs-alignment-marker" style="left:${pct}%" title="${pct}/100 — ${label}"></div>
</div>
<div class="cs-arc-row">
<span class="cs-arc-arrow" style="color:${arc.color}" title="${escHtml(arc.label)}">${arc.arrow}</span>
<span class="cs-arc-label" style="color:${arc.color}">${escHtml(arc.label)}</span>
${arcNote ? `<span class="cs-arc-note">${escHtml(arcNote)}</span>` : ''}
</div>
</div>`;
}
// ── 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 csField(label, value) {
if (!value) return '';
return `<div class="cs-row"><dt>${label}</dt><dd>${escHtml(String(value))}</dd></div>`;
}
function csCardHtml(s) {
const inv = (s.inventory || []).filter(Boolean);
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"><span class="mdi mdi-book-open-page-variant-outline"></span> ${
sources.map(x =>
`${x.line_hint ? `<span class="cs-src-hint">[${escHtml(x.line_hint)}]</span> ` : ''}` +
`${x.page != null ? `<b>p.${escHtml(String(x.page))}</b> ` : ''}` +
`${x.quote ? `"${escHtml(x.quote)}"` : ''}`
).join(' · ')
}</div>`
: '';
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}">
<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>
<button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}"
title="5-area psychological deep analysis"
style="margin-left:auto;font-size:11px;padding:2px 8px;">
<span class="mdi mdi-brain"></span> Deep Analysis
</button>
</div>
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
<dl class="cs-fields">
${csField('Physical', s.physical)}
${csField('Clothing & Appearance', s.clothing)}
${csField('Alignment & Ethos', s.alignment)}
${attrs}
${csField('Trained Skills', s.skills)}
${csField('Capabilities', s.capabilities)}
${invHtml}
${csField('Backstory & Origin', s.backstory)}
${csField('Relationships', s.relationships)}
${csField('Motivation', s.motivation)}
${csField('Fears', s.fears)}
${csField('Mannerisms & Habits', s.mannerisms)}
${s.voice_pattern ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice &amp; Speech</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''}
${csField('Dark Secret / Fatal Flaw', s.secret)}
${csField('Conflict Style', s.conflict_style)}
${csField('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`;
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('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 csShow(sheets, title, sourceText) {
document.getElementById('cs-overlay')?.remove();
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 Deep Analysis buttons
ov.querySelectorAll('.cs-deep-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const name = btn.dataset.name;
const sheet = sheets.find(s => s.name === name);
if (sheet) csDeepAnalysis(sheet, sourceText);
});
});
}
// ── 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 */ }
}
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]) { csShow(_cs.cache[key], readerState.title || 'Character sheets', text); return; }
const sheets = await csGenerate(text, key);
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
csSaveToLibrary(book, sheets);
csShow(sheets, readerState.title || 'Character sheets', text);
}
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]) { csShow(_cs.cache[key], title, text); return; }
const sheets = await csGenerate(text, key);
if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; }
csSaveToLibrary(book, sheets);
csShow(sheets, title, text);
}
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser);