// ── 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 = `