Release v1.14.19

This commit is contained in:
mARTin-B78 2026-07-09 14:11:40 +02:00
parent 17716ac740
commit 8f3fcbae04
12 changed files with 278 additions and 162 deletions

View File

@ -9,6 +9,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.14.19] — 2026-07-09
### Changed
- **Cast Characters is now a dedicated page instead of a popup** — the audiobook cast flow now has a new `Cast Characters` stage between `Cast Audiobook` and `Cast`, with the old `Cast Characters` stage renamed to `Cast`. The new page keeps the cast-character content and actions, but uses the same large-page layout as `Cast Audiobook` so the UI is easier to work with.
### Fixed
- **Conversation Playground thinking text was no longer visible while streaming** — the reasoning panel now auto-opens as soon as the first `thinking` chunk arrives, instead of staying collapsed until clicked.
## [1.14.18] — 2026-07-09 ## [1.14.18] — 2026-07-09
### Fixed ### Fixed
@ -1631,7 +1639,8 @@ Initial feature-complete release.
--- ---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.1...HEAD [Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.19...HEAD
[1.14.19]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.18...v1.14.19
[1.8.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...v1.8.1 [1.8.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.8.0...v1.8.1
[1.8.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.7.0...v1.8.0 [1.8.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.7.0...v1.8.0
[1.2.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...v1.2.0 [1.2.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...v1.2.0

View File

@ -1 +1 @@
1.14.18 1.14.19

View File

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

View File

@ -3291,7 +3291,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const continueBtnHtml = resumable const continueBtnHtml = resumable
? `<button class="btn-primary btn-sm" id="ab-cv-continue" style="margin-right:8px;" title="Continue casting from passage ${_audiobook.completedChunks + 1} of ${_audiobook.completedTotal}, keeping already-cast passages"><span class="mdi mdi-play-circle-outline"></span> Continue casting</button>` ? `<button class="btn-primary btn-sm" id="ab-cv-continue" style="margin-right:8px;" title="Continue casting from passage ${_audiobook.completedChunks + 1} of ${_audiobook.completedTotal}, keeping already-cast passages"><span class="mdi mdi-play-circle-outline"></span> Continue casting</button>`
: ''; : '';
foot.innerHTML = `<span style="flex:1"></span>${continueBtnHtml}<button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality run — resolves Unknown speakers with stricter context checks while preserving the existing cast"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-secondary btn-sm" id="ab-cv-cast-chars" style="margin-right:8px;" title="Generate character sheets and match voices to each found character"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button><button class="btn-secondary btn-sm" id="ab-cv-export-md" style="margin-right:8px;" title="Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character"><span class="mdi mdi-folder-zip-outline"></span> Export cast .zip</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Assign voices to each character and synthesise in Script Rehearser"><span class="mdi mdi-drama-masks"></span> Edit Characters</button>`; foot.innerHTML = `<span style="flex:1"></span>${continueBtnHtml}<button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality run — resolves Unknown speakers with stricter context checks while preserving the existing cast"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-secondary btn-sm" id="ab-cv-cast-chars" style="margin-right:8px;" title="Open the character-sheets stage for this book"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button><button class="btn-secondary btn-sm" id="ab-cv-export-md" style="margin-right:8px;" title="Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character"><span class="mdi mdi-folder-zip-outline"></span> Export cast .zip</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Assign voices to each character and synthesise in Script Rehearser"><span class="mdi mdi-drama-masks"></span> Edit Characters</button>`;
const runVerificationPass = () => { const runVerificationPass = () => {
const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern. const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern.
@ -3388,7 +3388,7 @@ ABSOLUTE REGELN:
const wrap = document.createElement('span'); const wrap = document.createElement('span');
wrap.className = 'ab-cv-castchars-group'; wrap.className = 'ab-cv-castchars-group';
wrap.innerHTML = wrap.innerHTML =
'<button class="btn-secondary btn-sm ab-cv-castchars-view" title="Open this book\'s characters in the Library"><span class="mdi mdi-account-box-multiple-outline"></span> View Characters</button>' + '<button class="btn-secondary btn-sm ab-cv-castchars-view" title="Open this book\'s cast in the Library"><span class="mdi mdi-account-box-multiple-outline"></span> View Cast</button>' +
'<button class="btn-secondary btn-sm ab-cv-castchars-menu" title="Recast options"><span class="mdi mdi-chevron-down"></span></button>'; '<button class="btn-secondary btn-sm ab-cv-castchars-menu" title="Recast options"><span class="mdi mdi-chevron-down"></span></button>';
castBtn.replaceWith(wrap); castBtn.replaceWith(wrap);
wrap.querySelector('.ab-cv-castchars-view').addEventListener('click', () => { wrap.querySelector('.ab-cv-castchars-view').addEventListener('click', () => {

View File

@ -63,6 +63,7 @@ function csLlmUrl() {
} }
function csLlmModel() { return $('reh-llm-model')?.value || ''; } function csLlmModel() { return $('reh-llm-model')?.value || ''; }
function csLang() { return $('reh-design-lang')?.value || ''; } function csLang() { return $('reh-design-lang')?.value || ''; }
function csReaderPageHost() { return $('reader-charsheets-panel'); }
// Build page-annotated text from the current reader scope ([p.N] at page changes). // Build page-annotated text from the current reader scope ([p.N] at page changes).
function csReaderText() { function csReaderText() {
@ -324,12 +325,12 @@ async function csGenerateStream(body, onDelta, outerSignal, idleTimeoutMs = 6000
} }
} }
async function csGenerate(text, cacheKey, initialRoster) { async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
if (_cs.running) return null; if (_cs.running) return null;
if (!text) { toast('Nothing to analyse', 'error'); return null; } if (!text) { toast('Nothing to analyse', 'error'); return null; }
const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text]; const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text];
_cs.running = true; _cs.cancel = false; _cs.running = true; _cs.cancel = false;
const prog = csProgress(chunks.length); const prog = csProgress(chunks.length, opts.pageHost || null);
const llm_url = csLlmUrl(), model = csLlmModel(); const llm_url = csLlmUrl(), model = csLlmModel();
const selectedLanguage = csLang(); const selectedLanguage = csLang();
let language = selectedLanguage; let language = selectedLanguage;
@ -409,16 +410,24 @@ async function csGenerate(text, cacheKey, initialRoster) {
return sheets; return sheets;
} }
function csProgress(total) { function csProgress(total, hostEl = null) {
let ov = document.getElementById('cs-progress'); const usingHost = !!hostEl;
if (!ov) { let root = usingHost ? hostEl : document.getElementById('cs-progress');
ov = document.createElement('div'); if (!root) {
ov.id = 'cs-progress'; ov.className = 'audiobook-overlay'; if (usingHost) return null;
root = document.createElement('div');
root.id = 'cs-progress';
root.className = 'audiobook-overlay';
document.body.appendChild(root);
}
const needsShell = !root.querySelector('#cs-progress-fill');
if (needsShell) {
if (usingHost) root.innerHTML = '';
// Big two-pane layout, mirroring the audiobook casting live view: the // Big two-pane layout, mirroring the audiobook casting live view: the
// passage being analysed on the left, the LLM's raw JSON output filling // passage being analysed on the left, the LLM's raw JSON output filling
// in live on the right, and a character sidebar (current one highlighted) // in live on the right, and a character sidebar (current one highlighted)
// so the user can watch each sheet actually being built. // so the user can watch each sheet actually being built.
ov.innerHTML = `<div class="audiobook-box cs-progress-box cs-progress-box-big"> root.innerHTML = `<div class="audiobook-box cs-progress-box cs-progress-box-big">
<div class="cs-progress-head"> <div class="cs-progress-head">
<div class="audiobook-title"> <div class="audiobook-title">
<span class="mdi mdi-account-details-outline"></span> Character sheets <span class="mdi mdi-account-details-outline"></span> Character sheets
@ -456,46 +465,47 @@ function csProgress(total) {
</div> </div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div> <div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
</div>`; </div>`;
document.body.appendChild(ov); root.querySelector('#cs-progress-cancel')?.addEventListener('click', () => { _cs.cancel = true; });
ov.querySelector('#cs-progress-cancel').addEventListener('click', () => { _cs.cancel = true; });
// Prompt panel — mirrors the audiobook casting "Prompt" button: toggle // Prompt panel — mirrors the audiobook casting "Prompt" button: toggle
// visibility, load/save/delete named presets (localStorage), and persist // visibility, load/save/delete named presets (localStorage), and persist
// the active text as the settings default so csGenerate() picks it up. // the active text as the settings default so csGenerate() picks it up.
const promptBtn = ov.querySelector('#cs-prompt-btn'); const promptBtn = root.querySelector('#cs-prompt-btn');
const promptPanel = ov.querySelector('#cs-prompt-panel'); const promptPanel = root.querySelector('#cs-prompt-panel');
const promptText = ov.querySelector('#cs-prompt-text'); const promptText = root.querySelector('#cs-prompt-text');
promptText.value = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT; if (promptText) promptText.value = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT;
promptBtn.addEventListener('click', () => { promptBtn?.addEventListener('click', () => {
if (!promptPanel) return;
promptPanel.hidden = !promptPanel.hidden; promptPanel.hidden = !promptPanel.hidden;
const chevron = ov.querySelector('#cs-prompt-chevron'); const chevron = root.querySelector('#cs-prompt-chevron');
if (chevron) chevron.className = promptPanel.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up'; if (chevron) chevron.className = promptPanel.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up';
}); });
let savedCsPrompts = []; let savedCsPrompts = [];
try { savedCsPrompts = JSON.parse(localStorage.getItem('ttsvc_cs_prompts') || '[]'); } catch (_) { savedCsPrompts = []; } try { savedCsPrompts = JSON.parse(localStorage.getItem('ttsvc_cs_prompts') || '[]'); } catch (_) { savedCsPrompts = []; }
const libSelect = ov.querySelector('#cs-prompt-lib'); const libSelect = root.querySelector('#cs-prompt-lib');
const delBtn = ov.querySelector('#cs-prompt-del'); const delBtn = root.querySelector('#cs-prompt-del');
const nameInput = ov.querySelector('#cs-prompt-name'); const nameInput = root.querySelector('#cs-prompt-name');
const renderCsPromptLib = (selectedIdx = -1) => { const renderCsPromptLib = (selectedIdx = -1) => {
if (!libSelect || !delBtn) return;
libSelect.innerHTML = '<option value="">— Load saved prompt —</option>' + libSelect.innerHTML = '<option value="">— Load saved prompt —</option>' +
savedCsPrompts.map((p, i) => `<option value="${i}">${escHtml(p.name)}</option>`).join(''); savedCsPrompts.map((p, i) => `<option value="${i}">${escHtml(p.name)}</option>`).join('');
if (selectedIdx >= 0) { libSelect.value = selectedIdx; delBtn.style.display = 'inline-flex'; } if (selectedIdx >= 0) { libSelect.value = selectedIdx; delBtn.style.display = 'inline-flex'; }
else { libSelect.value = ''; delBtn.style.display = 'none'; } else { libSelect.value = ''; delBtn.style.display = 'none'; }
}; };
renderCsPromptLib(); renderCsPromptLib();
libSelect.addEventListener('change', () => { libSelect?.addEventListener('change', () => {
const idx = parseInt(libSelect.value, 10); const idx = parseInt(libSelect.value, 10);
if (!isNaN(idx) && savedCsPrompts[idx]) { if (!isNaN(idx) && savedCsPrompts[idx]) {
promptText.value = savedCsPrompts[idx].prompt; if (promptText) promptText.value = savedCsPrompts[idx].prompt;
nameInput.value = savedCsPrompts[idx].name; if (nameInput) nameInput.value = savedCsPrompts[idx].name;
delBtn.style.display = 'inline-flex'; delBtn.style.display = 'inline-flex';
} else { } else {
nameInput.value = ''; if (nameInput) nameInput.value = '';
delBtn.style.display = 'none'; delBtn.style.display = 'none';
} }
}); });
delBtn.addEventListener('click', () => { delBtn?.addEventListener('click', () => {
const idx = parseInt(libSelect.value, 10); const idx = parseInt(libSelect.value, 10);
if (isNaN(idx)) return; if (isNaN(idx)) return;
if (confirm('Delete this saved prompt preset?')) { if (confirm('Delete this saved prompt preset?')) {
@ -505,11 +515,11 @@ function csProgress(total) {
toast('Prompt deleted', 'success'); toast('Prompt deleted', 'success');
} }
}); });
ov.querySelector('#cs-prompt-save').addEventListener('click', async () => { root.querySelector('#cs-prompt-save')?.addEventListener('click', async () => {
const val = promptText.value.trim(); const val = promptText?.value.trim() || '';
if (!val) { toast('Prompt is empty', 'error'); return; } if (!val) { toast('Prompt is empty', 'error'); return; }
const name = nameInput.value.trim() || 'Custom Prompt ' + (savedCsPrompts.length + 1); const name = nameInput?.value.trim() || 'Custom Prompt ' + (savedCsPrompts.length + 1);
let targetIdx = parseInt(libSelect.value, 10); let targetIdx = parseInt(libSelect?.value || '', 10);
if (!isNaN(targetIdx) && savedCsPrompts[targetIdx] && savedCsPrompts[targetIdx].name === name) { if (!isNaN(targetIdx) && savedCsPrompts[targetIdx] && savedCsPrompts[targetIdx].name === name) {
savedCsPrompts[targetIdx].prompt = val; savedCsPrompts[targetIdx].prompt = val;
} else { } else {
@ -524,7 +534,7 @@ function csProgress(total) {
// per-passage prompt lookup — and the next time this panel opens — // per-passage prompt lookup — and the next time this panel opens —
// always reflect the latest text, including mid-run edits. // always reflect the latest text, including mid-run edits.
let promptSaveTimer = null; let promptSaveTimer = null;
promptText.addEventListener('input', () => { promptText?.addEventListener('input', () => {
clearTimeout(promptSaveTimer); clearTimeout(promptSaveTimer);
promptSaveTimer = setTimeout(() => { promptSaveTimer = setTimeout(() => {
const val = promptText.value; const val = promptText.value;
@ -533,14 +543,14 @@ function csProgress(total) {
}, 500); }, 500);
}); });
} }
ov.style.display = 'flex'; if (!usingHost) root.style.display = 'flex';
const fill = ov.querySelector('#cs-progress-fill'); const fill = root.querySelector('#cs-progress-fill');
const msg = ov.querySelector('#cs-progress-msg'); const msg = root.querySelector('#cs-progress-msg');
const chars = ov.querySelector('#cs-progress-chars'); const chars = root.querySelector('#cs-progress-chars');
const sideTitle = ov.querySelector('#cs-progress-side-title'); const sideTitle = root.querySelector('#cs-progress-side-title');
const passagePre = ov.querySelector('#cs-progress-passage-pre'); const passagePre = root.querySelector('#cs-progress-passage-pre');
const livePre = ov.querySelector('#cs-progress-live-pre'); const livePre = root.querySelector('#cs-progress-live-pre');
const liveStatus = ov.querySelector('#cs-progress-live-status'); const liveStatus = root.querySelector('#cs-progress-live-status');
return { return {
// Called once per passage before its request starts, so both panes are // Called once per passage before its request starts, so both panes are
// reset and honestly labelled before we know whether this model streams // reset and honestly labelled before we know whether this model streams
@ -583,7 +593,9 @@ function csProgress(total) {
`).join('') : '<div class="cs-progress-empty">reading…</div>'; `).join('') : '<div class="cs-progress-empty">reading…</div>';
} }
}, },
done() { ov.style.display = 'none'; }, done() {
if (!usingHost) root.style.display = 'none';
},
}; };
} }
@ -845,7 +857,100 @@ function csToMarkdown(sheets) {
return md.trim(); return md.trim();
} }
async function csShow(sheets, title, sourceText, book) { function csWireResultInteractions(root, sheets, sourceText, book, inline = false) {
const copyBtn = root.querySelector('#cs-copy');
copyBtn?.addEventListener('click', () => {
navigator.clipboard?.writeText(csToMarkdown(sheets))
.then(() => toast('Copied as Markdown', 'success'), () => toast('Copy failed', 'error'));
});
const closeBtn = root.querySelector('#cs-close');
closeBtn?.addEventListener('click', () => {
if (inline) {
if (typeof navLibraryView === 'function') navLibraryView('characters');
} else {
document.getElementById('cs-overlay')?.remove();
}
});
root.querySelector('#cs-open-cast')?.addEventListener('click', () => csGoToLibrary());
root.querySelector('#cs-back-cast-audio')?.addEventListener('click', () => {
if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('cast');
});
const bkCtx = book || '';
root.querySelectorAll('.cs-avatar').forEach(av => {
av.addEventListener('click', e => {
e.stopPropagation();
const name = av.dataset.name;
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*';
inp.onchange = async () => {
const file = inp.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = async ev => {
const dataUrl = ev.target.result;
const id = `${bkCtx}::${name}`.toLowerCase();
if (typeof clSetImage === 'function') await clSetImage(id, dataUrl);
const s = sheets.find(x => x.name === name);
if (s) s._image = dataUrl;
const card = root.querySelector(`.cs-card[data-name="${CSS.escape(name)}"]`);
if (card) {
const oldAv = card.querySelector('.cs-avatar');
if (oldAv) {
oldAv.outerHTML = csAvatarHtml(s);
card.querySelector('.cs-avatar')?.addEventListener('click', av.onclick);
}
}
toast('Profile picture saved', 'success');
};
reader.readAsDataURL(file);
};
inp.click();
});
});
root.querySelectorAll('.cs-deep-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (sheet) csDeepAnalysis(sheet, sourceText);
});
});
root.querySelectorAll('.cs-img-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (!sheet) return;
navigator.clipboard?.writeText(csBuildImagePrompt(sheet))
.then(() => toast('Image prompt copied', 'success'), () => toast('Copy failed', 'error'));
});
});
root.querySelectorAll('.cs-voice-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (!sheet) return;
navigator.clipboard?.writeText(csBuildVoicePrompt(sheet))
.then(() => toast('Voice prompt copied — paste into Design a Voice', 'success'), () => toast('Copy failed', 'error'));
});
});
root.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const pg = parseInt(btn.dataset.page, 10);
if (pg && typeof window.readerJumpToPage === 'function') window.readerJumpToPage(pg);
else if (pg && typeof toast === 'function') toast(`Source: page ${pg}`, 'info');
});
});
}
async function csShow(sheets, title, sourceText, book, hostEl = null) {
const inline = !!hostEl;
document.getElementById('cs-overlay')?.remove(); document.getElementById('cs-overlay')?.remove();
// Enrich sheets with stored profile images from the character library. // Enrich sheets with stored profile images from the character library.
@ -861,13 +966,14 @@ async function csShow(sheets, title, sourceText, book) {
} catch (_) {} } catch (_) {}
} }
const ov = document.createElement('div');
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
const main = sheets.filter(s => s.tier === 'main'); const main = sheets.filter(s => s.tier === 'main');
const supp = sheets.filter(s => s.tier !== 'main'); const supp = sheets.filter(s => s.tier !== 'main');
const group = (label, list) => list.length const group = (label, list) => list.length
? `<div class="cs-group-label">${label}</div>` + list.map(csCardHtml).join('') ? `<div class="cs-group-label">${label}</div>` + list.map(csCardHtml).join('')
: ''; : '';
const ov = inline ? hostEl : document.createElement('div');
if (!inline) {
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box cs-box"> ov.innerHTML = `<div class="audiobook-box cs-box">
<div class="cs-titlebar"> <div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> <span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span>
@ -881,83 +987,24 @@ async function csShow(sheets, title, sourceText, book) {
<div class="cs-list">${group('Main characters', main)}${group('Supporting characters', supp)}</div> <div class="cs-list">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
</div>`; </div>`;
document.body.appendChild(ov); document.body.appendChild(ov);
ov.querySelector('#cs-close').addEventListener('click', () => ov.remove());
ov.querySelector('#cs-copy').addEventListener('click', () => {
navigator.clipboard?.writeText(csToMarkdown(sheets))
.then(() => toast('Copied as Markdown', 'success'), () => toast('Copy failed', 'error'));
});
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); }); ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
} else {
// Wire avatar upload (click on avatar → file picker → save to library) ov.innerHTML = `<div class="audiobook-box cs-box cs-page-shell" style="width:100%; max-width:none; max-height:none; height:auto;">
const bkCtx = book || title || ''; <div class="cs-titlebar">
ov.querySelectorAll('.cs-avatar').forEach(av => { <span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span>
av.addEventListener('click', e => { ${escHtml(title || 'Character sheets')}
e.stopPropagation(); <span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span>
const name = av.dataset.name; </span>
const inp = document.createElement('input'); <span style="flex:1"></span>
inp.type = 'file'; inp.accept = 'image/*'; <button class="btn-secondary btn-sm" id="cs-back-cast-audio"><span class="mdi mdi-arrow-left"></span> Cast Audiobook</button>
inp.onchange = async () => { <button class="btn-secondary btn-sm" id="cs-open-cast"><span class="mdi mdi-folder-account-outline"></span> Open Cast</button>
const file = inp.files[0]; if (!file) return; <button class="btn-secondary btn-sm" id="cs-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
const reader = new FileReader(); </div>
reader.onload = async ev => { <div class="card-subtitle" style="margin:0 0 12px 0;">Generated sheets are saved into the cast library. Use the buttons above to jump back to the audiobook cast or continue to the roster stage.</div>
const dataUrl = ev.target.result; <div class="cs-list">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
const id = `${bkCtx}::${name}`.toLowerCase(); </div>`;
if (typeof clSetImage === 'function') await clSetImage(id, dataUrl);
// Update in-memory sheet and re-render avatar
const s = sheets.find(x => x.name === name);
if (s) s._image = dataUrl;
const card = ov.querySelector(`.cs-card[data-name="${CSS.escape(name)}"]`);
if (card) {
const oldAv = card.querySelector('.cs-avatar');
if (oldAv) { oldAv.outerHTML = csAvatarHtml(s); card.querySelector('.cs-avatar').addEventListener('click', av.onclick); }
} }
toast('Profile picture saved', 'success'); csWireResultInteractions(ov, sheets, sourceText, book, inline);
};
reader.readAsDataURL(file);
};
inp.click();
});
});
// Wire Deep Analysis buttons
ov.querySelectorAll('.cs-deep-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (sheet) csDeepAnalysis(sheet, sourceText);
});
});
// Wire Image prompt buttons
ov.querySelectorAll('.cs-img-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (!sheet) return;
navigator.clipboard?.writeText(csBuildImagePrompt(sheet))
.then(() => toast('Image prompt copied', 'success'), () => toast('Copy failed', 'error'));
});
});
// Wire Voice prompt buttons
ov.querySelectorAll('.cs-voice-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const sheet = sheets.find(s => s.name === btn.dataset.name);
if (!sheet) return;
navigator.clipboard?.writeText(csBuildVoicePrompt(sheet))
.then(() => toast('Voice prompt copied — paste into Design a Voice', 'success'), () => toast('Copy failed', 'error'));
});
});
ov.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const pg = parseInt(btn.dataset.page, 10);
if (pg && typeof window.readerJumpToPage === 'function') window.readerJumpToPage(pg);
else if (pg && typeof toast === 'function') toast(`Source: page ${pg}`, 'info');
});
});
} }
// ── Entry points (reader + rehearser) ──────────────────────────────────────── // ── Entry points (reader + rehearser) ────────────────────────────────────────
@ -975,6 +1022,7 @@ function csGoToLibrary() {
if (typeof navTo === 'function') navTo('s-library'); if (typeof navTo === 'function') navTo('s-library');
if (typeof navLibraryView === 'function') navLibraryView('characters'); if (typeof navLibraryView === 'function') navLibraryView('characters');
else if (typeof libraryRender === 'function') libraryRender('characters'); else if (typeof libraryRender === 'function') libraryRender('characters');
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('castlib');
} }
// Best-effort dialogue-line tally so the Library's table view can show a // Best-effort dialogue-line tally so the Library's table view can show a
@ -994,9 +1042,16 @@ async function csForReader() {
const text = csReaderText(); const text = csReaderText();
const book = readerState.title || 'Untitled book'; const book = readerState.title || 'Untitled book';
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0); const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
if (_cs.cache[key]) { csGoToLibrary(); return; } if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('chars');
const pageHost = csReaderPageHost();
if (_cs.cache[key]) {
await csSaveToLibrary(book, _cs.cache[key]);
if (pageHost) await csShow(_cs.cache[key], book, text, book, pageHost);
return;
}
const knownRoster = csKnownReaderRoster(); const knownRoster = csKnownReaderRoster();
const sheets = await csGenerate(text, key, knownRoster); const sheets = await csGenerate(text, key, knownRoster, { pageHost });
if (!sheets) return; if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; } if (!sheets.length) { toast('No characters found', 'error'); return; }
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook; const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
@ -1006,8 +1061,8 @@ async function csForReader() {
csAttachLineCounts(sheets, counts); csAttachLineCounts(sheets, counts);
} }
await csSaveToLibrary(book, sheets); await csSaveToLibrary(book, sheets);
csGoToLibrary(); if (pageHost) await csShow(sheets, book, text, book, pageHost);
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success'); toast(sheets.length + ' character sheets saved — Library → Cast', 'success');
} }
// Refresh sheet data for a hand-picked subset of an already-cast book's // Refresh sheet data for a hand-picked subset of an already-cast book's
@ -1062,7 +1117,7 @@ async function csForReaderSelective(selectedNames) {
} }
await csSaveToLibrary(book, filtered); await csSaveToLibrary(book, filtered);
csGoToLibrary(); csGoToLibrary();
toast(filtered.length + ' character' + (filtered.length !== 1 ? 's' : '') + ' recast — Library → Characters / Cast', 'success'); toast(filtered.length + ' character' + (filtered.length !== 1 ? 's' : '') + ' recast — Library → Cast', 'success');
} }
async function csForRehearser() { async function csForRehearser() {
@ -1070,8 +1125,15 @@ async function csForRehearser() {
const book = $('reh-script-title')?.value.trim() || 'Untitled script'; const book = $('reh-script-title')?.value.trim() || 'Untitled script';
const text = csRehearserText(); const text = csRehearserText();
const key = 'reh:' + title + ':' + ((rehState.lines || []).length); const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
if (_cs.cache[key]) { csGoToLibrary(); return; } if (typeof navTo === 'function') navTo('s-reader');
const sheets = await csGenerate(text, key); if (typeof showReaderView === 'function') showReaderView('chars');
const pageHost = csReaderPageHost();
if (_cs.cache[key]) {
await csSaveToLibrary(book, _cs.cache[key]);
if (pageHost) await csShow(_cs.cache[key], title, text, book, pageHost);
return;
}
const sheets = await csGenerate(text, key, null, { pageHost });
if (!sheets) return; if (!sheets) return;
if (!sheets.length) { toast('No characters found', 'error'); return; } if (!sheets.length) { toast('No characters found', 'error'); return; }
if (window.rehState?.lines?.length) { if (window.rehState?.lines?.length) {
@ -1084,8 +1146,8 @@ async function csForRehearser() {
csAttachLineCounts(sheets, counts); csAttachLineCounts(sheets, counts);
} }
await csSaveToLibrary(book, sheets); await csSaveToLibrary(book, sheets);
csGoToLibrary(); if (pageHost) await csShow(sheets, title, text, book, pageHost);
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success'); toast(sheets.length + ' character sheets saved — Library → Cast', 'success');
} }
window.csForReader = csForReader; window.csForReader = csForReader;

View File

@ -420,10 +420,19 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
function appendThinking(bubble, delta) { function appendThinking(bubble, delta) {
ensureAssistantParts(bubble); ensureAssistantParts(bubble);
bubble.querySelector('.conv-think-wrap').hidden = false; const wrap = bubble.querySelector('.conv-think-wrap');
// Stays collapsed regardless of streaming state — the user only sees the const toggle = bubble.querySelector('.conv-think-toggle');
// reasoning text if they click the "Thinking" chevron to expand it. const body = bubble.querySelector('.conv-think-body');
bubble.querySelector('.conv-think-body').textContent += delta; wrap.hidden = false;
body.textContent += delta;
// Auto-open the panel the first time reasoning arrives so the user can see
// the model thinking live. After that, preserve the user's manual toggle.
if (!bubble.dataset.thinkingSeen) {
bubble.dataset.thinkingSeen = '1';
toggle.setAttribute('aria-expanded', 'true');
body.hidden = false;
}
} }
function setAnswerText(bubble, text) { function setAnswerText(bubble, text) {

View File

@ -26,9 +26,9 @@ window.navLibraryView = function (view) {
document.querySelectorAll('[data-library-panel]').forEach(function (el) { document.querySelectorAll('[data-library-panel]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.libraryPanel === view); el.classList.toggle('is-active', el.dataset.libraryPanel === view);
}); });
// Characters/Cast is the pipeline's "Cast Characters" stop — keep the // Cast roster is its own pipeline stop; keep the stepper highlight in sync
// stepper's current-step highlight in sync when the tab is opened directly. // when the tab is opened directly.
if (view === 'characters' && typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('chars'); if (view === 'characters' && typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('castlib');
libraryRender(view); libraryRender(view);
}; };

View File

@ -1954,6 +1954,7 @@ window.addEventListener('resize', (function () {
window.showReaderView = function (view) { window.showReaderView = function (view) {
const main = $('reader-main-view'); const main = $('reader-main-view');
const cast = $('reader-audiobook-panel'); const cast = $('reader-audiobook-panel');
const chars = $('reader-charsheets-panel');
const lib = $('reader-library-card'); const lib = $('reader-library-card');
if (main) main.hidden = (view !== 'main'); if (main) main.hidden = (view !== 'main');
if (cast) { if (cast) {
@ -1968,6 +1969,20 @@ window.showReaderView = function (view) {
cast.className = 'ab-castpanel-inline card'; cast.className = 'ab-castpanel-inline card';
} }
} }
if (chars) {
chars.hidden = (view !== 'chars');
if (view === 'chars' && !chars.innerHTML.trim()) {
chars.innerHTML = '<div class="card" style="padding:24px; min-height:260px; display:flex; flex-direction:column; gap:12px; justify-content:center;">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>'
+ '<p class="card-subtitle" style="margin:0;">This stage will show the live passage-by-passage extraction view, prompt editor, and generated sheets once you start a cast.</p>'
+ '<div class="btn-row" style="margin-top:4px;">'
+ '<button class="btn-primary btn-sm" onclick="if (typeof csForReader === \'function\') csForReader();"><span class="mdi mdi-account-details-outline"></span> Cast from Reader</button>'
+ '<button class="btn-secondary btn-sm" onclick="if (typeof csForRehearser === \'function\') csForRehearser();"><span class="mdi mdi-theater"></span> Cast from Rehearser</button>'
+ '<button class="btn-secondary btn-sm" onclick="if (typeof navLibraryView === \'function\') navLibraryView(\'characters\');"><span class="mdi mdi-folder-account-outline"></span> Go to Cast</button>'
+ '</div></div>';
chars.className = '';
}
}
if (lib) { if (lib) {
lib.hidden = (view !== 'library'); lib.hidden = (view !== 'library');
if (view === 'library') { if (view === 'library') {
@ -1978,7 +1993,9 @@ window.showReaderView = function (view) {
readerRenderLibrary(); // Re-fetch on open readerRenderLibrary(); // Re-fetch on open
} }
} }
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs(view === 'cast' ? 'cast' : 'source'); if (typeof refreshWorkflowCrumbs === 'function') {
refreshWorkflowCrumbs(view === 'cast' ? 'cast' : view === 'chars' ? 'chars' : 'source');
}
}; };
window.navReaderView = function (view) { window.navReaderView = function (view) {

View File

@ -786,7 +786,7 @@ document.addEventListener('click', e => {
}); });
// ── Cross-workflow pipeline stepper ───────────────────────────────────────── // ── Cross-workflow pipeline stepper ─────────────────────────────────────────
// PDF → Text → Cast Audiobook → Cast Characters (optional) → Script Rehearser // PDF → Text → Cast Audiobook → Cast Characters → Cast → Script Rehearser
// → Generate MP3s → Audiobook. A persistent strip (rendered into any element // → Generate MP3s → Audiobook. A persistent strip (rendered into any element
// with class .wf-stepper — currently one in s-reader.html, one in // with class .wf-stepper — currently one in s-reader.html, one in
// s-rehearser.html) that lets you jump directly to any reachable stage // s-rehearser.html) that lets you jump directly to any reachable stage
@ -803,6 +803,7 @@ const WF_STEPS = [
{ key: 'source', label: 'Source', enabled: () => true }, { key: 'source', label: 'Source', enabled: () => true },
{ key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length }, { key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length },
{ key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true }, { key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true },
{ key: 'castlib', label: 'Cast', enabled: () => !!window.readerState?.sentences?.length },
{ key: 'rehearser', label: 'Script Rehearser',enabled: () => !!window.rehState?.lines?.length }, { key: 'rehearser', label: 'Script Rehearser',enabled: () => !!window.rehState?.lines?.length },
{ key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length }, { key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length },
{ key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length }, { key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length },
@ -818,12 +819,16 @@ function workflowCrumbGo(key) {
if (typeof navTo === 'function') navTo('s-reader'); if (typeof navTo === 'function') navTo('s-reader');
if (typeof audiobookOpenCastView === 'function') audiobookOpenCastView(); if (typeof audiobookOpenCastView === 'function') audiobookOpenCastView();
} else if (key === 'chars') { } else if (key === 'chars') {
// Navigate to the Characters/Cast library view — generating sheets stays // Navigate to the character-sheet stage in Read Aloud.
// on the explicit "Cast Characters" buttons; a navigation stop must never if (typeof navTo === 'function') navTo('s-reader');
// kick off an LLM run as a side effect. if (typeof showReaderView === 'function') showReaderView('chars');
_wfActive = 'chars';
refreshWorkflowCrumbs();
} else if (key === 'castlib') {
// Navigate to the cast roster/library view.
if (typeof navTo === 'function') navTo('s-library'); if (typeof navTo === 'function') navTo('s-library');
if (typeof navLibraryView === 'function') navLibraryView('characters'); if (typeof navLibraryView === 'function') navLibraryView('characters');
_wfActive = 'chars'; _wfActive = 'castlib';
refreshWorkflowCrumbs(); refreshWorkflowCrumbs();
} else if (key === 'rehearser') { } else if (key === 'rehearser') {
if (typeof navTo === 'function') navTo('s-rehearser'); if (typeof navTo === 'function') navTo('s-rehearser');
@ -887,7 +892,7 @@ function refreshWorkflowCrumbs(active) {
window.refreshWorkflowCrumbs = refreshWorkflowCrumbs; window.refreshWorkflowCrumbs = refreshWorkflowCrumbs;
// Rehearser's own phase tabs (Library/Cast/Stage/Summary) map onto the last // Rehearser's own phase tabs (Library/Cast/Stage/Summary) map onto the last
// three stepper stops — keep the stepper's "current" highlight following them. // four stepper stops — keep the stepper's "current" highlight following them.
window.onRehearserPhaseChange = function (n) { window.onRehearserPhaseChange = function (n) {
refreshWorkflowCrumbs(n === 3 ? 'mp3' : n === 4 ? 'audiobook' : 'rehearser'); refreshWorkflowCrumbs(n === 3 ? 'mp3' : n === 4 ? 'audiobook' : 'rehearser');
}; };

View File

@ -11,7 +11,7 @@
<div class="lib-tabs"> <div class="lib-tabs">
<button class="lib-tab is-active" data-library-view="books" onclick="navLibraryView('books')"><span class="mdi mdi-book-open-page-variant-outline"></span> Books</button> <button class="lib-tab is-active" data-library-view="books" onclick="navLibraryView('books')"><span class="mdi mdi-book-open-page-variant-outline"></span> Books</button>
<button class="lib-tab" data-library-view="plays" onclick="navLibraryView('plays')"><span class="mdi mdi-theater"></span> Theater Plays</button> <button class="lib-tab" data-library-view="plays" onclick="navLibraryView('plays')"><span class="mdi mdi-theater"></span> Theater Plays</button>
<button class="lib-tab" data-library-view="characters" onclick="navLibraryView('characters')"><span class="mdi mdi-account-box-multiple-outline"></span> Characters / Cast</button> <button class="lib-tab" data-library-view="characters" onclick="navLibraryView('characters')"><span class="mdi mdi-account-box-multiple-outline"></span> Cast</button>
</div> </div>
<!-- Books --> <!-- Books -->

View File

@ -191,6 +191,9 @@
<!-- ⑦ Audiobook Casting Panel ──────────────────────────────────── --> <!-- ⑦ Audiobook Casting Panel ──────────────────────────────────── -->
<div id="reader-audiobook-panel" hidden></div> <div id="reader-audiobook-panel" hidden></div>
<!-- ⑧ Character Sheets Stage ──────────────────────────────────── -->
<div id="reader-charsheets-panel" hidden></div>
<!-- Saved books now live in the combined Library → Books section. --> <!-- Saved books now live in the combined Library → Books section. -->
</div><!-- /tab-reader --> </div><!-- /tab-reader -->

View File

@ -5715,6 +5715,17 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Character sheets overlay */ /* Character sheets overlay */
.cs-box { width: min(1120px, 96vw); max-height: 90vh; display: flex; flex-direction: column; } .cs-box { width: min(1120px, 96vw); max-height: 90vh; display: flex; flex-direction: column; }
.cs-page-shell { width: 100%; max-width: none; }
#s-reader #reader-charsheets-panel { display: flex; flex-direction: column; min-height: 0; }
#s-reader #reader-charsheets-panel .cs-progress-box-big,
#s-reader #reader-charsheets-panel .cs-page-shell {
width: 100%;
max-width: none;
}
#s-reader #reader-charsheets-panel .cs-progress-box-big {
min-height: min(880px, 90vh);
height: auto;
}
.cs-titlebar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .cs-titlebar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.cs-list { overflow-y: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(auto-fill, minmax(430px, 1fr)); gap: 14px; align-content: start; } .cs-list { overflow-y: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(auto-fill, minmax(430px, 1fr)); gap: 14px; align-content: start; }
.cs-group-label { grid-column: 1 / -1; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-top: 4px; } .cs-group-label { grid-column: 1 / -1; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-top: 4px; }