Optimize startup and persist engine keys

This commit is contained in:
mARTin-B78 2026-06-30 14:52:17 +02:00
parent 586dc3dc3f
commit bc6c4842b5
20 changed files with 1237 additions and 9884 deletions

View File

@ -9,6 +9,34 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.12.54] — 2026-06-30
### Fixed
- **Engine API keys now persist** — Docker/local engine card keys and cloud API cards save into server settings via `engine_api_keys`; Groq STT and ElevenLabs also sync to their existing first-class settings keys.
- **Engine settings whitelist**`engine_local_urls`, `engine_container_names`, `engine_api_keys`, `custom_engine_cards`, and `fishspeech_url` are now accepted by the settings API instead of being silently dropped.
### Changed
- **Engines header cleanup** — replaced the wide right-aligned header note with a compact stacked context row so Language Models, Speech Recognition, and Text to Speech headers no longer leave a large empty band.
---
## [1.12.53] — 2026-06-30
### Changed
- **Library frontend split** — moved the Characters / Cast workspace out of `static/js/library.js` into `static/js/library-characters.js`; `library.js` now owns only production overview, book/play cards, and cross-links.
- **Removed obsolete monolith** — deleted the unused tracked `static/app.js`; the active runtime path remains `loader.js` + `static/js/*` or the generated bundle.
### Performance
- **Faster startup** — navigation now initializes before async settings/voice refreshes, benchmark data loads only when Performance opens, and ElevenLabs browsing starts only when Get Voices is shown.
- **Faster voice-list rendering** — batches voice-row DOM insertion with `DocumentFragment` and avoids rebuilding filter `<select>` options when the option set has not changed.
- **Less hidden DOM work** — background voice-library refreshes update data without rendering the full voice list while the Voices section is hidden.
- **Faster character render wiring** — replaces repeated `all.find(...)` lookups with an id map and batches production card insertion.
### Fixed
- **Production bundle parity**`scripts/minify.mjs` now includes `benchmark-voice-picker.js` and `library-characters.js`, matching the fallback loader order.
---
## [1.12.52] — 2026-06-30
### Fixed

View File

@ -1 +1 @@
1.12.52
1.12.54

View File

@ -27,6 +27,7 @@ _SETTINGS_KEYS = {
"nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url",
"nvidia_zeroshot_url", "nvidia_flow_url",
"faster_whisper_url", "whisper_cpp_url", "groq_api_key", "kokoro_url", "vibevoice_url", "xtts_url",
"fishspeech_url",
"whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key",
"tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend",
# Captures settings
@ -36,6 +37,7 @@ _SETTINGS_KEYS = {
"captures_default_voice",
"client_voice_bindings",
"llm_url", "llm_model", "llm_api_key",
"engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards",
# Browser-persistent UI state
"refine_llm_url", "conv_llm_url", "seed_finder_text",
"seed_finder_dir", "pt_dir", "audiobook_prompt",
@ -214,6 +216,8 @@ def _normalize_settings(s: dict) -> dict:
s["engine_local_urls"] = {}
if not isinstance(s.get("engine_container_names"), dict):
s["engine_container_names"] = {}
if not isinstance(s.get("engine_api_keys"), dict):
s["engine_api_keys"] = {}
if not isinstance(s.get("custom_engine_cards"), list):
s["custom_engine_cards"] = []
return s
@ -273,6 +277,7 @@ def _load_settings() -> dict:
"llm_model": "",
"engine_local_urls": {},
"engine_container_names": {},
"engine_api_keys": {},
"custom_engine_cards": [],
"refine_llm_url": "",
"conv_llm_url": "",

View File

@ -917,7 +917,10 @@ async def audio_effects(request: Request):
# ── Voices export / import ────────────────────────────────────────────────────
_EXPORT_SKIP_KEYS = {"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key"}
_EXPORT_SKIP_KEYS = {
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
"elevenlabs_api_key", "llm_api_key", "engine_api_keys",
}
_IMPORT_ALLOWED_SUFFIXES = set(_AUDIO_EXTS + [".reference.txt", ".meta.json", ".jpg", ".jpeg", ".png", ".webp"])

View File

@ -20,10 +20,10 @@ mkdirSync(outDir, { recursive: true });
// Must match loader.js batch C order exactly.
const MAIN = [
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
'voice-picker', 'benchmark-voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
'characters-library', 'sillytavern', 'library',
'characters-library', 'sillytavern', 'library', 'library-characters',
].map(n => join(jsDir, n + '.js'));
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.12.52">
<meta name="app-version" content="1.12.54">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.52">
<link rel="stylesheet" href="/static/style.css?v=1.12.54">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -362,7 +362,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.52"></script>
<script src="/static/loader.js?v=1.12.54"></script>
</body>
</html>

View File

@ -22,7 +22,10 @@
// API key inputs — persist to localStorage, eye toggle, saved badge
document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
const key = inp.dataset.llmKey;
const saved = localStorage.getItem('llm-key-' + key);
const storageKey = 'llm-key-' + key;
const saved = typeof window.engineApiKeyValue === 'function'
? window.engineApiKeyValue(key, storageKey)
: localStorage.getItem(storageKey);
if (saved) inp.value = saved;
const eye = document.createElement('button');
@ -43,8 +46,9 @@
inp.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => {
if (inp.value) localStorage.setItem('llm-key-' + key, inp.value);
else localStorage.removeItem('llm-key-' + key);
if (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey);
else if (inp.value) localStorage.setItem(storageKey, inp.value);
else localStorage.removeItem(storageKey);
badge.hidden = false;
setTimeout(() => { badge.hidden = true; }, 1800);
}, 600);
@ -750,4 +754,3 @@ window.initCollapsibleCards();
});
});
})();

