Add editable Prompt panel for Character sheets (v1.14.17)

Same pattern as the Casting audiobook Prompt button: a collapsible
textarea with the full extraction prompt (fields + sources/citation
schema), named-preset save/load/delete, and live persistence so edits
apply from the next passage onward. Server-side, _charsheets_prepare()
now accepts a character_sheets_prompt override (shared by the blocking
and streaming endpoints), following the same pattern already used for
the audiobook attribution prompt override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-09 12:21:26 +02:00
parent b5ffa655d8
commit 606ae125a3
6 changed files with 167 additions and 7 deletions

View File

@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.14.17] — 2026-07-09
### Added
- **Editable "Prompt" panel for Character sheets**, matching the one already on Casting audiobook — a button in the progress modal's header opens a textarea with the full extraction prompt (all ~25 fields + the sources/citation schema), with named-preset save/load/delete. Edits persist as the new default and take effect from the next passage onward (even mid-run), via a new `character_sheets_prompt` override accepted by `_charsheets_prepare()` server-side (shared by both the blocking and streaming endpoints, so it can't drift between them).
---
## [1.14.16] — 2026-07-09
### Changed

View File

@ -1 +1 @@
1.14.16
1.14.17

View File

@ -617,7 +617,17 @@ def _charsheets_prepare(data: dict) -> dict:
"- You may add a brand-new character only when the passage clearly introduces one that is not in the target roster.\n"
"- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n"
) if target_mode and known else ""
system = (
# A user-editable prompt (client-side "Prompt" panel, mirroring the
# audiobook casting one) replaces the extraction-instructions/schema body
# below wholesale — the language/target-mode prefixes above still apply
# regardless, since they depend on per-request state the user's saved
# prompt text can't know about.
prompt_override = data.get("character_sheets_prompt")
if isinstance(prompt_override, str) and prompt_override.strip():
system = f"{lang_instruction}{target_instruction}{prompt_override.strip()}"
else:
system = (
f"{lang_instruction}"
f"{target_instruction}"
"You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich "

View File

@ -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.16">
<meta name="app-version" content="1.14.17">
<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.16">
<link rel="stylesheet" href="/static/style.css?v=1.14.17">
<!-- ── 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.16"></script>
<script src="/static/loader.js?v=1.14.17"></script>
</body>
</html>

View File

@ -7,6 +7,48 @@
const CS_CHUNK_CHARS = 4000;
const _cs = { running: false, cancel: false, cache: {} };
// Mirrors the hardcoded extraction-instructions/schema body in
// routes/conversation.py's _charsheets_prepare(). Editable via the "Prompt"
// panel (same pattern as audiobook casting) — the language/cast-target-mode
// prefixes are still applied server-side regardless, since they depend on
// per-request state a saved prompt can't know about.
const CS_DEFAULT_PROMPT = `You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich character sheets passage by passage as a book is read. Extract playable, action-oriented sheets an actor can use to immediately know how to PLAY the character.
PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'.
For each character output these fields:
- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.
- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.
- first_name, last_name, full_name, title: split the character identity when known. Leave unknown parts empty. Put noble/office/role labels in title (e.g. 'Henker', 'Vampir', 'Graf').
- archetype: a two-word role summary (e.g. 'Ruthless Scholar')
- gender: 'male', 'female', or 'nonbinary' as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.
- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.
- clothing: distinctive clothing, armour, accessories as observed in the text
- alignment: strict moral code + the one line they will never cross
- moral_alignment_score: integer 0100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous
- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'
- arc_note: one sentence explaining the arc or moral position visible so far
- attribute_high / attribute_low: highest and lowest natural attribute (Charisma, Intelligence, Wisdom, Agility)
- skills: what they are demonstrably good at in the story
- capabilities: combat, magic, social, technical, or other demonstrated abilities
- backstory: origin, formative background and history revealed in the text
- relationships: key allies, family, rivals and enemies name them and how they relate
- motivation: the inner drive WHY they pursue what they pursue (distinct from the win condition)
- fears: their deepest fears, phobias or dread
- mannerisms: habitual gestures, tics, body language, habits and quirks
- voice_pattern: speech style accent, pacing, vocabulary, register and verbal tics (for voice casting)
- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.
- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.
- inventory: 1-3 defining items/props/clothing (array of short strings)
- secret: dark secret or fatal flaw
- conflict_style: fight, flight, or manipulate how they act when cornered
- win_condition: the specific event that would make them feel they have won
- tier: 'main' or 'supporting'
- sources: array of {page, quote, line_hint} the page number from the nearest [p.N] marker, a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.
IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.
Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.
Respond with STRICT JSON only:
{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
/no-think`;
// Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the
// app's loaded settings. Crucially, NEVER fall back to the hardcoded
// localhost:11434 default — if settings aren't loaded yet, return '' so the
@ -316,7 +358,11 @@ async function csGenerate(text, cacheKey, initialRoster) {
prog.update(i, `Passage ${i + 1} / ${chunks.length}`, charLabel, csProgressSnapshot(map));
prog.startPassage(chunks[i]);
try {
const body = { text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model };
// Read fresh each passage so an edit made mid-run (via the Prompt
// panel) takes effect starting from the next request, not just on
// the next full run.
const character_sheets_prompt = (typeof _appSettings !== 'undefined' && _appSettings.character_sheets_prompt) || CS_DEFAULT_PROMPT;
const body = { text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model, character_sheets_prompt };
let data = null;
let sawDelta = false;
try {
@ -374,8 +420,22 @@ function csProgress(total) {
// so the user can watch each sheet actually being built.
ov.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</div>
<div class="audiobook-title">
<span class="mdi mdi-account-details-outline"></span> Character sheets
<button class="ab-castpanel-btn" id="cs-prompt-btn" title="Edit character-sheet extraction prompt" style="margin-left:auto; display:flex; align-items:center; gap:4px; padding:4px 8px; font-weight:600; font-size:12px; width:auto; height:auto; min-height:28px;"><span class="mdi mdi-text-box-edit-outline"></span> Prompt <span class="mdi mdi-chevron-down" id="cs-prompt-chevron"></span></button>
</div>
<div class="audiobook-msg" id="cs-progress-msg">Analysing</div>
<div class="cs-progress-prompt" id="cs-prompt-panel" hidden>
<textarea id="cs-prompt-text" rows="8" placeholder="Character-sheet extraction instructions..."></textarea>
<div class="cs-progress-prompt-actions">
<select id="cs-prompt-lib" class="btn-secondary btn-sm" aria-label="Load a saved prompt">
<option value=""> Load saved prompt </option>
</select>
<button class="btn-secondary btn-sm" id="cs-prompt-del" title="Delete selected prompt" style="display:none; color:var(--error);"><span class="mdi mdi-delete-outline"></span></button>
<input type="text" id="cs-prompt-name" placeholder="Preset name...">
<button class="btn-primary btn-sm" id="cs-prompt-save" title="Save current prompt as a preset"><span class="mdi mdi-content-save"></span> Save preset</button>
</div>
</div>
<div class="reader-synth-track" style="margin-bottom:0"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
</div>
<div class="cs-progress-body">
@ -398,6 +458,80 @@ function csProgress(total) {
</div>`;
document.body.appendChild(ov);
ov.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', () => {
promptPanel.hidden = !promptPanel.hidden;
const chevron = ov.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 renderCsPromptLib = (selectedIdx = -1) => {
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', () => {
const idx = parseInt(libSelect.value, 10);
if (!isNaN(idx) && savedCsPrompts[idx]) {
promptText.value = savedCsPrompts[idx].prompt;
nameInput.value = savedCsPrompts[idx].name;
delBtn.style.display = 'inline-flex';
} else {
nameInput.value = '';
delBtn.style.display = 'none';
}
});
delBtn.addEventListener('click', () => {
const idx = parseInt(libSelect.value, 10);
if (isNaN(idx)) return;
if (confirm('Delete this saved prompt preset?')) {
savedCsPrompts.splice(idx, 1);
localStorage.setItem('ttsvc_cs_prompts', JSON.stringify(savedCsPrompts));
renderCsPromptLib();
toast('Prompt deleted', 'success');
}
});
ov.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);
if (!isNaN(targetIdx) && savedCsPrompts[targetIdx] && savedCsPrompts[targetIdx].name === name) {
savedCsPrompts[targetIdx].prompt = val;
} else {
savedCsPrompts.push({ name, prompt: val });
targetIdx = savedCsPrompts.length - 1;
}
localStorage.setItem('ttsvc_cs_prompts', JSON.stringify(savedCsPrompts));
renderCsPromptLib(targetIdx);
toast('Prompt preset saved', 'success');
});
// Persist to settings on every edit (debounced) so csGenerate()'s
// 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', () => {
clearTimeout(promptSaveTimer);
promptSaveTimer = setTimeout(() => {
const val = promptText.value;
if (typeof _appSettings !== 'undefined') _appSettings.character_sheets_prompt = val;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ character_sheets_prompt: val }) }).catch(() => {});
}, 500);
});
}
ov.style.display = 'flex';
const fill = ov.querySelector('#cs-progress-fill');

View File

@ -5786,6 +5786,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.cs-progress-box-big { width: min(1400px, 96vw); height: min(880px, 90vh); display: flex; flex-direction: column; padding: 20px 22px; }
.cs-progress-head { flex-shrink: 0; }
.cs-progress-head .audiobook-msg { margin: 8px 0 10px; }
.cs-progress-prompt { padding: 10px 12px; margin: 0 0 10px; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; }
.cs-progress-prompt textarea {
width: 100%; font-family: monospace; font-size: 12.5px; font-weight: 500; padding: 10px;
line-height: 1.5; border: 1px solid var(--border); border-radius: 6px; background: var(--surface);
color: var(--text); resize: vertical;
}
.cs-progress-prompt-actions { display: flex; justify-content: flex-end; margin-top: 8px; gap: 8px; align-items: center; }
.cs-progress-prompt-actions select { max-width: 200px; outline: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; font-size: 12px; background: var(--surface); color: var(--text); }
.cs-progress-prompt-actions input { width: 140px; padding: 4px 8px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--text); outline: none; }
.cs-progress-body { flex: 1; min-height: 0; display: flex; gap: 14px; margin-top: 12px; }
.cs-progress-main { flex: 1; min-width: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.cs-progress-col { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 6px; }