// ── 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 = `
Character sheets
Analysing…
`; 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 = `
${escHtml(charLabel || '')}
${list.length ? `
${list.map(c => `
${escHtml((c.name || '?')[0].toUpperCase())} ${escHtml(c.name || 'Unknown')} ${c.alias ? `${escHtml(c.alias)}` : ''} ${c.status ? `${escHtml(c.status)}` : ''}
`).join('')}
` : ''} `; } }, 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 `
EvilGood
${showArrow ? `${arc.arrow}` : ''}
${arcNote ? `
${escHtml(arc.label)} · ${escHtml(arcNote)}
` : `
${escHtml(arc.label)}
`}
`; } // ── 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 = `
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 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 ``; } function csField(label, value, sheet, field) { if (!value) return ''; return `
${label}${sheet && field ? csSourceBadgeHtml(sheet, field) : ''}
${escHtml(String(value))}
`; } function csAvatarHtml(s) { const hue = Math.abs((s.name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0)); if (s._image) { return `
${escHtml(s.name)}
`; } return `
${escHtml((s.name || '?')[0].toUpperCase())}
`; } 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 ? `
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 ? `
Quellen / Evidence
${ sources.map((x, i) => `` ).join('') }
` : ''; return `
${csAvatarHtml(s)}
${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)}
${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 ? `
Voice & Speech${csSourceBadgeHtml(s, 'voice_pattern')}
${escHtml(String(s.voice_pattern))}
` : ''} ${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')}
${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`; 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 ? `
${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 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'); } // Refresh sheet data for a hand-picked subset of an already-cast book's // characters, leaving everyone else's saved sheet untouched. Extraction is // still passage-by-passage over the whole book (any page might mention any // character), so this takes as long as a full recast — it just discards the // results for characters the user didn't pick, rather than skipping work. async function csForReaderSelective(selectedNames) { const wanted = new Set((selectedNames || []).map(n => String(n).trim().toLowerCase())); if (!wanted.size) { toast('No characters selected', 'error'); return; } const text = csReaderText(); const book = readerState.title || 'Untitled book'; const knownRoster = csKnownReaderRoster(); const sheets = await csGenerate(text, null, knownRoster); if (!sheets) return; const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase())); if (!filtered.length) { toast('None of the selected characters turned up in this pass — try again or pick different ones', '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(filtered, counts); } await csSaveToLibrary(book, filtered); csGoToLibrary(); toast(filtered.length + ' character' + (filtered.length !== 1 ? 's' : '') + ' recast — 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.csForReaderSelective = csForReaderSelective; window.csForRehearser = csForRehearser; $('reader-charsheets-btn')?.addEventListener('click', csForReader); $('reh-charsheets-btn')?.addEventListener('click', csForRehearser);