Table view was rendering as stacked blocks instead of columns: rows reused .lib-char-card for its event wiring, but that class's display:flex;flex-direction:column turned every <tr> into a flex column. Reset to display:table-row and stripped the leaked-in card chrome. Split /api/character-generate-prompts into four independent per-field LLM calls (from two paired calls) and added a `fields` filter, so the UI can offer one Generate button per prompt box instead of a single button that always regenerated all four - cheaper, and further shrinks each response to reduce truncation risk. Added a Sort dropdown (Role/Alphabet/Lines/Gender/Voice assigned) to the Characters/Cast list, persisted like the Cards/Table toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1178 lines
62 KiB
JavaScript
1178 lines
62 KiB
JavaScript
// ── 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,
|
||
// 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 = '<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>'
|
||
+ '<label class="lib-chars-sort"><span class="mdi mdi-sort"></span> Sort'
|
||
+ '<select id="lib-chars-sort-sel">' + SORT_OPTIONS.map(function (o) {
|
||
return '<option value="' + o[0] + '"' + (o[0] === sortMode ? ' selected' : '') + '>' + o[1] + '</option>';
|
||
}).join('') + '</select>'
|
||
+ '</label>'
|
||
+ '<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.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 = '<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>'
|
||
+ '<div class="lcd-prompt-actions">'
|
||
+ '<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>'
|
||
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompt" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-creation"></span> ' + (has ? 'Regenerate' : 'Generate') + '</button>'
|
||
+ '</div>'
|
||
+ '</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</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');
|
||
});
|
||
});
|
||
// 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 = '<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, 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
|
||
? '<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;
|