diff --git a/CHANGELOG.md b/CHANGELOG.md index d871815..c61ec45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 9631867..02fd050 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.14.16 +1.14.17 diff --git a/routes/conversation.py b/routes/conversation.py index 30e0155..75b0dfb 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -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 " diff --git a/static/index.html b/static/index.html index c22025e..29f9d33 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/character-sheets.js b/static/js/character-sheets.js index 1021382..a8deeb7 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -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 0–100. 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 = `
-
Character sheets
+
+ Character sheets + +
Analysing…
+
@@ -398,6 +458,80 @@ function csProgress(total) {
`; 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 = '' + + savedCsPrompts.map((p, i) => ``).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'); diff --git a/static/style.css b/static/style.css index ebb9812..2c13c2e 100644 --- a/static/style.css +++ b/static/style.css @@ -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; }