The per-word <span> wrapping behind "click any word to assign" created ~100k DOM nodes at book scale and froze the tab on every feed redraw; replaced with native caretRangeFromPoint word detection plus a single reused hover overlay — same UX, zero extra DOM. Export button gained a 2s re-entry guard (queued clicks during a freeze fired as a download burst) and now delivers one zip: the cast script in Markdown plus a sheet per character. Character detail view gains a Generation Prompts section — four fold-out copy boxes (Voice Design, Character Image, SillyTavern card, Concept Art sheet) filled by one LLM call over the full profile via the new /api/character-generate-prompts endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
779 lines
38 KiB
JavaScript
779 lines
38 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', '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: '', 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;
|
|
(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 = `<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" style="margin-bottom:4px"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
|
|
<div class="cs-progress-chars" id="cs-progress-chars"></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.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 = `
|
|
<div class="cs-progress-summary">${escHtml(charLabel || '')}</div>
|
|
${list.length ? `<div class="cs-progress-list">
|
|
${list.map(c => `
|
|
<div class="cs-progress-chip ${c.status ? 'is-' + escHtml(c.status) : ''}">
|
|
<span class="cs-progress-dot">${escHtml((c.name || '?')[0].toUpperCase())}</span>
|
|
<span class="cs-progress-meta">
|
|
<span class="cs-progress-name">${escHtml(c.name || 'Unknown')}</span>
|
|
${c.alias ? `<span class="cs-progress-alias">${escHtml(c.alias)}</span>` : ''}
|
|
</span>
|
|
<span class="cs-progress-fillmeta" title="${escHtml(String(c.filled || 0))} profile fields filled">
|
|
<span style="width:${Math.min(100, Math.round(((c.filled || 0) / CS_DETAIL_FIELDS.length) * 100))}%"></span>
|
|
</span>
|
|
${c.status ? `<span class="cs-progress-badge">${escHtml(c.status)}</span>` : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>` : ''}
|
|
`;
|
|
}
|
|
},
|
|
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 `<div class="cs-alignment-wrap">
|
|
<div class="cs-alignment-labels"><span>Evil</span><span>Good</span></div>
|
|
<div class="cs-alignment-gradient" title="${label} (${pct}/100)${arcNote ? ' — ' + arcNote : ''}">
|
|
<div class="cs-alignment-dot" style="left:${pct}%">
|
|
${showArrow ? `<span class="cs-alignment-arc-arrow" style="color:${arc.color}" title="${escHtml(arc.label)}">${arc.arrow}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
${arcNote ? `<div class="cs-arc-note">${escHtml(arc.label)} · <em>${escHtml(arcNote)}</em></div>` : `<div class="cs-arc-note" style="color:${arc.color}">${escHtml(arc.label)}</div>`}
|
|
</div>`;
|
|
}
|
|
|
|
// ── 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 = `<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 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 `<button class="cs-source-mark" type="button" data-page="${escHtml(String(src.page || ''))}" title="${escHtml(src.quote || 'Source')}">${n}</button>`;
|
|
}
|
|
|
|
function csField(label, value, sheet, field) {
|
|
if (!value) return '';
|
|
return `<div class="cs-row"><dt>${label}${sheet && field ? csSourceBadgeHtml(sheet, field) : ''}</dt><dd>${escHtml(String(value))}</dd></div>`;
|
|
}
|
|
|
|
function csAvatarHtml(s) {
|
|
const hue = Math.abs((s.name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0));
|
|
if (s._image) {
|
|
return `<div class="cs-avatar cs-avatar-img" data-name="${escHtml(s.name)}" title="Click to change profile picture">
|
|
<img src="${s._image}" alt="${escHtml(s.name)}">
|
|
<div class="cs-avatar-overlay"><span class="mdi mdi-camera"></span></div>
|
|
</div>`;
|
|
}
|
|
return `<div class="cs-avatar cs-avatar-letter" style="background:hsl(${hue},50%,35%)" data-name="${escHtml(s.name)}" title="Click to upload profile picture">
|
|
<span>${escHtml((s.name || '?')[0].toUpperCase())}</span>
|
|
<div class="cs-avatar-overlay"><span class="mdi mdi-camera"></span></div>
|
|
</div>`;
|
|
}
|
|
|
|
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
|
|
? `<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 || '—')} · ▼ ${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"><div class="cs-sources-title"><span class="mdi mdi-book-open-page-variant-outline"></span> Quellen / Evidence</div><div class="cs-source-list">${
|
|
sources.map((x, i) =>
|
|
`<button class="cs-source-link" type="button" data-page="${escHtml(String(x.page || ''))}" title="${escHtml(x.quote || '')}">
|
|
<b>${i + 1}</b>${x.line_hint ? `<span>${escHtml(x.line_hint)}</span>` : ''}${x.page != null ? `<em>p.${escHtml(String(x.page))}</em>` : ''}
|
|
</button>`
|
|
).join('')
|
|
}</div></div>`
|
|
: '';
|
|
|
|
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}">
|
|
<div class="cs-card-top">
|
|
${csAvatarHtml(s)}
|
|
<div class="cs-card-top-body">
|
|
<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 class="cs-head-btns">
|
|
<button class="btn-secondary btn-sm cs-img-btn" data-name="${escHtml(s.name)}" title="Copy image-gen prompt for ComfyUI / SD"><span class="mdi mdi-image-outline"></span> Img prompt</button>
|
|
<button class="btn-secondary btn-sm cs-voice-btn" data-name="${escHtml(s.name)}" title="Copy voice design prompt"><span class="mdi mdi-account-voice"></span> Voice prompt</button>
|
|
<button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}" title="5-area psychological deep analysis"><span class="mdi mdi-brain"></span> Deep</button>
|
|
</div>
|
|
</div>
|
|
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
|
|
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
|
|
</div>
|
|
</div>
|
|
<dl class="cs-fields">
|
|
${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 ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice & Speech${csSourceBadgeHtml(s, 'voice_pattern')}</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''}
|
|
${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')}
|
|
</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`;
|
|
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
|
|
? `<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 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');
|
|
}
|
|
|
|
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; }
|
|
await csSaveToLibrary(book, sheets);
|
|
csGoToLibrary();
|
|
toast(sheets.length + ' character sheets saved — 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; }
|
|
await csSaveToLibrary(book, sheets);
|
|
csGoToLibrary();
|
|
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
|
|
}
|
|
|
|
window.csForReader = csForReader;
|
|
window.csForRehearser = csForRehearser;
|
|
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
|
|
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser);
|