// ── 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: {} }; 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) ─────────────────────────────── 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 = `
Character sheets
Analysing…
`; 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 `
◼ EvilGood ◻
${arc.arrow} ${escHtml(arc.label)} ${arcNote ? `${escHtml(arcNote)}` : ''}
`; } // ── 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 = `
${escHtml(sheet.name)} ${sheet.archetype ? `${escHtml(sheet.archetype)}` : ''}
${csAlignmentBar(sheet.moral_alignment_score, sheet.arc_direction, sheet.arc_note)}
${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 csField(label, value) { if (!value) return ''; return `
${label}
${escHtml(String(value))}
`; } function csCardHtml(s) { const inv = (s.inventory || []).filter(Boolean); const invHtml = inv.length ? `
Signature Items
` : ''; 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.map(x => `${x.line_hint ? `[${escHtml(x.line_hint)}] ` : ''}` + `${x.page != null ? `p.${escHtml(String(x.page))} ` : ''}` + `${x.quote ? `"${escHtml(x.quote)}"` : ''}` ).join(' · ') }
` : ''; return `
${escHtml(s.name)} ${s.archetype ? `${escHtml(s.archetype)}` : ''} ${s.tier === 'main' ? 'Main' : 'Supporting'}
${s.aliases ? `
aka ${escHtml(s.aliases)}
` : ''} ${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
${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 ? `
Voice & Speech
${escHtml(String(s.voice_pattern))}
` : ''} ${csField('Dark Secret / Fatal Flaw', s.secret)} ${csField('Conflict Style', s.conflict_style)} ${csField('Win Condition', s.win_condition)}
${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 += `*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 ? `
${label}
` + list.map(csCardHtml).join('') : ''; 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.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);