// ── Library Characters / Cast workspace ───────────────────────────────────── // Loaded after library.js; uses its shared production helpers and exports // libraryRenderCharacters for the Library section router. // ── Characters / Cast ───────────────────────────────────────────────────────── (function (root) { let _libCharsRenderSeq = 0; async function _libReviewVisualPairs(button, container) { if (!button) return; button.disabled = true; const oldLabel = button.innerHTML; button.innerHTML = ' Reviewing…'; try { const response = await fetch('/api/characters/visual-pair-review'); const payload = await response.json(); if (!response.ok) throw new Error(payload.detail || 'Visual-pair review failed'); let panel = container.querySelector('#lib-chars-visual-review'); if (!panel) { panel = document.createElement('div'); panel.id = 'lib-chars-visual-review'; panel.className = 'lib-chars-visual-review'; container.insertBefore(panel, container.firstChild); } if (!payload.issues || !payload.issues.length) { panel.innerHTML = ' Visual pairs passed the metadata and asset-reuse checks (' + payload.checked + ' characters reviewed).'; if (typeof toast === 'function') toast('Visual pair review passed', 'success'); } else { panel.innerHTML = ' Review ' + payload.issues.length + ' character pair' + (payload.issues.length === 1 ? '' : 's') + ': ' + payload.issues.map(function (item) { return '' + escHtml(item.name || item.id) + (item.form_name ? ' / ' + escHtml(item.form_name) : '') + ' — ' + escHtml(item.issues.join(', ')) + ''; }).join(''); if (typeof toast === 'function') toast(payload.issues.length + ' visual pair(s) need review', 'warning'); } } catch (error) { if (typeof toast === 'function') toast(error.message || 'Visual-pair review failed', 'error'); } finally { button.disabled = false; button.innerHTML = oldLabel; } } let _libCharsCache = null; // Studio hands this view a single book (Voices step); the sidebar/Library entry // shows every book. The view is "scoped" exactly while #lib-chars-list is // borrowed into Studio's Voices slot, so leaving Studio always restores the // global view without any flag to reset. function _libShowAllBooks() { if (typeof window.studioShowAllCharacters === 'function') window.studioShowAllCharacters(); else if (typeof window.navLibraryView === 'function') window.navLibraryView('characters'); } function _libCharHaystack(rec) { const sh = rec.sheet || {}; const parts = [rec.name, rec.form_name, rec.book, sh.archetype, sh.species, sh.real_name, sh.profession]; ['aliases', 'alias', 'also_known_as'].forEach(function (k) { const v = sh[k]; if (Array.isArray(v)) parts.push(v.join(' ')); else if (v) parts.push(v); }); return parts.filter(function (x) { return typeof x === 'string' || typeof x === 'number'; }).join(' ').toLowerCase(); } async function libraryRenderCharacters(opts) { const container = document.getElementById('lib-chars-list'); if (!container) return; const useCache = !!(opts && opts.cached === true && _libCharsCache); const renderSeq = ++_libCharsRenderSeq; // This page can open before the shared voice library has finished loading. // Ratings live in window._voices, so rendering during that gap makes every // assigned voice look unrated even when its .meta.json still has a rating. if (!useCache && (!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') { try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); } if (renderSeq !== _libCharsRenderSeq) return; } // Fetch BEFORE touching the DOM: a transient failure here (e.g. the // server briefly unresponsive while a TTS container restart is in // flight) must never blank out an already-populated list — confirmed // live as "No characters yet" flashing up over a real, populated cast // while a bulk voice-design run elsewhere was mid-restart. Leave whatever // is already on screen alone and let the next real refresh fix it. let all = []; if (useCache) all = _libCharsCache; else try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } catch (e) { console.warn('[characters] load failed', e); if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error'); return; } if (renderSeq !== _libCharsRenderSeq) return; _libCharsCache = all; // Every action on this page (remove voice, auto-design, delete...) re-runs // this full rebuild via `refresh()`. Collapsing the container down to a // one-line loading placeholder mid-rebuild shrinks #main-content below the // user's current scroll position, which the browser clamps back to fit — // confirmed live as "the screen jumps to the top" after every single // action. Restore it once the real content is back, unless a caller // explicitly wants to jump to a specific book (see _libCharsScrollToBook // below), which takes priority over just staying put. const mainEl = document.getElementById('main-content'); const savedScrollTop = (!window._libCharsScrollToBook && mainEl) ? mainEl.scrollTop : null; container.innerHTML = '
Voices are assigned per book. Choose one of your uploaded books in the Source step, then come back here to see only its characters.
' + 'No characters yet.
' + 'Open a book in Read Aloud, cast it as an audiobook, then click Cast Characters to generate character sheets — or import an existing cast from SillyTavern.
' + '| ' + th('alpha', 'Name') + th('gender', 'Geschlecht') + th('age', 'Alter', 'Estimated age') + th('lines', 'Zeilen', 'Anzahl Zeilen') + th('language', 'Sprache') + th('align', 'Gut/Böse', 'Moralische Gesinnung') + th('profile', 'Profil (gefüllt)', 'Sort by filled profile fields; the second number is always 32') + th('voice', 'Stimme') + th('rating', 'Rating', 'Rate the assigned voice') + ' | Voice Design Prompt | Notes | ' + 'Tags | Book / Script | ' + ' |
|---|
One character, one voice — a form only changes how they look (a disguise, a transformation). ' + 'Select a form to edit its Physical / Clothing text below; the images are generated per form.
' + (characterForms.length ? 'Used in every voice design (and image) prompt for this book, so a fantasy story doesn\'t end up with 1920s-general portraits or English voices in a German book just because one character\'s own sheet was too sparse to tell.
' + '"' + escHtml(reuse.voiceId) + '" is already used for ' + escHtml(rec.name) + ' in "' + escHtml(reuse.book) + '". Reuse it for series consistency, or design a brand-new voice just for this book?
' + 'Edit the description, then generate a new voice from it. This replaces ' + (rec.voice ? 'the current voice' : 'this character’s voice') + '.
' + '' + '