Fix table-view layout bug, add per-prompt Generate buttons, sort dropdown (v1.13.2)
Table view was rendering as stacked blocks instead of columns: rows reused .lib-char-card for its event wiring, but that class's display:flex;flex-direction:column turned every <tr> into a flex column. Reset to display:table-row and stripped the leaked-in card chrome. Split /api/character-generate-prompts into four independent per-field LLM calls (from two paired calls) and added a `fields` filter, so the UI can offer one Generate button per prompt box instead of a single button that always regenerated all four - cheaper, and further shrinks each response to reduce truncation risk. Added a Sort dropdown (Role/Alphabet/Lines/Gender/Voice assigned) to the Characters/Cast list, persisted like the Cards/Table toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
204bc3a6c6
commit
628bd75a82
11
CHANGELOG.md
11
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 `<tr>` 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
|
## [1.13.1] — 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -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"
|
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.
|
# One independent call per field (not one four-field JSON object, nor even
|
||||||
# Even at a 4096-token ceiling, the model reliably ran out of budget before
|
# the two-field pairing this used briefly) — the UI now has a Generate
|
||||||
# reaching the LAST two fields (silly_tavern_prompt, concept_art_prompt come
|
# button per prompt box, so a click on just one must not also burn tokens
|
||||||
# after voice_design_prompt/image_prompt in the JSON) — truncation always
|
# regenerating the other three. Singling them out also further shrinks
|
||||||
# cost the same two fields. Each pair now gets its own full token budget,
|
# each response, since a four-field JSON object reliably truncated the
|
||||||
# so a squeeze in one pair can't cost the other pair anything, and the two
|
# LAST fields regardless of the token ceiling.
|
||||||
# calls run concurrently so this isn't slower than the single-call version.
|
all_groups = {
|
||||||
field_groups = [
|
"voice_design_prompt": (
|
||||||
(
|
|
||||||
("voice_design_prompt", "image_prompt"),
|
|
||||||
preamble
|
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, "
|
"- 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, "
|
"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 "
|
"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. "
|
"- 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, "
|
"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 "
|
"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 "
|
"(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors "
|
||||||
"are fine.\n\n"
|
"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": (
|
||||||
("silly_tavern_prompt", "concept_art_prompt"),
|
|
||||||
preamble
|
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 "
|
"- 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), "
|
"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 "
|
"'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 "
|
"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) — "
|
"- 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, "
|
"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, "
|
"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, "
|
"in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, "
|
||||||
"white background').\n\n"
|
"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:
|
def _call_group(fields: tuple, system: str) -> dict:
|
||||||
payload: 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."},
|
{"role": "user", "content": user + f"Generate the {' and '.join(fields)} now."},
|
||||||
],
|
],
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tokens": 2560,
|
"max_tokens": 1536,
|
||||||
}
|
}
|
||||||
if model:
|
if model:
|
||||||
payload["model"] = model
|
payload["model"] = model
|
||||||
|
|||||||
@ -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.13.1">
|
<meta name="app-version" content="1.13.2">
|
||||||
<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.13.1">
|
<link rel="stylesheet" href="/static/style.css?v=1.13.2">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── 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.13.1"></script>
|
<script src="/static/loader.js?v=1.13.2"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -39,11 +39,26 @@ async function libraryRenderCharacters() {
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Global toolbar — import a cast from SillyTavern into a new/unsorted production,
|
// 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 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');
|
const bar = document.createElement('div');
|
||||||
bar.className = 'lib-chars-toolbar';
|
bar.className = 'lib-chars-toolbar';
|
||||||
bar.innerHTML = '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
|
bar.innerHTML = '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
|
||||||
|
+ '<label class="lib-chars-sort"><span class="mdi mdi-sort"></span> Sort'
|
||||||
|
+ '<select id="lib-chars-sort-sel">' + SORT_OPTIONS.map(function (o) {
|
||||||
|
return '<option value="' + o[0] + '"' + (o[0] === sortMode ? ' selected' : '') + '>' + o[1] + '</option>';
|
||||||
|
}).join('') + '</select>'
|
||||||
|
+ '</label>'
|
||||||
+ '<div class="lib-chars-view-toggle">'
|
+ '<div class="lib-chars-view-toggle">'
|
||||||
+ '<button class="btn-secondary btn-sm' + (viewMode === 'cards' ? ' is-active' : '') + '" data-view="cards"><span class="mdi mdi-view-grid-outline"></span> Cards</button>'
|
+ '<button class="btn-secondary btn-sm' + (viewMode === 'cards' ? ' is-active' : '') + '" data-view="cards"><span class="mdi mdi-view-grid-outline"></span> Cards</button>'
|
||||||
+ '<button class="btn-secondary btn-sm' + (viewMode === 'table' ? ' is-active' : '') + '" data-view="table"><span class="mdi mdi-table"></span> Table</button>'
|
+ '<button class="btn-secondary btn-sm' + (viewMode === 'table' ? ' is-active' : '') + '" data-view="table"><span class="mdi mdi-table"></span> Table</button>'
|
||||||
@ -51,6 +66,10 @@ async function libraryRenderCharacters() {
|
|||||||
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
|
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
|
||||||
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
|
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) {
|
bar.querySelectorAll('.lib-chars-view-toggle button').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
localStorage.setItem('ttsvc_libchars_view', btn.dataset.view);
|
localStorage.setItem('ttsvc_libchars_view', btn.dataset.view);
|
||||||
@ -59,14 +78,25 @@ async function libraryRenderCharacters() {
|
|||||||
});
|
});
|
||||||
container.appendChild(bar);
|
container.appendChild(bar);
|
||||||
|
|
||||||
const productions = document.createDocumentFragment();
|
const _charSortCmp = {
|
||||||
Object.keys(byBook).sort().forEach(function (book) {
|
tier: function (a, b) {
|
||||||
const chars = byBook[book].sort(function (a, b) {
|
|
||||||
const tierOrder = { main: 0, supporting: 1, minor: 2 };
|
const tierOrder = { main: 0, supporting: 1, minor: 2 };
|
||||||
const ta = tierOrder[String(a.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
const ta = tierOrder[String(a.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
||||||
const tb = tierOrder[String(b.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
const tb = tierOrder[String(b.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
||||||
return ta - tb || (a.name || '').localeCompare(b.name || '');
|
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 cov = libBookCover(book);
|
||||||
const prod = document.createElement('div');
|
const prod = document.createElement('div');
|
||||||
@ -423,7 +453,10 @@ function _lcdPromptBox(label, value, sheetKey) {
|
|||||||
+ '<summary>' + escHtml(label) + (has ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>') + '</summary>'
|
+ '<summary>' + escHtml(label) + (has ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>') + '</summary>'
|
||||||
+ '<div class="lcd-prompt-body">'
|
+ '<div class="lcd-prompt-body">'
|
||||||
+ '<div class="lcd-prompt-text lcd-field-editable" contenteditable="true" spellcheck="false" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(value || '') + '</div>'
|
+ '<div class="lcd-prompt-text lcd-field-editable" contenteditable="true" spellcheck="false" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(value || '') + '</div>'
|
||||||
+ '<button type="button" class="btn-secondary btn-sm lcd-prompt-copy" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-content-copy"></span> Copy</button>'
|
+ '<div class="lcd-prompt-actions">'
|
||||||
|
+ '<button type="button" class="btn-secondary btn-sm lcd-prompt-copy" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-content-copy"></span> Copy</button>'
|
||||||
|
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompt" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-creation"></span> ' + (has ? 'Regenerate' : 'Generate') + '</button>'
|
||||||
|
+ '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '</details>';
|
+ '</details>';
|
||||||
}
|
}
|
||||||
@ -515,9 +548,7 @@ async function _charDetailPage(rec, allChars) {
|
|||||||
) : '';
|
) : '';
|
||||||
|
|
||||||
const promptsHtml = '<div class="lcd-section-full lcd-prompts-section">'
|
const promptsHtml = '<div class="lcd-section-full lcd-prompts-section">'
|
||||||
+ '<div class="lcd-section-label"><span class="mdi mdi-script-text-outline"></span> Generation Prompts'
|
+ '<div class="lcd-section-label"><span class="mdi mdi-script-text-outline"></span> Generation Prompts</div>'
|
||||||
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompts"><span class="mdi mdi-creation"></span> Generate</button>'
|
|
||||||
+ '</div>'
|
|
||||||
+ _lcdPromptBox('Voice Design Prompt', sh.voice_design_prompt, 'voice_design_prompt')
|
+ _lcdPromptBox('Voice Design Prompt', sh.voice_design_prompt, 'voice_design_prompt')
|
||||||
+ _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt')
|
+ _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt')
|
||||||
+ _lcdPromptBox('SillyTavern Character Prompt', sh.silly_tavern_prompt, 'silly_tavern_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');
|
toast('Prompt copied', 'success');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
pg.querySelector('.lcd-gen-prompts')?.addEventListener('click', async function () {
|
// One Generate button per prompt box — the server accepts a `fields` filter
|
||||||
const btn = this;
|
// so clicking just one no longer burns tokens (re)generating all four.
|
||||||
const orig = btn.innerHTML;
|
pg.querySelectorAll('.lcd-gen-prompt').forEach(function (btn) {
|
||||||
btn.disabled = true;
|
btn.addEventListener('click', async function (e) {
|
||||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
|
e.preventDefault();
|
||||||
try {
|
const key = btn.dataset.sheetKey;
|
||||||
const sh2 = rec.sheet || {};
|
const orig = btn.innerHTML;
|
||||||
const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' ');
|
btn.disabled = true;
|
||||||
const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : '';
|
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
|
||||||
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
|
try {
|
||||||
const r = await fetch('/api/character-generate-prompts', {
|
const sh2 = rec.sheet || {};
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' ');
|
||||||
body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model }),
|
const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : '';
|
||||||
});
|
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
|
||||||
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
|
const r = await fetch('/api/character-generate-prompts', {
|
||||||
const d = await r.json();
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
if (!rec.sheet) rec.sheet = {};
|
body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model, fields: [key] }),
|
||||||
const labels = { voice_design_prompt: 'Voice Design', image_prompt: 'Character Image', silly_tavern_prompt: 'SillyTavern', concept_art_prompt: 'Concept Art' };
|
});
|
||||||
const missing = [];
|
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
|
||||||
Object.keys(labels).forEach(function (k) {
|
const d = await r.json();
|
||||||
if (d[k]) rec.sheet[k] = d[k];
|
if (!d[key]) throw new Error('Empty response — try again');
|
||||||
else if (!rec.sheet[k]) missing.push(labels[k]);
|
if (!rec.sheet) rec.sheet = {};
|
||||||
});
|
rec.sheet[key] = d[key];
|
||||||
rec.updated = new Date();
|
rec.updated = new Date();
|
||||||
if (typeof clPut === 'function') await clPut(rec);
|
if (typeof clPut === 'function') await clPut(rec);
|
||||||
// A partially-cut-off LLM answer used to look exactly like success with
|
toast('Prompt generated', 'success');
|
||||||
// two silently empty boxes — say which ones are missing instead.
|
_charDetailPage(rec, allChars); // re-render so the box shows the new content
|
||||||
if (missing.length) toast('Generated, but incomplete — missing: ' + missing.join(', ') + '. Click Generate again.', 'error');
|
} catch (err) {
|
||||||
else toast('Generation prompts created', 'success');
|
toast('Prompt generation failed: ' + (err.message || err), 'error');
|
||||||
_charDetailPage(rec, allChars); // re-render so the boxes show the new content
|
btn.disabled = false;
|
||||||
} catch (err) {
|
btn.innerHTML = orig;
|
||||||
toast('Prompt generation failed: ' + (err.message || err), 'error');
|
}
|
||||||
btn.disabled = false;
|
});
|
||||||
btn.innerHTML = orig;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const slider = pg.querySelector('.lcd-align-slider');
|
const slider = pg.querySelector('.lcd-align-slider');
|
||||||
|
|||||||
@ -559,6 +559,11 @@ audio { width: 100%; }
|
|||||||
|
|
||||||
/* ── Characters / Cast view ─────────────────────────────────────────────────── */
|
/* ── Characters / Cast view ─────────────────────────────────────────────────── */
|
||||||
.lib-chars-toolbar { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
|
.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 { display:flex; gap:2px; margin-left:auto; }
|
||||||
.lib-chars-view-toggle button.is-active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
.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;
|
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 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 <tr> into a flex column, stacking its <td>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-row:hover { background:var(--panel); }
|
||||||
.lib-chars-tbl-avatar { width:28px; height:28px; font-size:12px; border:none; box-shadow:none; }
|
.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; }
|
.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;
|
padding: 8px 10px; min-height: 40px; outline: none;
|
||||||
}
|
}
|
||||||
.lcd-prompt-text:focus { border-color: var(--accent); }
|
.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 { margin-bottom:16px; }
|
||||||
.lcd-field:last-child { margin-bottom:0; }
|
.lcd-field:last-child { margin-bottom:0; }
|
||||||
.lcd-field-label {
|
.lcd-field-label {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user