Optimize startup and persist engine keys
This commit is contained in:
parent
586dc3dc3f
commit
bc6c4842b5
28
CHANGELOG.md
28
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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": "",
|
||||
|
||||
@ -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"])
|
||||
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
8892
static/app.js
8892
static/app.js
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
923
static/js/library-characters.js
Normal file
923
static/js/library-characters.js
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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’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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user