Release v1.14.19
This commit is contained in:
parent
17716ac740
commit
8f3fcbae04
11
CHANGELOG.md
11
CHANGELOG.md
@ -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
|
||||
|
||||
### 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.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
|
||||
|
||||
@ -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.14.18">
|
||||
<meta name="app-version" content="1.14.19">
|
||||
<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.14.18">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.14.19">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- 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>
|
||||
</html>
|
||||
|
||||
@ -3291,7 +3291,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
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>`
|
||||
: '';
|
||||
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 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');
|
||||
wrap.className = 'ab-cv-castchars-group';
|
||||
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>';
|
||||
castBtn.replaceWith(wrap);
|
||||
wrap.querySelector('.ab-cv-castchars-view').addEventListener('click', () => {
|
||||
|
||||
@ -63,6 +63,7 @@ function csLlmUrl() {
|
||||
}
|
||||
function csLlmModel() { return $('reh-llm-model')?.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).
|
||||
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 (!text) { toast('Nothing to analyse', 'error'); return null; }
|
||||
const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text];
|
||||
_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 selectedLanguage = csLang();
|
||||
let language = selectedLanguage;
|
||||
@ -409,16 +410,24 @@ async function csGenerate(text, cacheKey, initialRoster) {
|
||||
return sheets;
|
||||
}
|
||||
|
||||
function csProgress(total) {
|
||||
let ov = document.getElementById('cs-progress');
|
||||
if (!ov) {
|
||||
ov = document.createElement('div');
|
||||
ov.id = 'cs-progress'; ov.className = 'audiobook-overlay';
|
||||
function csProgress(total, hostEl = null) {
|
||||
const usingHost = !!hostEl;
|
||||
let root = usingHost ? hostEl : document.getElementById('cs-progress');
|
||||
if (!root) {
|
||||
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
|
||||
// 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)
|
||||
// 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="audiobook-title">
|
||||
<span class="mdi mdi-account-details-outline"></span> Character sheets
|
||||
@ -456,46 +465,47 @@ function csProgress(total) {
|
||||
</div>
|
||||
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
|
||||
</div>`;
|
||||
document.body.appendChild(ov);
|
||||
ov.querySelector('#cs-progress-cancel').addEventListener('click', () => { _cs.cancel = true; });
|
||||
root.querySelector('#cs-progress-cancel')?.addEventListener('click', () => { _cs.cancel = true; });
|
||||
|
||||
// Prompt panel — mirrors the audiobook casting "Prompt" button: toggle
|
||||
// visibility, load/save/delete named presets (localStorage), and persist
|
||||
// the active text as the settings default so csGenerate() picks it up.
|
||||
const promptBtn = ov.querySelector('#cs-prompt-btn');
|
||||
const promptPanel = ov.querySelector('#cs-prompt-panel');
|
||||
const promptText = ov.querySelector('#cs-prompt-text');
|
||||
promptText.value = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT;
|
||||
promptBtn.addEventListener('click', () => {
|
||||
const promptBtn = root.querySelector('#cs-prompt-btn');
|
||||
const promptPanel = root.querySelector('#cs-prompt-panel');
|
||||
const promptText = root.querySelector('#cs-prompt-text');
|
||||
if (promptText) promptText.value = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT;
|
||||
promptBtn?.addEventListener('click', () => {
|
||||
if (!promptPanel) return;
|
||||
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';
|
||||
});
|
||||
|
||||
let savedCsPrompts = [];
|
||||
try { savedCsPrompts = JSON.parse(localStorage.getItem('ttsvc_cs_prompts') || '[]'); } catch (_) { savedCsPrompts = []; }
|
||||
const libSelect = ov.querySelector('#cs-prompt-lib');
|
||||
const delBtn = ov.querySelector('#cs-prompt-del');
|
||||
const nameInput = ov.querySelector('#cs-prompt-name');
|
||||
const libSelect = root.querySelector('#cs-prompt-lib');
|
||||
const delBtn = root.querySelector('#cs-prompt-del');
|
||||
const nameInput = root.querySelector('#cs-prompt-name');
|
||||
const renderCsPromptLib = (selectedIdx = -1) => {
|
||||
if (!libSelect || !delBtn) return;
|
||||
libSelect.innerHTML = '<option value="">— Load saved prompt —</option>' +
|
||||
savedCsPrompts.map((p, i) => `<option value="${i}">${escHtml(p.name)}</option>`).join('');
|
||||
if (selectedIdx >= 0) { libSelect.value = selectedIdx; delBtn.style.display = 'inline-flex'; }
|
||||
else { libSelect.value = ''; delBtn.style.display = 'none'; }
|
||||
};
|
||||
renderCsPromptLib();
|
||||
libSelect.addEventListener('change', () => {
|
||||
libSelect?.addEventListener('change', () => {
|
||||
const idx = parseInt(libSelect.value, 10);
|
||||
if (!isNaN(idx) && savedCsPrompts[idx]) {
|
||||
promptText.value = savedCsPrompts[idx].prompt;
|
||||
nameInput.value = savedCsPrompts[idx].name;
|
||||
if (promptText) promptText.value = savedCsPrompts[idx].prompt;
|
||||
if (nameInput) nameInput.value = savedCsPrompts[idx].name;
|
||||
delBtn.style.display = 'inline-flex';
|
||||
} else {
|
||||
nameInput.value = '';
|
||||
if (nameInput) nameInput.value = '';
|
||||
delBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
delBtn.addEventListener('click', () => {
|
||||
delBtn?.addEventListener('click', () => {
|
||||
const idx = parseInt(libSelect.value, 10);
|
||||
if (isNaN(idx)) return;
|
||||
if (confirm('Delete this saved prompt preset?')) {
|
||||
@ -505,11 +515,11 @@ function csProgress(total) {
|
||||
toast('Prompt deleted', 'success');
|
||||
}
|
||||
});
|
||||
ov.querySelector('#cs-prompt-save').addEventListener('click', async () => {
|
||||
const val = promptText.value.trim();
|
||||
root.querySelector('#cs-prompt-save')?.addEventListener('click', async () => {
|
||||
const val = promptText?.value.trim() || '';
|
||||
if (!val) { toast('Prompt is empty', 'error'); return; }
|
||||
const name = nameInput.value.trim() || 'Custom Prompt ' + (savedCsPrompts.length + 1);
|
||||
let targetIdx = parseInt(libSelect.value, 10);
|
||||
const name = nameInput?.value.trim() || 'Custom Prompt ' + (savedCsPrompts.length + 1);
|
||||
let targetIdx = parseInt(libSelect?.value || '', 10);
|
||||
if (!isNaN(targetIdx) && savedCsPrompts[targetIdx] && savedCsPrompts[targetIdx].name === name) {
|
||||
savedCsPrompts[targetIdx].prompt = val;
|
||||
} else {
|
||||
@ -524,7 +534,7 @@ function csProgress(total) {
|
||||
// per-passage prompt lookup — and the next time this panel opens —
|
||||
// always reflect the latest text, including mid-run edits.
|
||||
let promptSaveTimer = null;
|
||||
promptText.addEventListener('input', () => {
|
||||
promptText?.addEventListener('input', () => {
|
||||
clearTimeout(promptSaveTimer);
|
||||
promptSaveTimer = setTimeout(() => {
|
||||
const val = promptText.value;
|
||||
@ -533,14 +543,14 @@ function csProgress(total) {
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
ov.style.display = 'flex';
|
||||
const fill = ov.querySelector('#cs-progress-fill');
|
||||
const msg = ov.querySelector('#cs-progress-msg');
|
||||
const chars = ov.querySelector('#cs-progress-chars');
|
||||
const sideTitle = ov.querySelector('#cs-progress-side-title');
|
||||
const passagePre = ov.querySelector('#cs-progress-passage-pre');
|
||||
const livePre = ov.querySelector('#cs-progress-live-pre');
|
||||
const liveStatus = ov.querySelector('#cs-progress-live-status');
|
||||
if (!usingHost) root.style.display = 'flex';
|
||||
const fill = root.querySelector('#cs-progress-fill');
|
||||
const msg = root.querySelector('#cs-progress-msg');
|
||||
const chars = root.querySelector('#cs-progress-chars');
|
||||
const sideTitle = root.querySelector('#cs-progress-side-title');
|
||||
const passagePre = root.querySelector('#cs-progress-passage-pre');
|
||||
const livePre = root.querySelector('#cs-progress-live-pre');
|
||||
const liveStatus = root.querySelector('#cs-progress-live-status');
|
||||
return {
|
||||
// Called once per passage before its request starts, so both panes are
|
||||
// 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>';
|
||||
}
|
||||
},
|
||||
done() { ov.style.display = 'none'; },
|
||||
done() {
|
||||
if (!usingHost) root.style.display = 'none';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -845,7 +857,100 @@ function csToMarkdown(sheets) {
|
||||
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();
|
||||
|
||||
// Enrich sheets with stored profile images from the character library.
|
||||
@ -861,103 +966,45 @@ async function csShow(sheets, title, sourceText, book) {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const ov = document.createElement('div');
|
||||
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
|
||||
const main = sheets.filter(s => s.tier === 'main');
|
||||
const supp = sheets.filter(s => s.tier !== 'main');
|
||||
const group = (label, list) => list.length
|
||||
? `<div class="cs-group-label">${label}</div>` + list.map(csCardHtml).join('')
|
||||
: '';
|
||||
ov.innerHTML = `<div class="audiobook-box cs-box">
|
||||
<div class="cs-titlebar">
|
||||
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span>
|
||||
${escHtml(title || 'Character sheets')}
|
||||
<span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span>
|
||||
</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-secondary btn-sm" id="cs-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
|
||||
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
|
||||
</div>
|
||||
<div class="cs-list">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
|
||||
</div>`;
|
||||
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(); });
|
||||
|
||||
// Wire avatar upload (click on avatar → file picker → save to library)
|
||||
const bkCtx = book || title || '';
|
||||
ov.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);
|
||||
// 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');
|
||||
};
|
||||
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');
|
||||
});
|
||||
});
|
||||
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">
|
||||
<div class="cs-titlebar">
|
||||
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span>
|
||||
${escHtml(title || 'Character sheets')}
|
||||
<span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span>
|
||||
</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-secondary btn-sm" id="cs-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
|
||||
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
|
||||
</div>
|
||||
<div class="cs-list">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(ov);
|
||||
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
|
||||
} else {
|
||||
ov.innerHTML = `<div class="audiobook-box cs-box cs-page-shell" style="width:100%; max-width:none; max-height:none; height:auto;">
|
||||
<div class="cs-titlebar">
|
||||
<span class="audiobook-title"><span class="mdi mdi-account-details-outline"></span>
|
||||
${escHtml(title || 'Character sheets')}
|
||||
<span class="audiobook-count">${sheets.length} character${sheets.length !== 1 ? 's' : ''}</span>
|
||||
</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-secondary btn-sm" id="cs-back-cast-audio"><span class="mdi mdi-arrow-left"></span> Cast Audiobook</button>
|
||||
<button class="btn-secondary btn-sm" id="cs-open-cast"><span class="mdi mdi-folder-account-outline"></span> Open Cast</button>
|
||||
<button class="btn-secondary btn-sm" id="cs-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
|
||||
</div>
|
||||
<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>
|
||||
<div class="cs-list">${group('Main characters', main)}${group('Supporting characters', supp)}</div>
|
||||
</div>`;
|
||||
}
|
||||
csWireResultInteractions(ov, sheets, sourceText, book, inline);
|
||||
}
|
||||
|
||||
// ── Entry points (reader + rehearser) ────────────────────────────────────────
|
||||
@ -975,6 +1022,7 @@ function csGoToLibrary() {
|
||||
if (typeof navTo === 'function') navTo('s-library');
|
||||
if (typeof navLibraryView === 'function') navLibraryView('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
|
||||
@ -994,9 +1042,16 @@ async function csForReader() {
|
||||
const text = csReaderText();
|
||||
const book = readerState.title || 'Untitled book';
|
||||
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 sheets = await csGenerate(text, key, knownRoster);
|
||||
const sheets = await csGenerate(text, key, knownRoster, { pageHost });
|
||||
if (!sheets) return;
|
||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
|
||||
@ -1006,8 +1061,8 @@ async function csForReader() {
|
||||
csAttachLineCounts(sheets, counts);
|
||||
}
|
||||
await csSaveToLibrary(book, sheets);
|
||||
csGoToLibrary();
|
||||
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
|
||||
if (pageHost) await csShow(sheets, book, text, book, pageHost);
|
||||
toast(sheets.length + ' character sheets saved — Library → Cast', 'success');
|
||||
}
|
||||
|
||||
// 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);
|
||||
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() {
|
||||
@ -1070,8 +1125,15 @@ async function csForRehearser() {
|
||||
const book = $('reh-script-title')?.value.trim() || 'Untitled script';
|
||||
const text = csRehearserText();
|
||||
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
|
||||
if (_cs.cache[key]) { csGoToLibrary(); return; }
|
||||
const sheets = await csGenerate(text, key);
|
||||
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], title, text, book, pageHost);
|
||||
return;
|
||||
}
|
||||
const sheets = await csGenerate(text, key, null, { pageHost });
|
||||
if (!sheets) return;
|
||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||
if (window.rehState?.lines?.length) {
|
||||
@ -1084,8 +1146,8 @@ async function csForRehearser() {
|
||||
csAttachLineCounts(sheets, counts);
|
||||
}
|
||||
await csSaveToLibrary(book, sheets);
|
||||
csGoToLibrary();
|
||||
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
|
||||
if (pageHost) await csShow(sheets, title, text, book, pageHost);
|
||||
toast(sheets.length + ' character sheets saved — Library → Cast', 'success');
|
||||
}
|
||||
|
||||
window.csForReader = csForReader;
|
||||
|
||||
@ -420,10 +420,19 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
|
||||
function appendThinking(bubble, delta) {
|
||||
ensureAssistantParts(bubble);
|
||||
bubble.querySelector('.conv-think-wrap').hidden = false;
|
||||
// Stays collapsed regardless of streaming state — the user only sees the
|
||||
// reasoning text if they click the "Thinking" chevron to expand it.
|
||||
bubble.querySelector('.conv-think-body').textContent += delta;
|
||||
const wrap = bubble.querySelector('.conv-think-wrap');
|
||||
const toggle = bubble.querySelector('.conv-think-toggle');
|
||||
const body = bubble.querySelector('.conv-think-body');
|
||||
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) {
|
||||
|
||||
@ -26,9 +26,9 @@ window.navLibraryView = function (view) {
|
||||
document.querySelectorAll('[data-library-panel]').forEach(function (el) {
|
||||
el.classList.toggle('is-active', el.dataset.libraryPanel === view);
|
||||
});
|
||||
// Characters/Cast is the pipeline's "Cast Characters" stop — keep the
|
||||
// stepper's current-step highlight in sync when the tab is opened directly.
|
||||
if (view === 'characters' && typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('chars');
|
||||
// Cast roster is its own pipeline stop; keep the stepper highlight in sync
|
||||
// when the tab is opened directly.
|
||||
if (view === 'characters' && typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('castlib');
|
||||
libraryRender(view);
|
||||
};
|
||||
|
||||
|
||||
@ -1954,6 +1954,7 @@ window.addEventListener('resize', (function () {
|
||||
window.showReaderView = function (view) {
|
||||
const main = $('reader-main-view');
|
||||
const cast = $('reader-audiobook-panel');
|
||||
const chars = $('reader-charsheets-panel');
|
||||
const lib = $('reader-library-card');
|
||||
if (main) main.hidden = (view !== 'main');
|
||||
if (cast) {
|
||||
@ -1968,6 +1969,20 @@ window.showReaderView = function (view) {
|
||||
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) {
|
||||
lib.hidden = (view !== 'library');
|
||||
if (view === 'library') {
|
||||
@ -1978,7 +1993,9 @@ window.showReaderView = function (view) {
|
||||
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) {
|
||||
|
||||
@ -786,7 +786,7 @@ document.addEventListener('click', e => {
|
||||
});
|
||||
|
||||
// ── 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
|
||||
// with class .wf-stepper — currently one in s-reader.html, one in
|
||||
// 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: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length },
|
||||
{ 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: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.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 audiobookOpenCastView === 'function') audiobookOpenCastView();
|
||||
} else if (key === 'chars') {
|
||||
// Navigate to the Characters/Cast library view — generating sheets stays
|
||||
// on the explicit "Cast Characters" buttons; a navigation stop must never
|
||||
// kick off an LLM run as a side effect.
|
||||
// Navigate to the character-sheet stage in Read Aloud.
|
||||
if (typeof navTo === 'function') navTo('s-reader');
|
||||
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 navLibraryView === 'function') navLibraryView('characters');
|
||||
_wfActive = 'chars';
|
||||
_wfActive = 'castlib';
|
||||
refreshWorkflowCrumbs();
|
||||
} else if (key === 'rehearser') {
|
||||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||||
@ -887,7 +892,7 @@ function refreshWorkflowCrumbs(active) {
|
||||
window.refreshWorkflowCrumbs = refreshWorkflowCrumbs;
|
||||
|
||||
// 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) {
|
||||
refreshWorkflowCrumbs(n === 3 ? 'mp3' : n === 4 ? 'audiobook' : 'rehearser');
|
||||
};
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<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" 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>
|
||||
|
||||
<!-- Books -->
|
||||
|
||||
@ -191,6 +191,9 @@
|
||||
<!-- ⑦ Audiobook Casting Panel ──────────────────────────────────── -->
|
||||
<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. -->
|
||||
|
||||
</div><!-- /tab-reader -->
|
||||
|
||||
@ -5715,6 +5715,17 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
|
||||
/* Character sheets overlay */
|
||||
.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-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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user