tts-voice-creator-clone-and.../static/js/library-characters.js
mARTin-B78 5fd1660c09 Add character table view, bulk voice-assign, source links, fix prompt truncation (v1.12.98)
Table view: one row per character (avatar, name, sex, line count, voice
language, alignment, voice, tags, prompt-availability checks), toggled
next to the card grid and persisted.

Bulk voice auto-assign: checkbox per character + "Auto-assign selected"
per production, sequential so later picks see what earlier ones just
took (avoids duplicate voice assignments).

Character tags (auto-set to the book of origin) are now visible on
cards - the field always existed, cards just never rendered it, so a
character recurring across books had no visible link between records.

Detail fields (Backstory, Motivation, etc.) now show small numbered
links to their exact source citation when the sheet has one, instead
of making the reader search the full "Quellen im Text" list.

Added gender as an actual extracted character-sheet field - the UI
already had a gender icon but the LLM was never asked for the value.

Fixed SillyTavern/Concept Art prompts still coming back empty despite
the earlier token-budget increase: they're the last two fields in one
JSON object, so truncation always cost the same two regardless of the
ceiling. Split into two independent, concurrent LLM calls instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:09:59 +02:00

1149 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 = '<div class="lib-chars-loading"><span class="mdi mdi-loading mdi-spin"></span> Loading characters…</div>';
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 = '<div class="lib-chars-toolbar">'
+ '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
+ '</div>'
+ '<div class="lib-empty">'
+ '<span class="mdi mdi-account-box-multiple-outline"></span>'
+ '<p>No characters yet.</p>'
+ '<p class="lib-empty-hint">Open a book in <b>Read Aloud</b>, cast it as an audiobook, then click <b>Cast Characters</b> to generate character sheets — or import an existing cast from SillyTavern.</p>'
+ '</div>';
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,
// plus a Cards/Table view toggle (persisted, like Script Rehearser's).
const viewMode = localStorage.getItem('ttsvc_libchars_view') === 'table' ? 'table' : 'cards';
const bar = document.createElement('div');
bar.className = 'lib-chars-toolbar';
bar.innerHTML = '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
+ '<div class="lib-chars-view-toggle">'
+ '<button class="btn-secondary btn-sm' + (viewMode === 'cards' ? ' is-active' : '') + '" data-view="cards"><span class="mdi mdi-view-grid-outline"></span> Cards</button>'
+ '<button class="btn-secondary btn-sm' + (viewMode === 'table' ? ' is-active' : '') + '" data-view="table"><span class="mdi mdi-table"></span> Table</button>'
+ '</div>';
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
if (typeof stImportDialog === 'function') stImportDialog('', function () { 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 productions = document.createDocumentFragment();
Object.keys(byBook).sort().forEach(function (book) {
const chars = byBook[book].sort(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 || '');
});
const cov = libBookCover(book);
const prod = document.createElement('div');
prod.className = 'lib-chars-production';
prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">'
+ '<div class="lib-chars-prod-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>'
+ '<div class="lib-chars-prod-actions">'
+ '<button class="btn-secondary btn-sm lib-chars-casting-btn" data-book="' + escHtml(book) + '" title="Back to the casting script (speaker attribution)"><span class="mdi mdi-drama-masks"></span> Casting</button>'
+ '<button class="btn-secondary btn-sm lib-chars-cast-btn" data-book="' + escHtml(book) + '" title="Re-run character sheet generation"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button>'
+ '<button class="btn-secondary btn-sm lib-chars-reh-btn" data-book="' + escHtml(book) + '" title="Open in Script Rehearsal"><span class="mdi mdi-theater"></span> Rehearse</button>'
+ '<button class="btn-secondary btn-sm lib-chars-read-btn" data-book="' + escHtml(book) + '" title="Open in Read Aloud"><span class="mdi mdi-book-open-page-variant-outline"></span> Read Aloud</button>'
+ '<button class="btn-secondary btn-sm lib-chars-imp-btn" data-book="' + escHtml(book) + '" title="Import SillyTavern cards into this production"><span class="mdi mdi-import"></span></button>'
+ '<button class="btn-secondary btn-sm lib-chars-bulk-voice-btn" data-book="' + escHtml(book) + '" title="Auto-assign a voice to every checked character" disabled><span class="mdi mdi-account-voice"></span> Auto-assign selected (<span class="lib-chars-bulk-count">0</span>)</button>'
+ '</div></div>'
+ (viewMode === 'table'
? _charsTableHtml(chars)
: '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
+ chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
+ '</div>');
// 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 = '<span class="mdi mdi-loading mdi-spin"></span> 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 '<div class="lib-char-align">'
+ '<span class="lib-char-align-evil" title="Evil">●</span>'
+ '<div class="lib-char-align-bar" title="' + label + ' (' + pct + '/100)">'
+ '<div class="lib-char-align-dot" style="left:' + pct + '%"></div>'
+ '</div>'
+ '<span class="lib-char-align-good" title="Good">●</span>'
+ '<span class="lib-char-align-arrow" style="color:' + a.color + '" title="' + a.tip + '">' + a.ch + '</span>'
+ '</div>';
}
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 '<div class="lib-char-rels">'
+ hits.map(function (x) {
const h = _charHue(x.c.name);
return '<span class="lib-char-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
}).join('')
+ '</div>';
}
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) : '<span style="opacity:.45">Keine Stimme</span>';
const tier = String(sh.tier || '').toLowerCase();
const tierBadge = tier === 'main' ? '<span class="lib-char-tier main">Haupt</span>'
: tier === 'supporting' ? '<span class="lib-char-tier support">Neben</span>' : '';
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
? '<div class="lib-char-tags">' + tagList.map(function (t) {
return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>';
}).join('') + '</div>'
: '';
const avatarInner = rec.image
? '<img src="' + rec.image + '" alt="' + escHtml(rec.name) + '" style="width:100%;height:100%;object-fit:cover;border-radius:50%">'
: escHtml((rec.name || '?')[0].toUpperCase());
return '<div class="lib-char-card" data-char-id="' + escHtml(rec.id) + '">'
+ '<label class="lib-char-select" title="Select for bulk actions" onclick="event.stopPropagation()">'
+ '<input type="checkbox" class="lib-char-select-cb" data-char-id="' + escHtml(rec.id) + '">'
+ '</label>'
+ '<div class="lib-char-card-banner" style="--ch1:hsl(' + hue + ',52%,35%);--ch2:hsl(' + hue2 + ',56%,26%)">'
+ '<div class="lib-char-avatar" data-char-id="' + escHtml(rec.id) + '" title="Bild hochladen">' + avatarInner + '</div>'
+ '<button class="lib-char-export" title="Als SillyTavern-Karte exportieren (.json)"><span class="mdi mdi-export-variant"></span></button>'
+ '</div>'
+ '<div class="lib-char-body">'
+ '<div class="lib-char-name">' + tierBadge + escHtml(rec.name) + '<span class="mdi ' + genderIcon + '" style="font-size:11px;opacity:.5"></span></div>'
+ (_libStr(sh.title) ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.title)) + '</div>' : '')
+ (_libStr(sh.aliases) ? '<div class="lib-char-archetype">aka ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
+ (sh.archetype ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
+ (snippet ? '<div class="lib-char-snippet">' + escHtml(snippet) + '</div>' : '')
+ tagsHtml
+ _charAlignHtml(sh)
+ _charRelsHtml(rec, allChars)
+ '<div class="lib-char-divider"></div>'
+ '<div class="lib-char-voice-row">'
+ '<span class="lib-char-voice-label">' + voiceLabel + '</span>'
+ '<button class="lib-char-pick-voice btn-sm">Auswahl</button>'
+ '<button class="lib-char-auto-voice btn-sm">Auto</button>'
+ '</div>'
+ '</div></div>';
}
// 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' ? '<span class="lib-char-tier main">Haupt</span>'
: tier === 'supporting' ? '<span class="lib-char-tier support">Neben</span>' : '';
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
? '<img src="' + rec.image + '" alt="' + escHtml(rec.name) + '" style="width:100%;height:100%;object-fit:cover;border-radius:50%">'
: escHtml((rec.name || '?')[0].toUpperCase());
const hasPrompt = function (key) { return !!(sh[key] && String(sh[key]).trim()); };
const promptCell = function (key, title) {
return '<span class="lib-chars-tbl-check ' + (hasPrompt(key) ? 'is-yes' : 'is-no') + '" title="' + title + (hasPrompt(key) ? ': vorhanden' : ': fehlt') + '">'
+ '<span class="mdi ' + (hasPrompt(key) ? 'mdi-check-circle' : 'mdi-circle-outline') + '"></span></span>';
};
return '<tr class="lib-char-card lib-chars-tbl-row" data-char-id="' + escHtml(rec.id) + '">'
+ '<td class="lib-chars-tbl-cb" onclick="event.stopPropagation()"><input type="checkbox" class="lib-char-select-cb" data-char-id="' + escHtml(rec.id) + '"></td>'
+ '<td><div class="lib-char-avatar lib-chars-tbl-avatar" data-char-id="' + escHtml(rec.id) + '" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + avatarInner + '</div></td>'
+ '<td class="lib-chars-tbl-name">' + tierBadge + escHtml(rec.name) + '</td>'
+ '<td>' + (genderIcon ? '<span class="mdi ' + genderIcon + '"></span>' : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td>' + (sh.line_count != null ? sh.line_count : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td>' + (voiceLang ? escHtml(voiceLang) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td>' + (pct != null ? '<div class="lib-char-align-bar" title="' + pct + '/100"><div class="lib-char-align-dot" style="left:' + pct + '%"></div></div>' : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td class="lib-chars-tbl-voice">' + (voiceId ? escHtml(voiceId) : '<span class="lib-chars-tbl-dash">Keine Stimme</span>')
+ '<button class="lib-char-pick-voice btn-sm">Auswahl</button><button class="lib-char-auto-voice btn-sm">Auto</button></td>'
+ '<td class="lib-chars-tbl-tags">' + tagList.map(function (t) { return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>'; }).join('') + '</td>'
+ '<td>' + promptCell('silly_tavern_prompt', 'SillyTavern') + '</td>'
+ '<td>' + promptCell('voice_design_prompt', 'TTS Voice') + '</td>'
+ '<td>' + promptCell('image_prompt', 'Bild') + '</td>'
+ '<td><button class="lib-char-export btn-sm" title="Als SillyTavern-Karte exportieren"><span class="mdi mdi-export-variant"></span></button></td>'
+ '</tr>';
}).join('');
return '<div class="lib-chars-tbl-wrap"><table class="lib-chars-tbl">'
+ '<thead><tr>'
+ '<th></th><th></th><th>Name</th><th title="Geschlecht">⚥</th><th title="Anzahl Zeilen">Zeilen</th><th>Sprache</th>'
+ '<th title="Moralische Gesinnung">Gut/Böse</th><th>Stimme</th><th>Tags</th>'
+ '<th title="SillyTavern-Prompt">ST</th><th title="TTS-Voice-Design-Prompt">TTS</th><th title="Bild-Prompt">Bild</th><th></th>'
+ '</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
// ── 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 '<div class="lcd-sources">'
+ '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text</div>'
+ '<div class="lcd-source-list">'
+ list.map(function (s) {
const page = s.page != null ? 'Seite ' + s.page : '';
const hint = _libStr(s.line_hint || s.hint || '');
return '<div class="lcd-source-item">'
+ (page || hint ? '<div class="lcd-source-page">' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
+ (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
+ '</div>';
}).join('')
+ '</div></div>';
}
function _lcdField(label, value, multiline) {
const v = _libStr(value);
if (!v) return '';
return '<div class="lcd-field">'
+ '<div class="lcd-field-label">' + label + '</div>'
+ '<div class="lcd-field-value">' + (multiline ? escHtml(v) : escHtml(v)) + '</div>'
+ '</div>';
}
function _lcdSection(icon, label, fields) {
const body = fields.join('');
if (!body) return '';
return '<div class="lcd-section">'
+ '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
+ body
+ '</div>';
}
function _lcdSectionFull(icon, label, fields) {
const body = fields.join('');
if (!body) return '';
return '<div class="lcd-section-full">'
+ '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
+ body
+ '</div>';
}
// 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 '<details class="lcd-prompt-box">'
+ '<summary>' + escHtml(label) + (has ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>') + '</summary>'
+ '<div class="lcd-prompt-body">'
+ '<div class="lcd-prompt-text lcd-field-editable" contenteditable="true" spellcheck="false" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(value || '') + '</div>'
+ '<button type="button" class="btn-secondary btn-sm lcd-prompt-copy" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-content-copy"></span> Copy</button>'
+ '</div>'
+ '</details>';
}
// 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 '<a href="#" class="lcd-field-src-link" data-jump-src="' + idx + '" title="Quelle im Text anzeigen">' + (sourceIdxs.indexOf(idx) + 1) + '</a>';
}).join('');
return '<div class="lcd-field">'
+ (label || links ? '<div class="lcd-field-label">' + escHtml(label) + (links ? ' <span class="lcd-field-src-links">' + links + '</span>' : '') + '</div>' : '')
+ '<div class="lcd-field-value lcd-field-editable" contenteditable="true" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(v) + '</div>'
+ '</div>';
}
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
? '<div class="lcd-avatar lcd-avatar-upload" title="Bild hochladen"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
: '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';
const alignHtml = pct != null ? (
'<div class="lcd-align-section">'
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
+ '<div class="lcd-align-bar-wrap">'
+ '<span class="lcd-align-label">Böse</span>'
+ '<input type="range" class="lcd-align-slider" min="0" max="100" value="' + pct + '">'
+ '<span class="lcd-align-label">Gut</span>'
+ '<span class="lcd-align-slider-val">' + pct + '/100</span>'
+ '</div>'
+ '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label
+ (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)')
+ '</div>'
+ (_libStr(sh.alignment) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
+ '</div>'
) : '';
const promptsHtml = '<div class="lcd-section-full lcd-prompts-section">'
+ '<div class="lcd-section-label"><span class="mdi mdi-script-text-outline"></span> Generation Prompts'
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompts"><span class="mdi mdi-creation"></span> Generate</button>'
+ '</div>'
+ _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')
+ '</div>';
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 ? (
'<div class="lcd-rels-dots">'
+ relHits.map(function (x) {
const h = _charHue(x.c.name);
return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
}).join('')
+ '</div>'
) : '';
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 ? (
'<div class="lcd-sources">'
+ '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text'
+ '<span class="lcd-sources-hint"> · Klicken zum Springen</span></div>'
+ '<div class="lcd-source-list">'
+ sourcesList.map(function (s, idx) {
const page = s.page != null ? 'Seite ' + s.page : '';
const hint = _libStr(s.line_hint || s.hint || '');
return '<div class="lcd-source-item lcd-source-clickable" id="lcd-src-' + idx + '" data-page="' + (s.page != null ? s.page : '') + '">'
+ (page || hint ? '<div class="lcd-source-page"><span class="mdi mdi-book-open-page-variant-outline"></span> ' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
+ (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
+ '</div>';
}).join('')
+ '</div></div>'
) : '';
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 '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '">'
+ '<span class="lib-cpg-sidebar-dot" style="background:hsl(' + h + ',55%,38%)">' + escHtml((c.name || '?')[0].toUpperCase()) + '</span>'
+ '<span class="lib-cpg-sidebar-name">' + escHtml(c.name) + '</span>'
+ (count ? '<span class="lib-cpg-sidebar-count">' + count + '</span>' : '')
+ '</div>';
}).join('');
container.innerHTML = '';
const pg = document.createElement('div');
pg.className = 'lib-char-page';
pg.innerHTML =
'<div class="lib-cpg-main">'
+ '<button class="lib-cpg-back"><span class="mdi mdi-arrow-left"></span> Alle Charaktere</button>'
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + hue2 + ',58%,25%)">'
+ avatarHtml
+ '<div class="lcd-header-body">'
+ '<div class="lcd-name" contenteditable="true" data-rec-key="name" spellcheck="false">' + escHtml(rec.name) + '</div>'
+ (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '<div class="lcd-aliases" contenteditable="true" data-sheet-key="full_name" spellcheck="false">' + escHtml(_libStr(sh.full_name)) + '</div>' : '')
+ (_libStr(sh.title) ? '<div class="lcd-aliases" contenteditable="true" data-sheet-key="title" spellcheck="false">' + escHtml(_libStr(sh.title)) + '</div>' : '')
+ '<div class="lcd-aliases" contenteditable="true" data-sheet-key="aliases" spellcheck="false">' + escHtml(_libStr(sh.aliases)) + '</div>'
+ '<div class="lcd-archetype" contenteditable="true" data-sheet-key="archetype" spellcheck="false">' + escHtml(_libStr(sh.archetype)) + '</div>'
+ '<div class="lcd-tier-gender">'
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
+ '</div>'
+ '</div>'
+ '</div>'
+ '<div class="lcd-body">'
+ '<div class="lcd-voice-top">'
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
+ '<span class="lcd-voice-label">' + (voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
+ '<button class="lcd-pick-voice">Auswählen</button>'
+ '<button class="lcd-auto-voice">Automatisch</button>'
+ '<button class="lcd-online-voice">Online suchen</button>'
+ '<button class="lcd-gen-voice">Generieren</button>'
+ '</div>'
+ alignHtml
+ '<div class="lcd-sections">'
+ _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
+ '</div>'
+ sourcesHtml
+ (rec.analysis ? '<div class="lcd-analysis" contenteditable="true" data-rec-key="analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
+ '</div>'
+ '</div>'
+ '<div class="lib-cpg-sidebar">'
+ '<div class="lib-cpg-sidebar-title">Charaktere · ' + escHtml(rec.book || '') + '</div>'
+ sidebarHtml
+ '</div>';
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 = '<img src="' + ev.target.result + '" alt="' + 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');
});
});
pg.querySelector('.lcd-gen-prompts')?.addEventListener('click', async function () {
const btn = this;
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> 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 }),
});
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
const d = await r.json();
if (!rec.sheet) rec.sheet = {};
const labels = { voice_design_prompt: 'Voice Design', image_prompt: 'Character Image', silly_tavern_prompt: 'SillyTavern', concept_art_prompt: 'Concept Art' };
const missing = [];
Object.keys(labels).forEach(function (k) {
if (d[k]) rec.sheet[k] = d[k];
else if (!rec.sheet[k]) missing.push(labels[k]);
});
rec.updated = new Date();
if (typeof clPut === 'function') await clPut(rec);
// A partially-cut-off LLM answer used to look exactly like success with
// two silently empty boxes — say which ones are missing instead.
if (missing.length) toast('Generated, but incomplete — missing: ' + missing.join(', ') + '. Click Generate again.', 'error');
else toast('Generation prompts created', 'success');
_charDetailPage(rec, allChars); // re-render so the boxes show 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
? '<div class="lcd-avatar"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
: '<div class="lcd-avatar" style="background:hsl(' + hue + ',55%,38%)">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';
const alignHtml = pct != null ? (
'<div class="lcd-align-section">'
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
+ '<div class="lcd-align-bar-wrap">'
+ '<span class="lcd-align-label">Böse</span>'
+ '<div class="lcd-align-bar"><div class="lcd-align-dot" style="left:' + pct + '%"></div></div>'
+ '<span class="lcd-align-label">Gut</span>'
+ '</div>'
+ '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label + (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)') + '</div>'
+ (_libStr(sh.arc_note) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.arc_note)) + '</div>' : '')
+ (_libStr(sh.alignment) ? '<div class="lcd-arc-note" style="margin-top:6px">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
+ '</div>'
) : '';
// 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 ? (
'<div class="lcd-rels-dots">'
+ relHits.map(function (x) {
const h = _charHue(x.c.name);
return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
}).join('')
+ '</div>'
) : '';
const ov = document.createElement('div');
ov.className = 'lib-char-detail-ov';
ov.innerHTML = '<div class="lib-char-detail-box">'
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + ((hue+40)%360) + ',58%,25%)">'
+ avatarHtml
+ '<div class="lcd-header-body">'
+ '<div class="lcd-name">' + escHtml(rec.name) + '</div>'
+ (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '<div class="lcd-aliases">' + escHtml(_libStr(sh.full_name)) + '</div>' : '')
+ (_libStr(sh.title) ? '<div class="lcd-aliases">' + escHtml(_libStr(sh.title)) + '</div>' : '')
+ (_libStr(sh.aliases) ? '<div class="lcd-aliases">auch bekannt als ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
+ (_libStr(sh.archetype) ? '<div class="lcd-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
+ '<div class="lcd-tier-gender">'
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
+ '</div>'
+ '</div>'
+ '<div class="lcd-header-btns">'
+ '<button class="lcd-edit-btn"><span class="mdi mdi-pencil-outline"></span> Bearbeiten</button>'
+ '<button class="lcd-close-btn"><span class="mdi mdi-close"></span></button>'
+ '</div>'
+ '</div>'
+ '<div class="lcd-body">'
+ '<div class="lcd-voice-top">'
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
+ '<span class="lcd-voice-label">' + (voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
+ '<button class="lcd-pick-voice">Auswählen</button>'
+ '<button class="lcd-auto-voice">Automatisch</button>'
+ '<button class="lcd-online-voice">Online suchen</button>'
+ '<button class="lcd-gen-voice">Generieren</button>'
+ '</div>'
+ alignHtml
+ '<div class="lcd-sections">'
+ _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),
])
+ '</div>'
+ _lcdSourcesHtml(sh.sources)
+ (rec.analysis ? '<div class="lcd-analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
+ '</div>'
+ '</div>';
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 = '<div class="lib-vp-search"><input type="search" placeholder="Search voices…" class="lib-vp-input" autocomplete="off"></div>'
+ '<div class="lib-vp-list"></div>';
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 '<div class="lib-vp-item' + (sel ? ' selected' : '') + '" data-vid="' + escHtml(v.id) + '">'
+ escHtml(v.id || v.name || '') + (v.gender ? ' <span style="opacity:.5;font-size:10px">· ' + escHtml(v.gender) + '</span>' : '')
+ '</div>';
}).join('') + (list.length === 0 ? '<div style="padding:12px;opacity:.5;font-size:12px">No voices found</div>' : '');
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;