// ── Library Characters / Cast workspace ───────────────────────────────────── // Loaded after library.js; uses its shared production helpers and exports // libraryRenderCharacters for the Library section router. // ── Characters / Cast ───────────────────────────────────────────────────────── async function libraryRenderCharacters() { const container = document.getElementById('lib-chars-list'); if (!container) return; container.innerHTML = '
Loading characters…
'; let all = []; try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } catch (_) { all = []; } const byId = new Map(all.map(function (rec) { return [rec.id, rec]; })); if (!all.length) { container.innerHTML = '
' + '' + '
' + '
' + '' + '

No characters yet.

' + '

Open a book in Read Aloud, cast it as an audiobook, then click Cast Characters to generate character sheets — or import an existing cast from SillyTavern.

' + '
'; container.querySelector('#lib-chars-import').addEventListener('click', function () { if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); }); }); return; } // Group by book (production) const byBook = {}; all.forEach(function (rec) { const bk = rec.book || 'Unsorted'; if (!byBook[bk]) byBook[bk] = []; byBook[bk].push(rec); }); container.innerHTML = ''; // Global toolbar — import a cast from SillyTavern into a new/unsorted production, // a Cards/Table view toggle, and a Sort dropdown (all persisted, like Script // Rehearser's cast-list controls). const viewMode = localStorage.getItem('ttsvc_libchars_view') === 'table' ? 'table' : 'cards'; const SORT_OPTIONS = [ ['tier', 'Rolle (Haupt zuerst)'], ['alpha', 'Alphabet'], ['lines', 'Anzahl Zeilen'], ['gender', 'Geschlecht'], ['voice', 'Stimme zugewiesen'], ]; const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); }) ? localStorage.getItem('ttsvc_libchars_sort') : 'tier'; const bar = document.createElement('div'); bar.className = 'lib-chars-toolbar'; bar.innerHTML = '' + '' + '
' + '' + '' + '
'; bar.querySelector('#lib-chars-import').addEventListener('click', function () { if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); }); }); bar.querySelector('#lib-chars-sort-sel').addEventListener('change', function () { localStorage.setItem('ttsvc_libchars_sort', this.value); libraryRenderCharacters(); }); bar.querySelectorAll('.lib-chars-view-toggle button').forEach(function (btn) { btn.addEventListener('click', function () { localStorage.setItem('ttsvc_libchars_view', btn.dataset.view); libraryRenderCharacters(); }); }); container.appendChild(bar); const _charSortCmp = { tier: function (a, b) { const tierOrder = { main: 0, supporting: 1, minor: 2 }; const ta = tierOrder[String(a.sheet?.tier || 'minor').toLowerCase()] ?? 2; const tb = tierOrder[String(b.sheet?.tier || 'minor').toLowerCase()] ?? 2; return ta - tb || (a.name || '').localeCompare(b.name || ''); }, alpha: function (a, b) { return (a.name || '').localeCompare(b.name || ''); }, lines: function (a, b) { return (b.sheet?.line_count || 0) - (a.sheet?.line_count || 0) || (a.name || '').localeCompare(b.name || ''); }, gender: function (a, b) { const ga = String(a.sheet?.gender || 'zzz'), gb = String(b.sheet?.gender || 'zzz'); return ga.localeCompare(gb) || (a.name || '').localeCompare(b.name || ''); }, voice: function (a, b) { return (b.voice ? 1 : 0) - (a.voice ? 1 : 0) || (a.name || '').localeCompare(b.name || ''); }, }; const productions = document.createDocumentFragment(); Object.keys(byBook).sort().forEach(function (book) { const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier); const cov = libBookCover(book); const prod = document.createElement('div'); prod.className = 'lib-chars-production'; prod.innerHTML = '
' + '
' + escHtml(book) + '
' + '
' + '' + '' + '' + '' + '' + '' + '
' + (viewMode === 'table' ? _charsTableHtml(chars) : '
' + chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('') + '
'); // Action buttons prod.querySelector('.lib-chars-casting-btn').addEventListener('click', function () { // Back to the casting script. If a casting session is active, navTo's // synchronous cast-restore (nav.js) reopens it directly; otherwise this // lands on the reader so the book can be opened and cast from there. if (typeof navTo === 'function') navTo('s-reader'); }); prod.querySelector('.lib-chars-cast-btn').addEventListener('click', function () { if (typeof productionOpenInReader === 'function') productionOpenInReader(book); toast('Open the book in Read Aloud then click Cast Characters', 'info'); }); prod.querySelector('.lib-chars-reh-btn').addEventListener('click', function () { if (typeof productionOpenInRehearser === 'function') productionOpenInRehearser(book); }); prod.querySelector('.lib-chars-read-btn').addEventListener('click', function () { if (typeof productionOpenInReader === 'function') productionOpenInReader(book); }); prod.querySelector('.lib-chars-imp-btn').addEventListener('click', function () { if (typeof stImportDialog === 'function') stImportDialog(book, function () { libraryRenderCharacters(); }); }); const bulkBtn = prod.querySelector('.lib-chars-bulk-voice-btn'); const bulkCount = prod.querySelector('.lib-chars-bulk-count'); const refreshBulkBtn = function () { const n = prod.querySelectorAll('.lib-char-select-cb:checked').length; bulkCount.textContent = n; bulkBtn.disabled = n === 0; }; prod.querySelectorAll('.lib-char-select-cb').forEach(function (cb) { cb.addEventListener('change', refreshBulkBtn); }); bulkBtn.addEventListener('click', async function () { const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; }); if (!ids.length) return; bulkBtn.disabled = true; const origLabel = bulkBtn.innerHTML; let done = 0; for (const id of ids) { const rec = byId.get(id); if (!rec) continue; bulkBtn.innerHTML = ' Assigning ' + (++done) + ' / ' + ids.length + '…'; // Sequential, not parallel: _autoAssignVoice avoids re-using a voice // already taken by another character in the same book by checking // what's assigned so far — running these concurrently would have // every call see the same "nothing assigned yet" snapshot and could // hand out the same voice to several selected characters at once. try { await _autoAssignVoice(rec); } catch (_) {} } bulkBtn.innerHTML = origLabel; toast(`Voice assigned to ${done} character${done !== 1 ? 's' : ''}`, 'success'); libraryRenderCharacters(); }); // Wire voice selectors and auto-assign buttons prod.querySelectorAll('.lib-char-card').forEach(function (card) { const charId = card.dataset.charId; const rec = byId.get(charId); if (!rec) return; // Click on card body → open detail page (not if clicking a button or avatar) card.addEventListener('click', function (e) { if (e.target.closest('button, .lib-char-avatar, .lib-voice-picker-popup')) return; _charDetailPage(rec, chars); }); // Avatar click → upload profile picture card.querySelector('.lib-char-avatar')?.addEventListener('click', function (e) { e.stopPropagation(); const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.onchange = async function () { const file = inp.files[0]; if (!file) return; const fr = new FileReader(); fr.onload = async function (ev) { if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result); toast('Profile picture saved', 'success'); libraryRenderCharacters(); }; fr.readAsDataURL(file); }; inp.click(); }); card.querySelector('.lib-char-pick-voice')?.addEventListener('click', function (e) { e.stopPropagation(); _openVoicePicker(card, rec, function () { libraryRenderCharacters(); }); }); card.querySelector('.lib-char-auto-voice')?.addEventListener('click', async function (e) { e.stopPropagation(); await _autoAssignVoice(rec); libraryRenderCharacters(); }); card.querySelector('.lib-char-export')?.addEventListener('click', function (e) { e.stopPropagation(); if (typeof stExportRecord === 'function') stExportRecord(rec); }); card.querySelector('.lib-char-online-voice')?.addEventListener('click', function (e) { e.stopPropagation(); _charSearchOnline(rec); }); card.querySelector('.lib-char-gen-voice')?.addEventListener('click', function (e) { e.stopPropagation(); _charDesignVoice(rec); }); }); productions.appendChild(prod); }); container.appendChild(productions); } function _charHue(name) { return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0)); } function _charAlignHtml(sh) { const score = sh.moral_alignment_score; if (score == null) return ''; const pct = Math.max(0, Math.min(100, score)); const arc = sh.arc_direction || 'neutral'; const arrowMap = { 'good-to-bad': { ch: '↘', color: '#ff7043', tip: 'Arc: Descends toward evil' }, 'bad-to-good': { ch: '↗', color: '#66bb6a', tip: 'Arc: Redeems toward good' }, 'complex': { ch: '↕', color: '#ab47bc', tip: 'Arc: Complex / unpredictable' }, 'stable-good': { ch: '→', color: '#66bb6a', tip: 'Arc: Stable good' }, 'stable-bad': { ch: '→', color: '#888', tip: 'Arc: Stable evil' }, 'neutral': { ch: '→', color: '#aaa', tip: 'Arc: Neutral' }, }; const a = arrowMap[arc] || arrowMap['neutral']; const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Morally ambiguous'; return '
' + '' + '
' + '
' + '
' + '' + '' + a.ch + '' + '
'; } function _libStr(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 _charRelsHtml(rec, allChars) { if (!allChars || allChars.length < 2) return ''; const relText = _libStr(rec.sheet?.relationships).toLowerCase(); if (!relText) return ''; const hits = allChars .filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; }) .map(function (c) { const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); return { c: c, n: (relText.match(re) || []).length }; }) .filter(function (x) { return x.n > 0; }) .sort(function (a, b) { return b.n - a.n; }) .slice(0, 5); if (!hits.length) return ''; return '
' + hits.map(function (x) { const h = _charHue(x.c.name); return '' + escHtml((x.c.name || '?')[0].toUpperCase()) + ''; }).join('') + '
'; } function _charCardHtml(rec, allChars) { const sh = rec.sheet || {}; const hue = _charHue(rec.name); const hue2 = (hue + 40) % 360; const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; const voiceLabel = voiceId ? escHtml(voiceId) : 'Keine Stimme'; const tier = String(sh.tier || '').toLowerCase(); const tierBadge = tier === 'main' ? 'Haupt' : tier === 'supporting' ? 'Neben' : ''; const gender = String(sh.gender || '').toLowerCase(); const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary'; const snippet = _libStr(sh.mannerisms || sh.voice_pattern || sh.motivation || sh.backstory || '').slice(0, 140); // Every character is auto-tagged with the book it was found in (see // clUpsert/clMergeTags) — surfacing the chips here is how you tell a // character recurring across several books apart from a same-named one // that's book-local, since tags (not the single `book` field) are what // carries multi-book membership. const tagList = String(rec.tags || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean); const tagsHtml = tagList.length ? '
' + tagList.map(function (t) { return '' + escHtml(t) + ''; }).join('') + '
' : ''; const avatarInner = rec.image ? '' + escHtml(rec.name) + '' : escHtml((rec.name || '?')[0].toUpperCase()); return '
' + '' + '
' + '
' + avatarInner + '
' + '' + '
' + '
' + '
' + tierBadge + escHtml(rec.name) + '
' + (_libStr(sh.title) ? '
' + escHtml(_libStr(sh.title)) + '
' : '') + (_libStr(sh.aliases) ? '
aka ' + escHtml(_libStr(sh.aliases)) + '
' : '') + (sh.archetype ? '
' + escHtml(_libStr(sh.archetype)) + '
' : '') + (snippet ? '
' + escHtml(snippet) + '
' : '') + tagsHtml + _charAlignHtml(sh) + _charRelsHtml(rec, allChars) + '
' + '
' + '' + voiceLabel + '' + '' + '' + '
' + '
'; } // Compact alternative to the card grid for scanning a large cast at once — // one row per character with the fields that matter for casting decisions. // Rows carry the "lib-char-card" class too so they're picked up by the same // avatar/voice-picker/auto-voice/export wiring the card grid already uses; // only the HTML shape differs. function _charsTableHtml(chars) { const rows = chars.map(function (rec) { const sh = rec.sheet || {}; const hue = _charHue(rec.name); const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; const voiceLang = (rec.voice && typeof rec.voice === 'object') ? (rec.voice.language || '') : ''; const tier = String(sh.tier || '').toLowerCase(); const tierBadge = tier === 'main' ? 'Haupt' : tier === 'supporting' ? 'Neben' : ''; const gender = String(sh.gender || '').toLowerCase(); const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : gender ? 'mdi-gender-non-binary' : ''; const score = sh.moral_alignment_score; const pct = score != null ? Math.max(0, Math.min(100, score)) : null; const tagList = String(rec.tags || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean); const avatarInner = rec.image ? '' + escHtml(rec.name) + '' : escHtml((rec.name || '?')[0].toUpperCase()); const hasPrompt = function (key) { return !!(sh[key] && String(sh[key]).trim()); }; const promptCell = function (key, title) { return '' + ''; }; return '' + '' + '
' + avatarInner + '
' + '' + tierBadge + escHtml(rec.name) + '' + '' + (genderIcon ? '' : '') + '' + '' + (sh.line_count != null ? sh.line_count : '') + '' + '' + (voiceLang ? escHtml(voiceLang) : '') + '' + '' + (pct != null ? '
' : '') + '' + '' + (voiceId ? escHtml(voiceId) : 'Keine Stimme') + '' + '' + tagList.map(function (t) { return '' + escHtml(t) + ''; }).join('') + '' + '' + promptCell('silly_tavern_prompt', 'SillyTavern') + '' + '' + promptCell('voice_design_prompt', 'TTS Voice') + '' + '' + promptCell('image_prompt', 'Bild') + '' + '' + ''; }).join(''); return '
' + '' + '' + '' + '' + '' + rows + '
NameZeilenSpracheGut/BöseStimmeTagsSTTTSBild
'; } // ── Character detail modal ──────────────────────────────────────────────────── function _lcdSourcesHtml(sources) { const list = Array.isArray(sources) ? sources.filter(function (s) { return s && (s.quote || s.page != null); }) : []; if (!list.length) return ''; return '
' + '
Quellen im Text
' + '
' + list.map(function (s) { const page = s.page != null ? 'Seite ' + s.page : ''; const hint = _libStr(s.line_hint || s.hint || ''); return '
' + (page || hint ? '
' + escHtml([page, hint].filter(Boolean).join(' · ')) + '
' : '') + (s.quote ? '
„' + escHtml(_libStr(s.quote)) + '"
' : '') + '
'; }).join('') + '
'; } function _lcdField(label, value, multiline) { const v = _libStr(value); if (!v) return ''; return '
' + '
' + label + '
' + '
' + (multiline ? escHtml(v) : escHtml(v)) + '
' + '
'; } function _lcdSection(icon, label, fields) { const body = fields.join(''); if (!body) return ''; return '
' + '
' + label + '
' + body + '
'; } function _lcdSectionFull(icon, label, fields) { const body = fields.join(''); if (!body) return ''; return '
' + '
' + label + '
' + body + '
'; } // A collapsible "ready to copy" box for one of the four external-tool prompts // (Voice Design / Character Image / SillyTavern / Concept Art). Closed by // default; editable in place via the same contenteditable+data-sheet-key // convention the rest of the detail view uses, so edits autosave for free. function _lcdPromptBox(label, value, sheetKey) { const has = !!(value && String(value).trim()); return '
' + '' + escHtml(label) + (has ? '' : ' — not generated yet') + '' + '
' + '
' + escHtml(value || '') + '
' + '
' + '' + '' + '
' + '
' + '
'; } // sourceIdxs (optional): indices into the character's sources list — shown // as small clickable "[N]" links next to the label so a reader who doubts a // fact can jump straight to the exact page + quote it was extracted from, // instead of scanning the whole "Quellen im Text" list at the bottom. function _lcdFieldEdit(label, value, sheetKey, sourceIdxs) { const v = _libStr(value); const links = (sourceIdxs || []).map(function (idx) { return '' + (sourceIdxs.indexOf(idx) + 1) + ''; }).join(''); return '
' + (label || links ? '
' + escHtml(label) + (links ? ' ' + links + '' : '') + '
' : '') + '
' + escHtml(v) + '
' + '
'; } function _jumpToReaderPage(pageNum) { // Always navigate to the reader section first if (typeof navTo === 'function') navTo('s-reader'); setTimeout(function () { // Try page-div scroll (PDF reader) const pages = window.readerState?.pages; if (pages && pages.length >= pageNum) { const pg = pages[pageNum - 1]; if (pg?.pageDiv) { pg.pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } } // Try sentence-index jump — find first sentence on or after target page (0-indexed internally) const sentences = window.readerState?.sentences; if (sentences && sentences.length) { const target0 = pageNum - 1; const idx = sentences.findIndex(function (s) { return (s.words || []).some(function (w) { return (w.page ?? w.para ?? 0) >= target0; }); }); if (idx >= 0 && typeof readerJumpTo === 'function') { readerJumpTo(idx); return; } } toast('Öffne das Buch in „Vorlesen" und klicke nochmal auf die Quelle', 'info'); }, 300); } // ── Character detail PAGE (full-page with inline editing, replaces the modal) ─ async function _charDetailPage(rec, allChars) { const container = document.getElementById('lib-chars-list'); if (!container) return; window._libDetailRec = rec; const sh = rec.sheet || {}; const hue = _charHue(rec.name); const hue2 = (hue + 40) % 360; const tier = String(sh.tier || '').toLowerCase(); const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : ''; const gender = _libStr(sh.gender); const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female' : gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary'; const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; const score = sh.moral_alignment_score; const pct = score != null ? Math.max(0, Math.min(100, score)) : null; const arcMap = { 'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' }, 'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' }, 'complex': { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' }, 'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' }, 'stable-bad': { ch: '→', label: 'Stabil böse', color: '#888' }, 'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' }, }; const arcInfo = arcMap[sh.arc_direction || 'neutral'] || arcMap['neutral']; const avatarHtml = rec.image ? '
' + escHtml(rec.name) + '
' : '
' + escHtml((rec.name || '?')[0].toUpperCase()) + '
'; const alignHtml = pct != null ? ( '
' + '
Moralische Gesinnung
' + '
' + 'Böse' + '' + 'Gut' + '' + pct + '/100' + '
' + '
' + arcInfo.ch + ' ' + arcInfo.label + (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)') + '
' + (_libStr(sh.alignment) ? '
' + escHtml(_libStr(sh.alignment)) + '
' : '') + '
' ) : ''; const promptsHtml = '
' + '
Generation Prompts
' + _lcdPromptBox('Voice Design Prompt', sh.voice_design_prompt, 'voice_design_prompt') + _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt') + _lcdPromptBox('SillyTavern Character Prompt', sh.silly_tavern_prompt, 'silly_tavern_prompt') + _lcdPromptBox('Concept Art Prompt', sh.concept_art_prompt, 'concept_art_prompt') + '
'; const relText = _libStr(sh.relationships).toLowerCase(); const relHits = (allChars || []) .filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; }) .map(function (c) { const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); return { c: c, n: (relText.match(re) || []).length }; }) .filter(function (x) { return x.n > 0; }) .sort(function (a, b) { return b.n - a.n; }) .slice(0, 8); const relDotsHtml = relHits.length ? ( '
' + relHits.map(function (x) { const h = _charHue(x.c.name); return '' + escHtml((x.c.name || '?')[0].toUpperCase()) + ''; }).join('') + '
' ) : ''; const sourcesList = Array.isArray(sh.sources) ? sh.sources.filter(function (s) { return s && (s.quote || s.page != null); }) : []; // Group source indices by the sheet field they were extracted for // (line_hint), so each detail field below can show "jump to this exact // citation" links instead of making the reader search the full list. const sourcesByField = {}; sourcesList.forEach(function (s, idx) { const key = _libStr(s.line_hint || s.hint || '').trim().toLowerCase(); if (!key) return; (sourcesByField[key] = sourcesByField[key] || []).push(idx); }); const sourcesHtml = sourcesList.length ? ( '
' + '
Quellen im Text' + ' · Klicken zum Springen
' + '
' + sourcesList.map(function (s, idx) { const page = s.page != null ? 'Seite ' + s.page : ''; const hint = _libStr(s.line_hint || s.hint || ''); return '
' + (page || hint ? '
' + escHtml([page, hint].filter(Boolean).join(' · ')) + '
' : '') + (s.quote ? '
„' + escHtml(_libStr(s.quote)) + '"
' : '') + '
'; }).join('') + '
' ) : ''; const sidebarChars = (allChars || []).slice().sort(function (a, b) { return (b.sheet?.sources?.length || 0) - (a.sheet?.sources?.length || 0); }); const sidebarHtml = sidebarChars.map(function (c) { const h = _charHue(c.name); const count = (c.sheet?.sources || []).length; return '
' + '' + escHtml((c.name || '?')[0].toUpperCase()) + '' + '' + escHtml(c.name) + '' + (count ? '' + count + '' : '') + '
'; }).join(''); container.innerHTML = ''; const pg = document.createElement('div'); pg.className = 'lib-char-page'; pg.innerHTML = '
' + '' + '
' + avatarHtml + '
' + '
' + escHtml(rec.name) + '
' + (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '
' + escHtml(_libStr(sh.full_name)) + '
' : '') + (_libStr(sh.title) ? '
' + escHtml(_libStr(sh.title)) + '
' : '') + '
' + escHtml(_libStr(sh.aliases)) + '
' + '
' + escHtml(_libStr(sh.archetype)) + '
' + '
' + (tierLabel ? '' + tierLabel + '' : '') + (gender ? ' ' + escHtml(gender) + '' : '') + '
' + '
' + '
' + '
' + '
' + '' + '' + (voiceId ? escHtml(voiceId) : 'Noch keine Stimme zugewiesen') + '' + '' + '' + '' + '' + '
' + alignHtml + '
' + _lcdSection('mdi-card-account-details-outline', 'Identität', [ _lcdFieldEdit('Voller Name', sh.full_name, 'full_name', sourcesByField['full_name']), _lcdFieldEdit('Vorname', sh.first_name, 'first_name', sourcesByField['first_name']), _lcdFieldEdit('Nachname', sh.last_name, 'last_name', sourcesByField['last_name']), _lcdFieldEdit('Titel / Rolle', sh.title, 'title', sourcesByField['title']), _lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases', sourcesByField['aliases']), ]) + _lcdSection('mdi-account-outline', 'Erscheinung', [ _lcdFieldEdit('Körperlich', sh.physical, 'physical', sourcesByField['physical']), _lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing', sourcesByField['clothing']), ]) + _lcdSection('mdi-drama-masks', 'Persönlichkeit', [ _lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms', sourcesByField['mannerisms']), _lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern', sourcesByField['voice_pattern']), ]) + _lcdSection('mdi-book-open-outline', 'Geschichte', [ _lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory', sourcesByField['backstory']), _lcdFieldEdit('Motivation', sh.motivation, 'motivation', sourcesByField['motivation']), _lcdFieldEdit('Ängste', sh.fears, 'fears', sourcesByField['fears']), ]) + _lcdSection('mdi-sword', 'Fähigkeiten', [ _lcdFieldEdit('Fertigkeiten', sh.skills, 'skills', sourcesByField['skills']), _lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities', sourcesByField['capabilities']), _lcdFieldEdit('Stärkstes Attribut', sh.attribute_high, 'attribute_high'), _lcdFieldEdit('Schwächstes Attribut', sh.attribute_low, 'attribute_low'), ]) + _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [ _lcdFieldEdit('', sh.relationships, 'relationships', sourcesByField['relationships']), relDotsHtml, ]) + _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [ _lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style', sourcesByField['conflict_style']), _lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition', sourcesByField['win_condition']), ]) + _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [ _lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'), _lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'), ]) + promptsHtml + '
' + sourcesHtml + (rec.analysis ? '
' + escHtml(String(rec.analysis)) + '
' : '') + '
' + '
' + '
' + '
Charaktere · ' + escHtml(rec.book || '') + '
' + sidebarHtml + '
'; container.appendChild(pg); pg.querySelector('.lib-cpg-back').addEventListener('click', function () { libraryRenderCharacters(); }); pg.querySelector('.lcd-avatar-upload').addEventListener('click', function () { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.onchange = async function () { const file = inp.files[0]; if (!file) return; const fr = new FileReader(); fr.onload = async function (ev) { if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result); toast('Profilbild gespeichert', 'success'); rec.image = ev.target.result; const av = pg.querySelector('.lcd-avatar-upload'); if (av) av.innerHTML = '' + escHtml(rec.name) + ''; }; fr.readAsDataURL(file); }; inp.click(); }); pg.querySelectorAll('.lib-cpg-sidebar-item').forEach(function (item) { item.addEventListener('click', async function () { const target = (allChars || []).find(function (c) { return c.id === item.dataset.charId; }); if (target) _charDetailPage(target, allChars); }); }); pg.querySelectorAll('.lcd-source-clickable').forEach(function (item) { item.addEventListener('click', function () { const n = parseInt(item.dataset.page, 10); if (!isNaN(n)) _jumpToReaderPage(n); }); }); pg.querySelector('.lcd-pick-voice')?.addEventListener('click', async function () { _openVoicePicker(pg.querySelector('.lcd-voice-top'), rec, async function () { const all = await clGetAll().catch(() => allChars); const up = all.find(function (r) { return r.id === rec.id; }) || rec; _charDetailPage(up, all.filter(function (r) { return r.book === rec.book; })); }); }); pg.querySelector('.lcd-auto-voice')?.addEventListener('click', async function () { await _autoAssignVoice(rec); const all = await clGetAll().catch(() => allChars); const up = all.find(function (r) { return r.id === rec.id; }) || rec; _charDetailPage(up, all.filter(function (r) { return r.book === rec.book; })); }); pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); }); pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoice(rec); }); // Generation Prompts section: copy buttons + one-call generation of all four // external-tool prompts (Voice Design / Image / SillyTavern / Concept Art) // from the character's full profile. pg.querySelectorAll('.lcd-prompt-copy').forEach(function (btn) { btn.addEventListener('click', async function () { const box = btn.closest('.lcd-prompt-body'); const text = box?.querySelector('.lcd-prompt-text')?.textContent.trim() || ''; if (!text) { toast('Nothing to copy yet — click Generate first', 'error'); return; } if (typeof copyText === 'function') await copyText(text); toast('Prompt copied', 'success'); }); }); // One Generate button per prompt box — the server accepts a `fields` filter // so clicking just one no longer burns tokens (re)generating all four. pg.querySelectorAll('.lcd-gen-prompt').forEach(function (btn) { btn.addEventListener('click', async function (e) { e.preventDefault(); const key = btn.dataset.sheetKey; const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' Generating…'; try { const sh2 = rec.sheet || {}; const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' '); const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : ''; const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' }; const r = await fetch('/api/character-generate-prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model, fields: [key] }), }); if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText); const d = await r.json(); if (!d[key]) throw new Error('Empty response — try again'); if (!rec.sheet) rec.sheet = {}; rec.sheet[key] = d[key]; rec.updated = new Date(); if (typeof clPut === 'function') await clPut(rec); toast('Prompt generated', 'success'); _charDetailPage(rec, allChars); // re-render so the box shows the new content } catch (err) { toast('Prompt generation failed: ' + (err.message || err), 'error'); btn.disabled = false; btn.innerHTML = orig; } }); }); const slider = pg.querySelector('.lcd-align-slider'); const sliderVal = pg.querySelector('.lcd-align-slider-val'); const arcEl = pg.querySelector('.lcd-align-arc'); if (slider) { slider.addEventListener('input', async function () { const val = parseInt(slider.value, 10); if (sliderVal) sliderVal.textContent = val + '/100'; if (arcEl) arcEl.textContent = arcInfo.ch + ' ' + arcInfo.label + (val >= 70 ? ' · Rechtschaffen (' + val + '/100)' : val <= 30 ? ' · Böse (' + val + '/100)' : ' · Moralisch ambivalent (' + val + '/100)'); arcEl && (arcEl.style.color = arcInfo.color); rec.sheet.moral_alignment_score = val; rec.updated = new Date(); if (typeof clPut === 'function') await clPut(rec); }); } let _saveTimer = null; function _schedSave(key, value, isRecKey) { clearTimeout(_saveTimer); _saveTimer = setTimeout(async function () { if (isRecKey) { rec[key] = value; } else { if (!rec.sheet) rec.sheet = {}; rec.sheet[key] = value; } rec.updated = new Date(); if (typeof clPut === 'function') await clPut(rec); }, 900); } pg.querySelectorAll('[contenteditable][data-sheet-key]').forEach(function (el) { el.addEventListener('input', function () { _schedSave(el.dataset.sheetKey, el.textContent.trim(), false); }); }); pg.querySelectorAll('[contenteditable][data-rec-key]').forEach(function (el) { el.addEventListener('input', function () { _schedSave(el.dataset.recKey, el.textContent.trim(), true); }); }); } window._charDetailPage = _charDetailPage; function _charDetailModal(rec, allChars) { const sh = rec.sheet || {}; const cov = libBookCover(rec.name); const hue = _charHue(rec.name); const tier = String(sh.tier || '').toLowerCase(); const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : ''; const gender = _libStr(sh.gender); const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female' : gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary'; const score = sh.moral_alignment_score; const pct = score != null ? Math.max(0, Math.min(100, score)) : null; const arc = sh.arc_direction || 'neutral'; const arcMap = { 'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' }, 'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' }, 'complex': { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' }, 'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' }, 'stable-bad': { ch: '→', label: 'Stabil böse', color: '#888' }, 'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' }, }; const arcInfo = arcMap[arc] || arcMap['neutral']; const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; const avatarHtml = rec.image ? '
' + escHtml(rec.name) + '
' : '
' + escHtml((rec.name || '?')[0].toUpperCase()) + '
'; const alignHtml = pct != null ? ( '
' + '
Moralische Gesinnung
' + '
' + 'Böse' + '
' + 'Gut' + '
' + '
' + arcInfo.ch + ' ' + arcInfo.label + (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)') + '
' + (_libStr(sh.arc_note) ? '
' + escHtml(_libStr(sh.arc_note)) + '
' : '') + (_libStr(sh.alignment) ? '
' + escHtml(_libStr(sh.alignment)) + '
' : '') + '
' ) : ''; // Relationship dots (same logic as card) const relText = _libStr(sh.relationships).toLowerCase(); const relHits = (allChars || []) .filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; }) .map(function (c) { const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); return { c: c, n: (relText.match(re) || []).length }; }) .filter(function (x) { return x.n > 0; }) .sort(function (a, b) { return b.n - a.n; }) .slice(0, 8); const relDotsHtml = relHits.length ? ( '
' + relHits.map(function (x) { const h = _charHue(x.c.name); return '' + escHtml((x.c.name || '?')[0].toUpperCase()) + ''; }).join('') + '
' ) : ''; const ov = document.createElement('div'); ov.className = 'lib-char-detail-ov'; ov.innerHTML = '
' + '
' + avatarHtml + '
' + '
' + escHtml(rec.name) + '
' + (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '
' + escHtml(_libStr(sh.full_name)) + '
' : '') + (_libStr(sh.title) ? '
' + escHtml(_libStr(sh.title)) + '
' : '') + (_libStr(sh.aliases) ? '
auch bekannt als ' + escHtml(_libStr(sh.aliases)) + '
' : '') + (_libStr(sh.archetype) ? '
' + escHtml(_libStr(sh.archetype)) + '
' : '') + '
' + (tierLabel ? '' + tierLabel + '' : '') + (gender ? ' ' + escHtml(gender) + '' : '') + '
' + '
' + '
' + '' + '' + '
' + '
' + '
' + '
' + '' + '' + (voiceId ? escHtml(voiceId) : 'Noch keine Stimme zugewiesen') + '' + '' + '' + '' + '' + '
' + alignHtml + '
' + _lcdSection('mdi-account-outline', 'Erscheinung', [ _lcdField('Körperlich', sh.physical, true), _lcdField('Kleidung & Aussehen', sh.clothing, true), ]) + _lcdSection('mdi-drama-masks', 'Persönlichkeit', [ _lcdField('Eigenheiten & Verhalten', sh.mannerisms, true), _lcdField('Stimme & Sprache', sh.voice_pattern, true), ]) + _lcdSection('mdi-book-open-outline', 'Geschichte', [ _lcdField('Hintergrund & Herkunft', sh.backstory, true), _lcdField('Motivation', sh.motivation, true), _lcdField('Ängste', sh.fears, true), ]) + _lcdSection('mdi-sword', 'Fähigkeiten', [ _lcdField('Fertigkeiten', sh.skills, true), _lcdField('Besondere Fähigkeiten', sh.capabilities, true), _lcdField('Stärkstes Attribut', sh.attribute_high, false), _lcdField('Schwächstes Attribut', sh.attribute_low, false), ]) + _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [ _lcdField('', sh.relationships, true), relDotsHtml, ]) + _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [ _lcdField('Konfliktstil', sh.conflict_style, true), _lcdField('Siegbedingung', sh.win_condition, true), ]) + _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [ _lcdField('Dunkles Geheimnis / fataler Fehler', sh.secret, true), _lcdField('Charakterentwicklung', sh.arc_note, true), ]) + '
' + _lcdSourcesHtml(sh.sources) + (rec.analysis ? '
' + escHtml(String(rec.analysis)) + '
' : '') + '
' + '
'; document.body.appendChild(ov); const close = function () { ov.remove(); }; ov.querySelector('.lcd-close-btn').addEventListener('click', close); ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); ov.querySelector('.lcd-edit-btn').addEventListener('click', function () { close(); if (typeof clEdit === 'function') clEdit(rec.id); }); // Voice buttons inside detail modal const box = ov.querySelector('.lib-char-detail-box'); ov.querySelector('.lcd-pick-voice').addEventListener('click', function (e) { e.stopPropagation(); _openVoicePicker(box, rec, function () { close(); libraryRenderCharacters(); }); }); ov.querySelector('.lcd-auto-voice').addEventListener('click', async function (e) { e.stopPropagation(); await _autoAssignVoice(rec); close(); libraryRenderCharacters(); }); ov.querySelector('.lcd-online-voice').addEventListener('click', function (e) { e.stopPropagation(); _charSearchOnline(rec); }); ov.querySelector('.lcd-gen-voice').addEventListener('click', function (e) { e.stopPropagation(); _charDesignVoice(rec); }); // Avatar click to upload image ov.querySelector('.lcd-avatar').addEventListener('click', function () { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.onchange = async function () { const file = inp.files[0]; if (!file) return; const fr = new FileReader(); fr.onload = async function (ev) { if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result); toast('Profile picture saved', 'success'); close(); libraryRenderCharacters(); }; fr.readAsDataURL(file); }; inp.click(); }); } window._charDetailModal = _charDetailModal; function _openVoicePicker(cardEl, rec, onDone) { // Remove any existing picker document.querySelectorAll('.lib-voice-picker-popup').forEach(function (p) { p.remove(); }); const voices = window._voices || []; const gender = String(rec.sheet?.gender || '').toLowerCase(); const genderMatch = gender.startsWith('f') ? 'f' : gender.startsWith('m') ? 'm' : ''; const popup = document.createElement('div'); popup.className = 'lib-voice-picker-popup'; popup.innerHTML = '' + '
'; function renderList(filter) { let list = voices.filter(function (v) { return v.enabled !== false; }); if (filter) { const f = filter.toLowerCase(); list = list.filter(function (v) { return (v.id || '').toLowerCase().includes(f) || (v.name || '').toLowerCase().includes(f); }); } else if (genderMatch) { list = list.filter(function (v) { const vg = String(v.gender || '').toLowerCase(); return vg.startsWith(genderMatch) || !vg; }).concat(list.filter(function (v) { const vg = String(v.gender || '').toLowerCase(); return vg && !vg.startsWith(genderMatch); })); } const ul = popup.querySelector('.lib-vp-list'); ul.innerHTML = list.slice(0, 60).map(function (v) { const sel = v.id === rec.voice; return '
' + escHtml(v.id || v.name || '') + (v.gender ? ' · ' + escHtml(v.gender) + '' : '') + '
'; }).join('') + (list.length === 0 ? '
No voices found
' : ''); ul.querySelectorAll('.lib-vp-item').forEach(function (item) { item.addEventListener('click', async function () { const vid = item.dataset.vid; await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name, voice: vid })); popup.remove(); onDone(); }); }); } renderList(''); popup.querySelector('.lib-vp-input').addEventListener('input', function (e) { renderList(e.target.value); }); // Position near the card cardEl.style.position = 'relative'; cardEl.appendChild(popup); // Close on outside click setTimeout(function () { function close(e) { if (!popup.contains(e.target)) { popup.remove(); document.removeEventListener('click', close); } } document.addEventListener('click', close); }, 0); popup.querySelector('.lib-vp-input').focus(); } async function _autoAssignVoice(rec) { const voices = (window._voices || []).filter(function (v) { return v.enabled !== false; }); if (!voices.length) { toast('Voice library not loaded', 'error'); return; } const gender = String(rec.sheet?.gender || '').toLowerCase().trim(); // Handle both English (female/male) and German (weiblich/männlich) gender terms const isFemale = gender.startsWith('f') || gender.startsWith('w'); // female, weiblich const isMale = !isFemale && gender.startsWith('m'); // male, männlich const gMatch = isFemale ? 'f' : isMale ? 'm' : ''; let pool = gMatch ? voices.filter(function (v) { const vg = String(v.gender || '').toLowerCase(); return gMatch === 'f' ? (vg.startsWith('f') || vg.startsWith('w')) : vg.startsWith('m'); }) : voices; if (!pool.length) pool = voices; // Prefer unassigned voices (not already used by another character in same book) const usedInBook = new Set(); try { const bookChars = await clGetAllByTagOrBook(rec.book); bookChars.forEach(function (r) { if (r.voice && r.id !== rec.id) usedInBook.add(r.voice); }); } catch (_) {} const fresh = pool.filter(function (v) { return !usedInBook.has(v.id); }); const candidate = (fresh.length ? fresh : pool).sort(function (a, b) { return (b.rating || 0) - (a.rating || 0); })[0]; if (!candidate) { toast('No matching voice found', 'error'); return; } await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name, voice: candidate.id })); toast('Assigned ' + candidate.id + ' → ' + rec.name, 'success'); } // Detect the production language from a character's own (book-language) text. function _charLang(rec) { const sh = rec.sheet || {}; const text = [sh.backstory, sh.voice_pattern, sh.mannerisms, sh.relationships, sh.motivation, sh.archetype] .filter(Boolean).join(' '); return (typeof detectLang === 'function') ? detectLang(text) : ''; } // Build a natural-language voice-design prompt from a character sheet. function _buildVoicePrompt(rec) { const sh = rec.sheet || {}; const g = String(sh.gender || '').toLowerCase(); const genderWord = g.startsWith('f') ? 'female' : g.startsWith('m') ? 'male' : ''; const bits = []; bits.push('A ' + (genderWord ? genderWord + ' ' : '') + 'voice' + (sh.archetype ? ' for ' + sh.archetype.toLowerCase() : '') + '.'); if (sh.voice_pattern) bits.push(sh.voice_pattern); if (sh.mannerisms) bits.push('Mannerisms: ' + sh.mannerisms); if (sh.physical) bits.push(sh.physical); if (sh.alignment) bits.push('Disposition: ' + sh.alignment); return bits.join(' ').slice(0, 600); } function _selectLoose(sel, val) { if (!sel || !val) return; const v = String(val).toLowerCase(); const opt = [...sel.options].find(function (o) { const ov = o.value.toLowerCase(), ot = o.textContent.toLowerCase(); return ov === v || ot === v || ov.startsWith(v) || ot.startsWith(v) || v.startsWith(ov); }); if (opt) { sel.value = opt.value; sel.dispatchEvent(new Event('change')); } } // Search a matching voice online — opens Get a Voice Online on the Fish.audio // tab, pre-filled with the character name + detected language. function _charSearchOnline(rec) { if (typeof navTo === 'function') navTo('s-studio'); const lang = _charLang(rec); setTimeout(function () { const fishTab = document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]'); if (fishTab) fishTab.click(); setTimeout(function () { const langSel = document.getElementById('fa-lang'); if (langSel) _selectLoose(langSel, lang); const search = document.getElementById('fa-search'); if (search) { search.value = rec.name; search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); } }, 120); }, 120); toast('Searching online voices for ' + rec.name + (lang ? ' (' + lang + ')' : ''), 'info'); } // Generate a new voice — opens Design a Voice, pre-filled with gender, the // detected language, a description built from the sheet, and the character name. function _charDesignVoice(rec) { if (typeof navTo === 'function') navTo('s-design'); const sh = rec.sheet || {}; const lang = _charLang(rec); setTimeout(function () { _selectLoose(document.getElementById('design-gender'), sh.gender); _selectLoose(document.getElementById('design-language'), lang); const instruct = document.getElementById('design-instruct'); if (instruct) instruct.value = _buildVoicePrompt(rec); const nm = document.getElementById('design-preset-name'); if (nm) nm.value = rec.name; }, 140); toast('Voice design prepared for ' + rec.name + (lang ? ' · ' + lang : ''), 'info'); } window._charSearchOnline = _charSearchOnline; window._charDesignVoice = _charDesignVoice; window.libraryRenderCharacters = libraryRenderCharacters;