View File

@ -191,7 +191,7 @@
updateSelCount();
}
}
populateFromLibrary();
window.populateBatchBenchmarkFromLibrary = populateFromLibrary;
async function loadBatchVoicesFromBackend() {
const backend = batchBackendSel.value;
@ -443,6 +443,7 @@
(function initBenchmarkSection() {
let initialized = false;
let benchmarkDataLoaded = false;
let sttEngines = [];
let turnHistoryCount = 0;
@ -1040,10 +1041,17 @@
if (window.BenchmarkVoicePicker) BenchmarkVoicePicker.populate('bench-turn-voice', [], { placeholder: 'Fetch voices', empty: 'No voices' });
else if (q('bench-turn-voice')) q('bench-turn-voice').innerHTML = '<option value="">Fetch voices</option>';
});
}
function loadBenchmarkSectionData() {
bindBenchmarkSection();
if (benchmarkDataLoaded || !q('bench-stt-run')) return;
benchmarkDataLoaded = true;
loadBenchmarkSttEngines();
loadBenchmarkVoiceLibrary();
initTurnControls();
}
window.loadBenchmarkSectionData = loadBenchmarkSectionData;
bindBenchmarkSection();
if (!initialized && document.body) {

View File

@ -1,7 +1,9 @@
// ── ElevenLabs Voice Library Browser ──────────────────────────────────────
(function initElevenLabsBrowser() {
if (!$('el-browser-card')) return;
let _elevenLabsBrowserReady = false;
function initElevenLabsBrowser() {
if (_elevenLabsBrowserReady || !$('el-browser-card')) return;
_elevenLabsBrowserReady = true;
let _elPage = 0, _elHasMore = false, _elTotal = 0;
let _elAudio = null, _elAudioBtn = null;
@ -180,11 +182,15 @@
}
elLoadKey();
})();
loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message));
}
window.initElevenLabsBrowser = initElevenLabsBrowser;
if (document.getElementById('s-studio')?.classList.contains('is-active')) initElevenLabsBrowser();
if (typeof renderIntegrationSnippets === 'function') renderIntegrationSnippets();
// ── Engines section: load containers on startup ────────────────────────────
loadLocalContainers();
// ── Engines section: load containers when that section is visible ──────────
if (document.getElementById('s-llms')?.classList.contains('is-active')) {
loadLocalContainers();
}
// ── Custom engine card storage helpers ────────────────────────────────────
function loadCustomEngineCards() {
@ -442,8 +448,8 @@ function renderLocalContainers(containers) {
// API key row — only for LLMs
const savedApiKey = isCustom
? (c.apiKey || '')
: ((_appSettings?.engine_api_keys?.[c.name]) || localStorage.getItem('dc-apikey-' + n) || '');
? ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || c.apiKey || '')
: ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || (_appSettings?.engine_api_keys?.[c.name]) || localStorage.getItem('dc-apikey-' + n) || '');
const apiKeyRowHtml = (c.role === 'llm' || c.name === 'litellm') ? `
<div class="llm-local-url">
<span class="llm-local-url-label" title="API Key"><span class="mdi mdi-key-outline"></span></span>
@ -594,6 +600,25 @@ function renderLocalContainers(containers) {
}
});
// API key input — save to server-backed engine_api_keys and localStorage fallback
grid.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => {
const key = inp.dataset.dcApikeyKey;
const storageKey = 'dc-apikey-' + key;
const saved = typeof window.engineApiKeyValue === 'function'
? window.engineApiKeyValue(key, storageKey)
: localStorage.getItem(storageKey);
if (saved && inp.value !== saved) inp.value = saved;
let keyTimer;
inp.addEventListener('input', () => {
clearTimeout(keyTimer);
keyTimer = setTimeout(() => {
if (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey);
else if (inp.value) localStorage.setItem(storageKey, inp.value);
else localStorage.removeItem(storageKey);
}, 300);
});
});
// Connect / Disconnect — probe URL, auto-apply setting, show state
grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => {
btn.addEventListener('click', async () => {
@ -752,4 +777,3 @@ function renderLocalContainers(containers) {
}
document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('click', loadLocalContainers));

View File

@ -0,0 +1,923 @@
// ── 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-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-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>'
+ (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>';
}
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 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>'
+ '<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-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'),
])
+ '</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); });
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.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;

View File

@ -40,7 +40,7 @@ window.libraryRender = function (view) {
});
if (view === 'books') libraryRenderBooks();
else if (view === 'plays') libraryRenderPlays();
else if (view === 'characters') libraryRenderCharacters();
else if (view === 'characters' && typeof window.libraryRenderCharacters === 'function') window.libraryRenderCharacters();
};
// ── Books (server-side reader library) ────────────────────────────────────────
@ -309,922 +309,9 @@ function libConfirmDelete(cardEl, heading, sub, onConfirm) {
cardEl.appendChild(o);
}
// ── Characters / Cast ─────────────────────────────────────────────────────────
// Characters/Cast rendering lives in library-characters.js. Keeping it separate
// makes the production overview code smaller and keeps the character workspace isolated.
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 = []; }
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);
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-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-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 = all.find(function (r) { return r.id === 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);
});
});
container.appendChild(prod);
});
}
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>'
+ (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>';
}
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 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>'
+ '<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-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'),
])
+ '</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); });
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.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;
window.libraryRenderBooks = libraryRenderBooks;
window.libraryRenderPlays = libraryRenderPlays;
window.productionOpenInRehearser = productionOpenInRehearser;

View File

@ -244,6 +244,50 @@ function _saveEngineContainerNames() {
if (_appSettings) _appSettings.engine_container_names = names;
}, 800);
}
const ENGINE_API_KEY_SETTING_MAP = {
groq_stt: 'groq_api_key',
elevenlabs: 'elevenlabs_api_key',
};
const _engineApiKeySaveTimers = {};
function engineApiKeyValue(key, localStorageKey = '') {
const settingKey = ENGINE_API_KEY_SETTING_MAP[key];
const fromSetting = settingKey && _appSettings ? (_appSettings[settingKey] || '') : '';
const fromMap = _appSettings?.engine_api_keys?.[key] || '';
const fromLocal = localStorageKey ? localStorage.getItem(localStorageKey) : '';
return fromSetting || fromMap || fromLocal || localStorage.getItem('llm-key-' + key) || localStorage.getItem('dc-apikey-' + key) || '';
}
function saveEngineApiKey(key, value, localStorageKey = '') {
const val = String(value || '').trim();
if (localStorageKey) {
if (val) localStorage.setItem(localStorageKey, val);
else localStorage.removeItem(localStorageKey);
}
const apiKeys = { ...(_appSettings?.engine_api_keys || {}) };
if (val) apiKeys[key] = val;
else delete apiKeys[key];
const patch = { engine_api_keys: apiKeys };
const settingKey = ENGINE_API_KEY_SETTING_MAP[key];
if (settingKey) patch[settingKey] = val;
if (_appSettings) Object.assign(_appSettings, patch);
clearTimeout(_engineApiKeySaveTimers[key]);
_engineApiKeySaveTimers[key] = setTimeout(() => _patchSettings(patch), 600);
}
function restoreEngineApiKeyInputs() {
document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
const key = inp.dataset.llmKey;
const saved = engineApiKeyValue(key, 'llm-key-' + key);
if (saved && !inp.value) inp.value = saved;
});
document.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => {
const key = inp.dataset.dcApikeyKey;
const saved = engineApiKeyValue(key, 'dc-apikey-' + key);
if (saved && !inp.value) inp.value = saved;
});
}
window.engineApiKeyValue = engineApiKeyValue;
window.saveEngineApiKey = saveEngineApiKey;
window.restoreEngineApiKeyInputs = restoreEngineApiKeyInputs;
window._saveEngineLocalUrls = _saveEngineLocalUrls;
window._saveEngineContainerNames = _saveEngineContainerNames;
@ -463,6 +507,7 @@ async function loadSettings() {
sv('s-vd-key', s.voice_design_api_key || '');
sv('s-voices-scan-dir', s.voices_scan_dir || '');
sv('s-output-dir', s.output_dir || '');
restoreEngineApiKeyInputs();
const seedFinderDirEl = $('s-seed-finder-dir');
if (seedFinderDirEl) seedFinderDirEl.value = s.seed_finder_dir || '';
const ptDirEl = $('s-pt-dir');

View File

@ -416,6 +416,42 @@ function addStoredTag(tag) {
try { localStorage.setItem(_TAG_KEY, JSON.stringify(tags.slice(0, 60))); } catch {}
}
// Voice-library rendering can be expensive with large local collections because it
// creates many rich rows and image requests. Schedule automatic refreshes after the
// current startup/navigation task so core routing is interactive first.
const _voiceLibraryLoadCallbacks = [];
const _VOICE_LIBRARY_LOAD_NAV_WAIT_MS = 3500;
function scheduleVoiceLibraryLoad(onDone) {
if (typeof onDone === 'function') _voiceLibraryLoadCallbacks.push(onDone);
if (window.__voiceLibraryLoadScheduled) return;
window.__voiceLibraryLoadScheduled = true;
const scheduledAt = (performance && performance.now) ? performance.now() : Date.now();
const run = () => {
const now = (performance && performance.now) ? performance.now() : Date.now();
if (typeof window.navTo !== 'function' && now - scheduledAt < _VOICE_LIBRARY_LOAD_NAV_WAIT_MS) {
setTimeout(run, 80);
return;
}
window.__voiceLibraryLoadScheduled = false;
if (typeof loadVoiceLibrary !== 'function') {
_voiceLibraryLoadCallbacks.splice(0);
return;
}
loadVoiceLibrary()
.then(() => {
const callbacks = _voiceLibraryLoadCallbacks.splice(0);
callbacks.forEach(fn => { try { fn(); } catch (e) { logErr('scheduleVoiceLibraryLoad callback', e); } });
})
.catch(e => {
_voiceLibraryLoadCallbacks.splice(0);
status('Voice library load failed: ' + (e.message || e));
});
};
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 1200 });
else setTimeout(run, 0);
}
window.scheduleVoiceLibraryLoad = scheduleVoiceLibraryLoad;
// ── Tabs ──────────────────────────────────────────────────────────────────
function disabledBackendTabMessage(tab) {
@ -435,13 +471,22 @@ function switchTab(name) {
}
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
if (name === 'library') loadVoiceLibrary();
if (name === 'library') scheduleVoiceLibraryLoad();
if (name === 'integrations') {
if (!_voices.length) loadVoiceLibrary();
if (!_voices.length) scheduleVoiceLibraryLoad(renderIntegrationSnippets);
renderIntegrationSnippets();
}
if (name === 'performance' && typeof window.populateBatchBenchmarkFromLibrary === 'function') {
window.populateBatchBenchmarkFromLibrary();
}
if (name === 'performance' && typeof window.loadBenchmarkSectionData === 'function') {
window.loadBenchmarkSectionData();
}
if (name === 'routing') loadRoutingTab();
if (name === 'getvoices') loadGetVoices();
if (name === 'getvoices') {
if (typeof window.initElevenLabsBrowser === 'function') window.initElevenLabsBrowser();
loadGetVoices();
}
return true;
}
// ── Language detection ────────────────────────────────────────────────────────
@ -488,5 +533,3 @@ document.addEventListener('click', e => {
sel.dispatchEvent(new Event('change'));
}
});

View File

@ -55,6 +55,7 @@ function _vlCacheClear() {
}
window._vlCacheClear = _vlCacheClear;
const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''};
let _libraryFilterOptionsSig = '';
const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.';
const BENCHMARK_PRESETS = {
@ -308,12 +309,25 @@ function populateLibraryFilters() {
const sexSel = $('library-filter-sex');
const typeSel = $('library-filter-type');
if (!langSel || !sexSel || !typeSel) return;
const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value};
const langs = [...new Set((_voices || []).map(libraryVoiceLang).filter(Boolean))].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b)));
const langSet = new Set();
const sexSet = new Set();
const typeSet = new Set();
(_voices || []).forEach(v => {
const lang = libraryVoiceLang(v);
if (lang) langSet.add(lang);
if (v.gender) sexSet.add(v.gender);
const type = voiceFileType(v);
if (type) typeSet.add(type);
});
const langs = [...langSet].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b)));
const sexOrder = ['F','M','N'];
const sexes = [...new Set((_voices || []).map(v => v.gender || '').filter(Boolean))]
const sexes = [...sexSet]
.sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b)));
const types = [...new Set((_voices || []).map(voiceFileType).filter(Boolean))].sort();
const types = [...typeSet].sort();
const sig = JSON.stringify([langs, sexes, types]);
if (sig === _libraryFilterOptionsSig) return;
_libraryFilterOptionsSig = sig;
const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value};
langSel.innerHTML = '<option value="">All languages</option>' + langs.map(x => `<option value="${escHtml(x)}">${escHtml(libraryLanguageLabel(x))} (${escHtml(x)})</option>`).join('');
sexSel.innerHTML = '<option value="">All</option>' + sexes.map(x => `<option value="${escHtml(x)}">${escHtml(SEX_FILTER_LABELS[x] || x)}</option>`).join('');
typeSel.innerHTML = '<option value="">All</option>' + types.map(x => `<option value="${escHtml(x)}">${escHtml(x.toUpperCase())}</option>`).join('');
@ -620,11 +634,21 @@ function updateLibraryInsights(state = 'ready') {
});
}
function shouldRenderVoiceLibrary() {
const section = $('s-voices');
return !section || section.classList.contains('is-active');
}
async function loadVoiceLibrary() {
if (_libraryLoadPromise) return _libraryLoadPromise;
if (_libraryLoadPromise) {
return _libraryLoadPromise.then(() => {
if (shouldRenderVoiceLibrary() && _voices.length) renderVoiceList();
});
}
_libraryLoadPromise = (async () => {
setBusyButton('refresh-voices-btn', true);
const list = $('voice-list');
const renderVisibleList = shouldRenderVoiceLibrary();
// Serve from sessionStorage cache immediately so the list is never blank on reload
const cached = _voices.length === 0 ? _vlCacheRead() : null;
@ -632,15 +656,15 @@ async function loadVoiceLibrary() {
_voices = cached;
window._voices = _voices;
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList();
if (renderVisibleList) renderVoiceList();
updatePreviewVoiceMatchPanel();
status(`Loaded ${_voices.length} voices`);
}
// Always background-fetch fresh data; skip skeleton if something is already shown
const silent = _voices.length > 0;
if (list && !silent) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8);
if (!silent) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); }
if (list && !silent && renderVisibleList) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8);
if (!silent && renderVisibleList) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); }
try {
const r = await fetch('/api/voices');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
@ -649,12 +673,12 @@ async function loadVoiceLibrary() {
_voices = fresh;
window._voices = _voices;
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList();
if (shouldRenderVoiceLibrary()) renderVoiceList();
updatePreviewVoiceMatchPanel();
if (typeof renderPerfHistory === 'function') renderPerfHistory();
status(`Loaded ${_voices.length} voices`);
} catch(e) {
if (!silent) {
if (!silent && renderVisibleList) {
if (list) list.innerHTML = '<div style="color:var(--red);padding:8px;font-size:13px">Failed to load voices: ' + (e.message||String(e)) + '</div>';
$('voice-count').textContent = 'Load failed';
updateLibraryInsights('error');
@ -1820,7 +1844,11 @@ function renderVoiceList() {
return;
}
if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
else filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
else {
const frag = document.createDocumentFragment();
filtered.forEach(v => frag.appendChild(makeVoiceRow(v)));
list.appendChild(frag);
}
if (_pendingSelectId) {
const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);
if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
@ -1845,6 +1873,7 @@ function renderVoicesByTag(list, filtered) {
const names = [...groups.keys()].sort((a, b) =>
a === UNTAG ? 1 : b === UNTAG ? -1 : a.toLowerCase().localeCompare(b.toLowerCase()));
const outer = document.createDocumentFragment();
names.forEach(tag => {
const voices = groups.get(tag);
const isCol = !!collapsed[tag];
@ -1860,7 +1889,9 @@ function renderVoicesByTag(list, filtered) {
const body = document.createElement('div');
body.className = 'vl-tag-body';
if (isCol) body.style.display = 'none';
voices.forEach(v => body.appendChild(makeVoiceRow(v)));
const rows = document.createDocumentFragment();
voices.forEach(v => rows.appendChild(makeVoiceRow(v)));
body.appendChild(rows);
head.addEventListener('click', () => {
const nowCol = body.style.display !== 'none';
body.style.display = nowCol ? 'none' : '';
@ -1872,8 +1903,9 @@ function renderVoicesByTag(list, filtered) {
});
sec.appendChild(head);
sec.appendChild(body);
list.appendChild(sec);
outer.appendChild(sec);
});
list.appendChild(outer);
}
async function decodeVoiceAudio(v) {
@ -3493,4 +3525,3 @@ async function _bulkSetEnabled(ids, enabled) {
}
return done;
}

View File

@ -124,7 +124,7 @@
// settings.js completes, so this await is typically instant.
await _bgSections;
// C — feature modules: always try the bundle first (one request instead of 21).
// C — feature modules: always try the bundle first (one request instead of 23).
// Falls back to individual files if the bundle is missing or fails to load.
var _featureFiles = [
'/static/js/voice-picker.js',
@ -149,18 +149,20 @@
'/static/js/characters-library.js',
'/static/js/sillytavern.js',
'/static/js/library.js',
'/static/js/library-characters.js',
];
var _bundleOk = false;
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
catch (_) { _bundleOk = false; }
if (!_bundleOk) await _loadBatch(_featureFiles);
// D — init (needs everything above to be defined)
try { await _load('/static/js/init.js'); } catch (_) {}
// F — navigation (must run before revealing UI so the correct section shows)
// D — navigation (must run before init/reveal so routing is interactive before
// async settings or voice-library refreshes can occupy the backend).
try { await _load('/static/nav.js'); } catch (_) {}
// E — init (needs feature modules and nav globals to be defined)
try { await _load('/static/js/init.js'); } catch (_) {}
// ── Remove skeleton, reveal sections ────────────────────────────────────
// Always runs — even if a script above failed to load, showing a partial UI
// is far better than leaving the skeleton on screen indefinitely.

View File

@ -46,13 +46,18 @@
function runSideEffects(name) {
if ((name === 'source' || name === 'save') && typeof initCloneSampleText === 'function') initCloneSampleText();
if (name === 'library' && typeof loadVoiceLibrary === 'function') loadVoiceLibrary();
if (name === 'library' && typeof scheduleVoiceLibraryLoad === 'function') scheduleVoiceLibraryLoad();
if ((name === 'integrations' || name === 'howto') && typeof renderIntegrationSnippets === 'function') {
if (typeof loadVoiceLibrary === 'function' && !(window._voices && window._voices.length)) loadVoiceLibrary();
if (typeof scheduleVoiceLibraryLoad === 'function' && !(window._voices && window._voices.length)) scheduleVoiceLibraryLoad(renderIntegrationSnippets);
renderIntegrationSnippets();
}
if (name === 'routing' && typeof loadRoutingTab === 'function') loadRoutingTab();
if (name === 'getvoices' && typeof loadGetVoices === 'function') loadGetVoices();
if (name === 'getvoices') {
if (typeof window.initElevenLabsBrowser === 'function') window.initElevenLabsBrowser();
if (typeof loadGetVoices === 'function') loadGetVoices();
}
if (name === 'performance' && typeof window.populateBatchBenchmarkFromLibrary === 'function') window.populateBatchBenchmarkFromLibrary();
if (name === 'performance' && typeof window.loadBenchmarkSectionData === 'function') window.loadBenchmarkSectionData();
}
function applySettingsPage(cat) {

View File

@ -2,43 +2,39 @@
.header-cat-block { display: none !important; }
.header-cat-block.is-active { display: flex !important; }
</style>
<div class="section-head" style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
<div style="display:flex; gap:16px; align-items:center;">
<div class="section-head engines-page-head">
<div class="engines-head-main">
<span class="section-icon"><span class="mdi mdi-brain"></span></span>
<div class="section-title">
<h2>Engines</h2>
<p style="margin:0">Local and cloud services for language generation, speech recognition, and synthesis.</p>
<p>Local and cloud services for language generation, speech recognition, and synthesis.</p>
</div>
</div>
<div style="flex: 1; display: flex; justify-content: flex-end; text-align: right;">
<div class="llm-sec-header header-cat-block" data-engines-cat="llm" style="border:none; padding:0; background:none; align-items: flex-end; flex-direction: column;">
<div style="max-width: 600px;">
<h3 class="llm-sec-title">Language Models</h3>
<p class="llm-sec-note" style="margin-bottom:0">Pick the <strong>Active Language Model</strong> below — it is used by default for every LLM task (persona rewriting, transcription refinement, conversation, and rehearser character analysis). The cards below let you connect engines and apply a URL with <strong>Use as LLM</strong>.</p>
</div>
</div>
<div class="llm-sec-header header-cat-block" data-engines-cat="stt" style="border:none; padding:0; background:none; align-items: flex-end; flex-direction: column;">
<div style="max-width: 600px;">
<h3 class="llm-sec-title">Speech Recognition</h3>
<p class="llm-sec-note">Your Docker stack containers appear at the top. Local runners and cloud APIs below.</p>
</div>
<div class="engines-head-context header-cat-block" data-engines-cat="llm">
<div class="engines-context-copy">
<h3 class="llm-sec-title">Language Models</h3>
<p class="llm-sec-note">Pick the <strong>Active Language Model</strong> below. Cards connect engines and apply a URL with <strong>Use as LLM</strong>.</p>
</div>
</div>
<div class="llm-sec-header header-cat-block" data-engines-cat="tts" style="border:none; padding:0; background:none; align-items: flex-end; flex-direction: column;">
<div style="max-width: 600px;">
<h3 class="llm-sec-title">Text to Speech</h3>
<p class="llm-sec-note">Docker-detected engines and local presets are listed together here. Click <strong>Use as TTS</strong> to apply a URL to this app&rsquo;s backend settings.</p>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
<button class="btn-secondary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
<button class="btn-secondary dc-refresh-btn" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
<span class="llm-free-badge llm-free-local">100% Local</span>
</div>
<div class="engines-head-context header-cat-block" data-engines-cat="stt">
<div class="engines-context-copy">
<h3 class="llm-sec-title">Speech Recognition</h3>
<p class="llm-sec-note">Docker stack containers appear first, with local runners and cloud APIs below.</p>
</div>
</div>
<div class="engines-head-context header-cat-block" data-engines-cat="tts">
<div class="engines-context-copy">
<h3 class="llm-sec-title">Text to Speech</h3>
<p class="llm-sec-note">Docker-detected engines and local presets are listed together. Apply a URL with <strong>Use as TTS</strong>.</p>
</div>
<div class="engines-context-actions">
<button class="btn-secondary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
<button class="btn-secondary dc-refresh-btn" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
<span class="llm-free-badge llm-free-local">100% Local</span>
</div>
</div>
</div>

View File

@ -148,7 +148,7 @@
</div><!-- /voices-workbench -->
<!-- Hidden scaffold — IDs required by app.js but not shown in new layout -->
<!-- Hidden scaffold — IDs required by voice-library.js but not shown in new layout -->
<div id="vl-scaffold" style="display:none" aria-hidden="true">
<select id="library-filter-sex"><option value="">All</option><option value="F">F</option><option value="M">M</option><option value="N">N</option></select>

View File

@ -2379,6 +2379,48 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; margin-bottom: 18px; flex-wrap: wrap;
}
.engines-page-head {
align-items: stretch;
flex-direction: column;
gap: 12px;
padding: 18px 20px;
}
.engines-head-main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.engines-head-context {
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.engines-context-copy {
min-width: 0;
}
.engines-context-copy .llm-sec-note {
max-width: none;
}
.engines-context-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
flex-shrink: 0;
}
@media (max-width: 900px) {
.engines-head-context {
align-items: flex-start;
flex-direction: column;
}
.engines-context-actions {
justify-content: flex-start;
}
}
.llm-sec-title { font-size: 15px; font-weight: 700; color: var(--text); margin: 0 0 4px; }
.llm-sec-note { font-size: 13px; color: var(--subtext); margin: 0; line-height: 1.5; max-width: 640px; }
.llm-free-badge {