tts-voice-creator-clone-and.../static/js/library-characters.js
mARTin-B78 c15a91d229 Add Casting back-navigation, fix truncated prompt generation (v1.12.91)
Characters/Cast gains a "Casting" button back to the active casting
session, the pipeline stepper renders on the Library section, and the
stepper's Cast Characters stop navigates instead of side-effect-running
sheet generation. Prompt generation: 4096-token budget (1600 truncated
the four-prompt JSON so two fields silently arrived empty), truncated
answers salvage completed fields, all-empty responses fail loudly, and
partial results name the missing prompts. The PDF-extraction progress
pill is enlarged and vertically centered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:43:51 +02:00

1019 lines
51 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
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>';
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
if (typeof stImportDialog === 'function') stImportDialog('', function () { 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>'
+ '</div></div>'
+ '<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(); });
});
// 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);
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) + '">'
+ '<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>' : '')
+ _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>';
}
// ── 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>';
}
function _lcdFieldEdit(label, value, sheetKey) {
const v = _libStr(value);
return '<div class="lcd-field">'
+ (label ? '<div class="lcd-field-label">' + escHtml(label) + '</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); }) : [];
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) {
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" 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'),
_lcdFieldEdit('Vorname', sh.first_name, 'first_name'),
_lcdFieldEdit('Nachname', sh.last_name, 'last_name'),
_lcdFieldEdit('Titel / Rolle', sh.title, 'title'),
_lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases'),
])
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
_lcdFieldEdit('Körperlich', sh.physical, 'physical'),
_lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing'),
])
+ _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
_lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms'),
_lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern'),
])
+ _lcdSection('mdi-book-open-outline', 'Geschichte', [
_lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory'),
_lcdFieldEdit('Motivation', sh.motivation, 'motivation'),
_lcdFieldEdit('Ängste', sh.fears, 'fears'),
])
+ _lcdSection('mdi-sword', 'Fähigkeiten', [
_lcdFieldEdit('Fertigkeiten', sh.skills, 'skills'),
_lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, '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'),
relDotsHtml,
])
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
_lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style'),
_lcdFieldEdit('Siegbedingung', sh.win_condition, '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;