diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5b4b9..e71b04a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.13.2] — 2026-07-06 + +### Fixed +- **Character table view rendered as stacked blocks, not a table** — table rows reused the `.lib-char-card` class (for its click/voice/export wiring) whose `display:flex;flex-direction:column` turned every `` into a flex column, stacking its cells vertically instead of laying them out side by side. Reset to `display:table-row` and stripped the card-specific chrome (background, border, shadow) that leaked in with it. + +### Changed +- **One Generate button per prompt box** instead of a single "Generate" that always regenerated all four (Voice Design, Character Image, SillyTavern, Concept Art) at once. The server endpoint now accepts a `fields` filter and runs one independent LLM call per requested field — clicking one button no longer burns tokens on the other three, and each call's smaller JSON further reduces truncation risk. +- **Sort dropdown for the character list** (Library → Characters/Cast) — Role/Alphabet/Number of lines/Gender/Voice assigned, persisted like the Cards/Table toggle. + +--- + ## [1.13.1] — 2026-07-06 ### Added diff --git a/VERSION b/VERSION index b50dd27..61ce01b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.1 +1.13.2 diff --git a/routes/conversation.py b/routes/conversation.py index 1839726..cdbc747 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -870,46 +870,55 @@ async def character_generate_prompts(request: Request): ) user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\n" - # Split into two independent calls instead of one four-field JSON object. - # Even at a 4096-token ceiling, the model reliably ran out of budget before - # reaching the LAST two fields (silly_tavern_prompt, concept_art_prompt come - # after voice_design_prompt/image_prompt in the JSON) — truncation always - # cost the same two fields. Each pair now gets its own full token budget, - # so a squeeze in one pair can't cost the other pair anything, and the two - # calls run concurrently so this isn't slower than the single-call version. - field_groups = [ - ( - ("voice_design_prompt", "image_prompt"), + # One independent call per field (not one four-field JSON object, nor even + # the two-field pairing this used briefly) — the UI now has a Generate + # button per prompt box, so a click on just one must not also burn tokens + # regenerating the other three. Singling them out also further shrinks + # each response, since a four-field JSON object reliably truncated the + # LAST fields regardless of the token ceiling. + all_groups = { + "voice_design_prompt": ( preamble - + "Produce exactly these two fields:\n" + + "Produce exactly this field:\n" "- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, " "no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, " "accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not " - "mention plot events — describe only how the voice should SOUND.\n" + "mention plot events — describe only how the voice should SOUND.\n\n" + 'Respond with STRICT JSON only: {"voice_design_prompt":""}/no-think' + ), + "image_prompt": ( + preamble + + "Produce exactly this field:\n" "- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. " "Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, " "clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style " "(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors " "are fine.\n\n" - 'Respond with STRICT JSON only: {"voice_design_prompt":"","image_prompt":""}/no-think', + 'Respond with STRICT JSON only: {"image_prompt":""}/no-think' ), - ( - ("silly_tavern_prompt", "concept_art_prompt"), + "silly_tavern_prompt": ( preamble - + "Produce exactly these two fields:\n" + + "Produce exactly this field:\n" "- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their " "own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), " "'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character " "greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character " - "lines showing their manner of speech). Keep each section a few lines at most.\n" + "lines showing their manner of speech). Keep each section a few lines at most.\n\n" + 'Respond with STRICT JSON only: {"silly_tavern_prompt":""}/no-think' + ), + "concept_art_prompt": ( + preamble + + "Produce exactly this field:\n" "- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — " "a turnaround/reference sheet with multiple views and expressions: front view, side or back view, " "2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, " "in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, " "white background').\n\n" - 'Respond with STRICT JSON only: {"silly_tavern_prompt":"","concept_art_prompt":""}/no-think', + 'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think' ), - ] + } + requested = [f for f in (data.get("fields") or []) if f in all_groups] + field_groups = [((f,), all_groups[f]) for f in (requested or all_groups.keys())] def _call_group(fields: tuple, system: str) -> dict: payload: dict = { @@ -918,7 +927,7 @@ async def character_generate_prompts(request: Request): {"role": "user", "content": user + f"Generate the {' and '.join(fields)} now."}, ], "temperature": 0.7, - "max_tokens": 2560, + "max_tokens": 1536, } if model: payload["model"] = model diff --git a/static/index.html b/static/index.html index 90c2afd..17fdb72 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/library-characters.js b/static/js/library-characters.js index fe22609..6240379 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -39,11 +39,26 @@ async function libraryRenderCharacters() { container.innerHTML = ''; // Global toolbar — import a cast from SillyTavern into a new/unsorted production, - // plus a Cards/Table view toggle (persisted, like Script Rehearser's). + // a Cards/Table view toggle, and a Sort dropdown (all persisted, like Script + // Rehearser's cast-list controls). const viewMode = localStorage.getItem('ttsvc_libchars_view') === 'table' ? 'table' : 'cards'; + const SORT_OPTIONS = [ + ['tier', 'Rolle (Haupt zuerst)'], + ['alpha', 'Alphabet'], + ['lines', 'Anzahl Zeilen'], + ['gender', 'Geschlecht'], + ['voice', 'Stimme zugewiesen'], + ]; + const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); }) + ? localStorage.getItem('ttsvc_libchars_sort') : 'tier'; const bar = document.createElement('div'); bar.className = 'lib-chars-toolbar'; bar.innerHTML = '' + + '' + '
' + '' + '' @@ -51,6 +66,10 @@ async function libraryRenderCharacters() { bar.querySelector('#lib-chars-import').addEventListener('click', function () { if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); }); }); + bar.querySelector('#lib-chars-sort-sel').addEventListener('change', function () { + localStorage.setItem('ttsvc_libchars_sort', this.value); + libraryRenderCharacters(); + }); bar.querySelectorAll('.lib-chars-view-toggle button').forEach(function (btn) { btn.addEventListener('click', function () { localStorage.setItem('ttsvc_libchars_view', btn.dataset.view); @@ -59,14 +78,25 @@ async function libraryRenderCharacters() { }); container.appendChild(bar); - const productions = document.createDocumentFragment(); - Object.keys(byBook).sort().forEach(function (book) { - const chars = byBook[book].sort(function (a, b) { + const _charSortCmp = { + tier: function (a, b) { const tierOrder = { main: 0, supporting: 1, minor: 2 }; const ta = tierOrder[String(a.sheet?.tier || 'minor').toLowerCase()] ?? 2; const tb = tierOrder[String(b.sheet?.tier || 'minor').toLowerCase()] ?? 2; return ta - tb || (a.name || '').localeCompare(b.name || ''); - }); + }, + alpha: function (a, b) { return (a.name || '').localeCompare(b.name || ''); }, + lines: function (a, b) { return (b.sheet?.line_count || 0) - (a.sheet?.line_count || 0) || (a.name || '').localeCompare(b.name || ''); }, + gender: function (a, b) { + const ga = String(a.sheet?.gender || 'zzz'), gb = String(b.sheet?.gender || 'zzz'); + return ga.localeCompare(gb) || (a.name || '').localeCompare(b.name || ''); + }, + voice: function (a, b) { return (b.voice ? 1 : 0) - (a.voice ? 1 : 0) || (a.name || '').localeCompare(b.name || ''); }, + }; + + const productions = document.createDocumentFragment(); + Object.keys(byBook).sort().forEach(function (book) { + const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier); const cov = libBookCover(book); const prod = document.createElement('div'); @@ -423,7 +453,10 @@ function _lcdPromptBox(label, value, sheetKey) { + '' + escHtml(label) + (has ? '' : ' — not generated yet') + '' + '
' + '
' + escHtml(value || '') + '
' - + '' + + '
' + + '' + + '' + + '
' + '
' + ''; } @@ -515,9 +548,7 @@ async function _charDetailPage(rec, allChars) { ) : ''; const promptsHtml = '
' - + '' + + '' + _lcdPromptBox('Voice Design Prompt', sh.voice_design_prompt, 'voice_design_prompt') + _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt') + _lcdPromptBox('SillyTavern Character Prompt', sh.silly_tavern_prompt, 'silly_tavern_prompt') @@ -727,41 +758,39 @@ async function _charDetailPage(rec, allChars) { toast('Prompt copied', 'success'); }); }); - pg.querySelector('.lcd-gen-prompts')?.addEventListener('click', async function () { - const btn = this; - const orig = btn.innerHTML; - btn.disabled = true; - btn.innerHTML = ' Generating…'; - try { - const sh2 = rec.sheet || {}; - const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' '); - const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : ''; - const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' }; - const r = await fetch('/api/character-generate-prompts', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model }), - }); - if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText); - const d = await r.json(); - if (!rec.sheet) rec.sheet = {}; - const labels = { voice_design_prompt: 'Voice Design', image_prompt: 'Character Image', silly_tavern_prompt: 'SillyTavern', concept_art_prompt: 'Concept Art' }; - const missing = []; - Object.keys(labels).forEach(function (k) { - if (d[k]) rec.sheet[k] = d[k]; - else if (!rec.sheet[k]) missing.push(labels[k]); - }); - rec.updated = new Date(); - if (typeof clPut === 'function') await clPut(rec); - // A partially-cut-off LLM answer used to look exactly like success with - // two silently empty boxes — say which ones are missing instead. - if (missing.length) toast('Generated, but incomplete — missing: ' + missing.join(', ') + '. Click Generate again.', 'error'); - else toast('Generation prompts created', 'success'); - _charDetailPage(rec, allChars); // re-render so the boxes show the new content - } catch (err) { - toast('Prompt generation failed: ' + (err.message || err), 'error'); - btn.disabled = false; - btn.innerHTML = orig; - } + // One Generate button per prompt box — the server accepts a `fields` filter + // so clicking just one no longer burns tokens (re)generating all four. + pg.querySelectorAll('.lcd-gen-prompt').forEach(function (btn) { + btn.addEventListener('click', async function (e) { + e.preventDefault(); + const key = btn.dataset.sheetKey; + const orig = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Generating…'; + try { + const sh2 = rec.sheet || {}; + const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' '); + const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : ''; + const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' }; + const r = await fetch('/api/character-generate-prompts', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model, fields: [key] }), + }); + if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText); + const d = await r.json(); + if (!d[key]) throw new Error('Empty response — try again'); + if (!rec.sheet) rec.sheet = {}; + rec.sheet[key] = d[key]; + rec.updated = new Date(); + if (typeof clPut === 'function') await clPut(rec); + toast('Prompt generated', 'success'); + _charDetailPage(rec, allChars); // re-render so the box shows the new content + } catch (err) { + toast('Prompt generation failed: ' + (err.message || err), 'error'); + btn.disabled = false; + btn.innerHTML = orig; + } + }); }); const slider = pg.querySelector('.lcd-align-slider'); diff --git a/static/style.css b/static/style.css index 478893e..113d107 100644 --- a/static/style.css +++ b/static/style.css @@ -559,6 +559,11 @@ audio { width: 100%; } /* ── Characters / Cast view ─────────────────────────────────────────────────── */ .lib-chars-toolbar { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; align-items:center; } +.lib-chars-sort { display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--subtext); font-weight:600; } +.lib-chars-sort select { + padding:5px 8px; border-radius:6px; border:1px solid var(--border); background:var(--surface); + color:var(--text); font-size:12.5px; cursor:pointer; +} .lib-chars-view-toggle { display:flex; gap:2px; margin-left:auto; } .lib-chars-view-toggle button.is-active { background:var(--accent); color:#fff; border-color:var(--accent); } @@ -571,7 +576,16 @@ audio { width: 100%; } position:sticky; top:0; z-index:1; } .lib-chars-tbl td { padding:6px 10px; border-bottom:1px solid var(--border); vertical-align:middle; } -.lib-chars-tbl-row { cursor:pointer; } +/* Rows reuse .lib-char-card so the existing per-card click/voice/export wiring + picks them up, but that class's display:flex;flex-direction:column (meant + for the card grid) turned every into a flex column, stacking its s + vertically instead of laying the table out in columns. Reset it back to a + real table row, and undo the card-specific chrome that leaked in with it. */ +tr.lib-char-card.lib-chars-tbl-row { + display: table-row; cursor: pointer; + background: none; border: 0; border-radius: 0; box-shadow: none; transition: none; +} +tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; background: var(--panel); } .lib-chars-tbl-row:hover { background:var(--panel); } .lib-chars-tbl-avatar { width:28px; height:28px; font-size:12px; border:none; box-shadow:none; } .lib-chars-tbl-row .lib-char-export { position:static; opacity:1; width:auto; height:auto; border:0; background:none; color:var(--subtext); padding:2px; } @@ -805,7 +819,7 @@ audio { width: 100%; } padding: 8px 10px; min-height: 40px; outline: none; } .lcd-prompt-text:focus { border-color: var(--accent); } -.lcd-prompt-copy { align-self: flex-end; } +.lcd-prompt-actions { display: flex; gap: 6px; justify-content: flex-end; } .lcd-field { margin-bottom:16px; } .lcd-field:last-child { margin-bottom:0; } .lcd-field-label {