tts-voice-creator-clone-and.../static/js/characters-library.js
mARTin-B78 495550bf6a Release v1.9.0 — Character Library + richer character sheets
Add a persistent, book-scoped Character Library (new Characters section,
IndexedDB) that auto-fills from Character-sheet analysis with editable cards.
Enrich extraction with six narrative fields (backstory, relationships,
motivation, fears, mannerisms, voice/speech) plus the greyscale Good↔Evil
alignment bar, arc arrow, and 5-area Deep Analysis. Add ⋯ separators between
non-contiguous passages in Recast unknown, and harden dialogue attribution
against hallucination with same-language emotion tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:22:40 +02:00

282 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Character library ─────────────────────────────────────────────────────────
//
// Persistent, book-scoped character sheets. Running "Character sheets" in Read
// Aloud or the Rehearser upserts each extracted character into this library,
// keyed by (book + name). Cards reuse the renderers from character-sheets.js
// (csCardHtml, csAlignmentBar, csDeepAnalysis), which loads before this module.
const CL_DB_NAME = 'character-library';
const CL_STORE = 'characters';
// Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics.
const CL_EDIT_FIELDS = [
['name', 'Name'], ['aliases', 'Aliases'], ['archetype', 'Archetype'],
['physical', 'Physical'], ['clothing', 'Clothing & Appearance'],
['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'],
['skills', 'Trained Skills'], ['capabilities', 'Capabilities'],
['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'],
['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'],
['voice_pattern', 'Voice & Speech'], ['secret', 'Dark Secret / Fatal Flaw'],
['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'],
];
// ── IndexedDB ─────────────────────────────────────────────────────────────────
function clDbOpen() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(CL_DB_NAME, 1);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(CL_STORE)) {
const store = db.createObjectStore(CL_STORE, { keyPath: 'id' });
store.createIndex('book', 'book', { unique: false });
}
};
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async function clDbOp(mode, fn) {
const db = await clDbOpen();
return new Promise((resolve, reject) => {
const tx = db.transaction(CL_STORE, mode);
const req = fn(tx.objectStore(CL_STORE));
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async function clGetAll() {
const db = await clDbOpen();
return new Promise((resolve, reject) => {
const req = db.transaction(CL_STORE, 'readonly').objectStore(CL_STORE).getAll();
req.onsuccess = e => resolve(e.target.result || []);
req.onerror = e => reject(e.target.error);
});
}
async function clGet(id) { return clDbOp('readonly', s => s.get(id)); }
async function clPut(rec) { return clDbOp('readwrite', s => s.put(rec)); }
async function clDelete(id) { return clDbOp('readwrite', s => s.delete(id)); }
function clKey(book, name) {
return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase();
}
// Merge an incoming sheet into an existing one without blanking solid facts —
// same rule as csMerge in character-sheets.js (longer value wins; sources append;
// alignment score averages).
function clMergeSheet(existing, incoming) {
const e = { ...existing };
const scalars = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS
: CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name');
scalars.forEach(f => { if ((incoming[f] || '').length > (e[f] || '').length) e[f] = incoming[f]; });
if (incoming.tier === 'main') e.tier = 'main';
if (incoming.moral_alignment_score != null) {
e.moral_alignment_score = e.moral_alignment_score != null
? Math.round((e.moral_alignment_score + incoming.moral_alignment_score) / 2)
: incoming.moral_alignment_score;
}
if (incoming.arc_direction && incoming.arc_direction !== 'neutral') e.arc_direction = incoming.arc_direction;
e.inventory = [...(existing.inventory || [])];
(incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
e.sources = [...(existing.sources || [])];
(incoming.sources || []).forEach(src => {
if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
});
return e;
}
// Upsert one sheet into the library under a book. Returns the stored record.
async function clUpsert(book, sheet) {
const name = (sheet.name || '').trim();
if (!name) return null;
const bk = (book || '').trim() || 'Unsorted';
const id = clKey(bk, name);
const now = new Date();
const prev = await clGet(id).catch(() => null);
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sheet, name };
const rec = {
id, book: bk, name,
sheet: merged,
analysis: prev?.analysis || null,
voice: prev?.voice || null,
created: prev?.created || now,
updated: now,
};
await clPut(rec);
return rec;
}
// Bulk upsert a list of sheets for one book (used by the analysis side-effect).
async function clUpsertMany(book, sheets) {
let n = 0;
for (const s of (sheets || [])) { if (await clUpsert(book, s)) n++; }
return n;
}
// ── Rendering ─────────────────────────────────────────────────────────────────
let _clRecords = [];
async function clRender() {
const grid = document.getElementById('cl-grid');
if (!grid) return;
// Bind filter/search once the section fragment exists (it loads after this JS).
const filterEl = document.getElementById('cl-book-filter');
const searchEl = document.getElementById('cl-search');
if (filterEl && !filterEl.dataset.bound) { filterEl.dataset.bound = '1'; filterEl.addEventListener('change', clApplyFilter); }
if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); }
try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; }
// Populate the book filter (preserve current selection if still present).
const filter = document.getElementById('cl-book-filter');
const books = [...new Set(_clRecords.map(r => r.book))].sort((a, b) => a.localeCompare(b));
if (filter) {
const cur = filter.value;
filter.innerHTML = `<option value="">All books (${books.length})</option>` +
books.map(b => `<option value="${escHtml(b)}">${escHtml(b)}</option>`).join('');
if (cur && books.includes(cur)) filter.value = cur;
}
clApplyFilter();
}
function clApplyFilter() {
const grid = document.getElementById('cl-grid');
if (!grid) return;
const book = document.getElementById('cl-book-filter')?.value || '';
const q = (document.getElementById('cl-search')?.value || '').trim().toLowerCase();
let recs = _clRecords.slice();
if (book) recs = recs.filter(r => r.book === book);
if (q) recs = recs.filter(r =>
(r.name || '').toLowerCase().includes(q) ||
(r.sheet?.aliases || '').toLowerCase().includes(q) ||
(r.sheet?.archetype || '').toLowerCase().includes(q) ||
(r.book || '').toLowerCase().includes(q));
if (!recs.length) {
grid.innerHTML = `<div class="cl-empty">
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
<p>${_clRecords.length ? 'No characters match your filter.' : 'No characters yet.'}</p>
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your library.</p>
</div>`;
return;
}
// Group by book.
const byBook = new Map();
for (const r of recs) { if (!byBook.has(r.book)) byBook.set(r.book, []); byBook.get(r.book).push(r); }
const groups = [...byBook.keys()].sort((a, b) => a.localeCompare(b));
grid.innerHTML = groups.map(bk => {
const list = byBook.get(bk).sort((a, b) => {
const t = (a.sheet?.tier === 'main' ? 0 : 1) - (b.sheet?.tier === 'main' ? 0 : 1);
return t || (a.name || '').localeCompare(b.name || '');
});
return `<div class="cl-book-group">
<div class="cl-book-head"><span class="mdi mdi-book-open-page-variant-outline"></span> ${escHtml(bk)}
<span class="cl-book-count">${list.length}</span></div>
<div class="cl-book-cards">${list.map(clCardHtml).join('')}</div>
</div>`;
}).join('');
}
function clCardHtml(rec) {
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
<div class="cl-card-tools">
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
</div>
${csCardHtml(rec.sheet)}
</div>`;
}
// ── Edit modal ──────────────────────────────────────────────────────────────
function clEdit(id) {
const rec = _clRecords.find(r => r.id === id);
if (!rec) return;
const s = rec.sheet || {};
const modal = document.createElement('div');
modal.className = 'audiobook-overlay';
const rows = CL_EDIT_FIELDS.map(([k, label]) => {
const multiline = !['name', 'aliases', 'archetype'].includes(k);
const val = escHtml(String(s[k] || ''));
return `<label class="cl-edit-row"><span>${label}</span>${
multiline
? `<textarea data-field="${k}" rows="2">${val}</textarea>`
: `<input type="text" data-field="${k}" value="${val}">`
}</label>`;
}).join('');
modal.innerHTML = `<div class="audiobook-box cl-edit-box">
<div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-pencil-outline"></span> Edit — ${escHtml(rec.name)}</span>
<span style="flex:1"></span>
<button class="btn-secondary btn-sm" id="cl-edit-cancel">Cancel</button>
<button class="btn-primary btn-sm" id="cl-edit-save"><span class="mdi mdi-content-save-outline"></span> Save</button>
</div>
<div class="cl-edit-body">
<label class="cl-edit-row"><span>Moral alignment (0 evil 100 good)</span>
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
<label class="cl-edit-row"><span>Arc direction</span>
<select data-field="arc_direction">${
['stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex']
.map(o => `<option value="${o}"${(s.arc_direction || 'neutral') === o ? ' selected' : ''}>${o}</option>`).join('')
}</select></label>
${rows}
</div>
</div>`;
document.body.appendChild(modal);
const close = () => modal.remove();
modal.querySelector('#cl-edit-cancel').addEventListener('click', close);
modal.addEventListener('click', e => { if (e.target === modal) close(); });
modal.querySelector('#cl-edit-save').addEventListener('click', async () => {
const patch = {};
modal.querySelectorAll('[data-field]').forEach(el => { patch[el.dataset.field] = el.value; });
const newName = (patch.name || '').trim() || rec.name;
let mas = parseInt(patch.moral_alignment_score, 10);
mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas));
const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: mas };
// If the name changed, the key changes too — delete old, write new.
const newId = clKey(rec.book, newName);
rec.sheet = newSheet; rec.name = newName; rec.updated = new Date();
try {
if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; }
await clPut(rec);
toast('Character saved', 'success');
close();
clRender();
} catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
});
}
async function clDeleteRec(id) {
const rec = _clRecords.find(r => r.id === id);
if (!rec) return;
if (!confirm(`Delete "${rec.name}" from ${rec.book}?`)) return;
try { await clDelete(id); toast('Deleted', 'success'); clRender(); }
catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
}
// ── Wiring ──────────────────────────────────────────────────────────────────
// Document-level delegation — the #cl-grid is (re)built on every render and the
// section fragment loads after this script, so we can't bind to it directly.
document.addEventListener('click', e => {
const edit = e.target.closest?.('.cl-edit');
if (edit) { e.stopPropagation(); clEdit(edit.dataset.id); return; }
const del = e.target.closest?.('.cl-delete');
if (del) { e.stopPropagation(); clDeleteRec(del.dataset.id); return; }
const deep = e.target.closest?.('.cl-card-wrap .cs-deep-btn');
if (deep) {
e.stopPropagation();
const rec = _clRecords.find(r => r.id === deep.closest('.cl-card-wrap')?.dataset.id);
if (rec && typeof csDeepAnalysis === 'function') csDeepAnalysis(rec.sheet, '');
}
});
window.clRender = clRender;
window.clUpsertMany = clUpsertMany;