Read Aloud (new "Vorlesen" tab):
- PDF (real page render + overlay highlight) / TXT reader with live word
highlighting, voice + speed, per-sentence synthesis-state colours, zoom
(fit-width/height, two-page, ±), resume, and a server-side book library
(syncs across devices; per-unit MP3 audio fetched on demand).
Book -> multi-speaker audiobook:
- "Cast as audiobook" attributes dialogue to characters via the LLM
(guillemet/quote-style aware, turn-taking, recent-context), with a
deterministic speech-tag fallback. Editable preview, non-blocking live
casting panel, then auto-saved as a reopenable Script Rehearser play.
- Audiobook export: synthesise every cast line -> one MP3 per chapter.
Character sheets:
- LLM-extracted, self-filling RPG-style sheets (with page+quote sources)
in both Read Aloud and the Rehearser.
Also: MP3 storage + per-page/sentence export, voice-library "Precompute
embeddings" pre-warm, German "Vorlesen" i18n + flag language toggle,
large-PDF performance (lazy raster, buffer/canvas eviction, yielded parse),
and the Seed Finder changelog entry.
New: routes/reader.py, POST /api/attribute-dialogue, POST /api/character-sheets,
static/js/{reader,audiobook,character-sheets}.js, static/sections/s-reader.html.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
233 lines
12 KiB
JavaScript
233 lines
12 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). Shared by both sections via one overlay.
|
|
//
|
|
// Reuses: /api/character-sheets (LLM), readerState/readerScopeIndices (reader.js),
|
|
// rehState/stripMarkdown/rehDefaultLlmUrl (rehearser.js), splitTextIntoChunks
|
|
// (generation.js), $ / escHtml / toast (utils.js).
|
|
|
|
const CS_CHUNK_CHARS = 4000;
|
|
const _cs = { running: false, cancel: false, cache: {} };
|
|
|
|
function csLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
|
|
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) ───────────────────────────────
|
|
|
|
// Compact summary of the sheets built so far → tells the LLM what's known and
|
|
// which fields each character still needs, so it fills gaps instead of restarting.
|
|
function csExistingSummary(map) {
|
|
if (!map.size) return '';
|
|
const FIELDS = ['archetype', 'physical', 'alignment', 'attribute_high', 'attribute_low', 'skills', 'secret', 'conflict_style', 'win_condition'];
|
|
return [...map.values()].slice(0, 30).map(s => {
|
|
const missing = 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) {
|
|
const SCALARS = ['aliases', 'archetype', 'physical', 'alignment', 'attribute_high', 'attribute_low', 'skills', 'secret', 'conflict_style', 'win_condition'];
|
|
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);
|
|
SCALARS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
|
|
if (s.tier === 'main') e.tier = 'main';
|
|
(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; },
|
|
};
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
|
|
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 Inventory</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 || '—')} · ▼ ${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.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'}">
|
|
<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>
|
|
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
|
|
<dl class="cs-fields">
|
|
${csField('Physical', s.physical)}
|
|
${csField('Alignment & Ethos', s.alignment)}
|
|
${attrs}
|
|
${csField('Trained Skills', s.skills)}
|
|
${invHtml}
|
|
${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 f = (l, v) => v ? `- **${l}:** ${v}\n` : '';
|
|
md += f('Physical', s.physical) + 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('Signature Inventory', (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.page != null ? 'p.' + x.page + ' ' : ''}“${x.quote}”`).join('; ');
|
|
if (src) md += `- *Sources:* ${src}\n`;
|
|
}
|
|
}
|
|
return md.trim();
|
|
}
|
|
|
|
function csShow(sheets, title) {
|
|
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(); });
|
|
}
|
|
|
|
// ── Entry points (reader + rehearser) ────────────────────────────────────────
|
|
|
|
async function csForReader() {
|
|
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
|
|
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets'); return; }
|
|
const sheets = await csGenerate(csReaderText(), key);
|
|
if (!sheets) return;
|
|
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
|
csShow(sheets, readerState.title || 'Character sheets');
|
|
}
|
|
|
|
async function csForRehearser() {
|
|
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
|
|
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
|
|
if (_cs.cache[key]) { csShow(_cs.cache[key], title); return; }
|
|
const sheets = await csGenerate(csRehearserText(), key);
|
|
if (!sheets) return;
|
|
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
|
csShow(sheets, title);
|
|
}
|
|
|
|
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
|
|
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser);
|