Release v1.9.0 — Character Library + richer character sheets
Add a persistent, book-scoped Character Library (new Characters section, IndexedDB) that auto-fills from Character-sheet analysis with editable cards. Enrich extraction with six narrative fields (backstory, relationships, motivation, fears, mannerisms, voice/speech) plus the greyscale Good↔Evil alignment bar, arc arrow, and 5-area Deep Analysis. Add ⋯ separators between non-contiguous passages in Recast unknown, and harden dialogue attribution against hallucination with same-language emotion tags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
18b4c1959c
commit
495550bf6a
15
CHANGELOG.md
15
CHANGELOG.md
@ -9,6 +9,21 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.9.0] — 2026-06-26
|
||||
|
||||
### Added
|
||||
- **Character Library**: a new **Characters** section in the sidebar collects every character the LLM extracts into a persistent, browsable library (IndexedDB), grouped by the book or script they came from. Running **Character sheets** from Read Aloud or the Script Rehearser now auto-saves each character (keyed by *book + name*), merging in new detail on re-runs. Each card can be **edited** inline or **deleted**, and keeps its greyscale Good↔Evil alignment bar, arc arrow, page/line sources, and the 5-area psychological **Deep Analysis**. New module `static/js/characters-library.js`.
|
||||
- **Richer character sheets**: extraction now gathers six more book-derived fields per character — *Backstory & Origin, Relationships, Motivation, Fears, Mannerisms & Habits,* and a casting-focused *Voice & Speech* pattern (accent, pacing, register, verbal tics).
|
||||
- **Character sheets — morality at a glance**: each sheet now shows a **greyscale Good↔Evil alignment bar** (white = good, black = evil) with a 0–100 score, plus an **arc arrow** indicating whether the character stays put, *descends* (↘ good→bad), *redeems* (↗ bad→good), or follows a *complex* (↕) path. Also added **Clothing & Appearance** and **Capabilities** fields and richer physical detail (height, hair, eyes, skin, gait), with sources now carrying a short category **line hint**.
|
||||
- **Character sheets — Deep Analysis**: a per-character button runs a 5-area psychological & narrative study (Core Flaw & Desire · Agency & Passivity · Dialogue & Voice · Narrative Arc · Paradox & Depth) in a modal. New endpoint `POST /api/character-deep-analysis`.
|
||||
- **Audiobook casting — Recast & rescue tools**: after a cast run you can now **Recast all** (re-run the whole document with tweaked settings/prompt), **Recast unknown** (re-attribute only the leftover *Unknown* lines using surrounding context), run a **2nd Quality Run — Verify** pass that re-checks every speaker assignment and resolves Unknowns, and **Save script** straight to Script Rehearsals without leaving the page.
|
||||
|
||||
### Changed
|
||||
- **Recast unknown — passage separators**: re-analysing only the *Unknown* speakers now shows a `⋯` divider between non-adjacent passages, so it's clear where one excerpt ends and another begins.
|
||||
- **Dialogue attribution — fidelity & language**: the attribution prompt now forbids hallucinating/summarising (segments must reconstruct the passage word-for-word), emits emotion tags in the **same language** as the text (e.g. German *wütend/flüsternd*), and demands strict JSON. Removed the brittle text-script fallback parser that could mis-split prompt echoes into fake speakers.
|
||||
|
||||
---
|
||||
|
||||
## [1.8.1] — 2026-06-25
|
||||
|
||||
### Added
|
||||
|
||||
@ -466,9 +466,9 @@ async def character_sheets(request: Request):
|
||||
|
||||
lang_hint = f" The text language is {language}; write the sheet in that language." if language else ""
|
||||
system = (
|
||||
"You are an expert dramaturge and tabletop RPG game master building character sheets that FILL UP "
|
||||
"as a book is read passage by passage. Read this passage and extract playable, action-oriented "
|
||||
"character sheets — sheets an actor can use to immediately know how to PLAY the character.\n"
|
||||
"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.\n"
|
||||
"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 "
|
||||
@ -478,22 +478,37 @@ async def character_sheets(request: Request):
|
||||
"For each character output these fields:\n"
|
||||
"- name, aliases\n"
|
||||
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
|
||||
"- physical: age, build, vocal quality, posture. Use ONLY the metric system for any height/weight.\n"
|
||||
"- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
|
||||
"- clothing: distinctive clothing, armour, accessories — as observed in the text\n"
|
||||
"- alignment: strict moral code + the one line they will never cross\n"
|
||||
"- moral_alignment_score: integer 0–100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous\n"
|
||||
"- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'\n"
|
||||
"- arc_note: one sentence explaining the arc or moral position visible so far\n"
|
||||
"- attribute_high / attribute_low: highest and lowest natural attribute (Charisma, Intelligence, Wisdom, Agility…)\n"
|
||||
"- skills: what they are demonstrably good at in the story\n"
|
||||
"- capabilities: combat, magic, social, technical, or other demonstrated abilities\n"
|
||||
"- backstory: origin, formative background and history revealed in the text\n"
|
||||
"- relationships: key allies, family, rivals and enemies — name them and how they relate\n"
|
||||
"- motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition)\n"
|
||||
"- fears: their deepest fears, phobias or dread\n"
|
||||
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
|
||||
"- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n"
|
||||
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
|
||||
"- secret: dark secret or fatal flaw\n"
|
||||
"- conflict_style: fight, flight, or manipulate — how they act when cornered\n"
|
||||
"- win_condition: the specific event that would make them feel they have won\n"
|
||||
"- tier: 'main' or 'supporting'\n"
|
||||
"- sources: array of {page, quote} — the page number from the nearest [p.N] marker and a short "
|
||||
"verbatim quote that supports the sheet (1-3 entries). Use null page if unknown.\n"
|
||||
"- 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-5 entries), and a brief label (e.g. 'appearance', 'alignment'). "
|
||||
"Use null page if unknown.\n"
|
||||
"Reuse the EXACT names from the known-characters list for returning characters. "
|
||||
"Respond with STRICT JSON only:\n"
|
||||
'{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","alignment":"",'
|
||||
'"attribute_high":"","attribute_low":"","skills":"","inventory":[],"secret":"",'
|
||||
'"conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":""}]}]}\n/no-think'
|
||||
'{"sheets":[{"name":"","aliases":"","archetype":"","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":"",'
|
||||
'"inventory":[],"secret":"","conflict_style":"","win_condition":"",'
|
||||
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
|
||||
)
|
||||
user = (
|
||||
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
||||
@ -506,7 +521,7 @@ async def character_sheets(request: Request):
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.4,
|
||||
"max_tokens": 3500,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
@ -546,14 +561,110 @@ async def character_sheets(request: Request):
|
||||
elif not isinstance(inv, list):
|
||||
inv = []
|
||||
src = s.get("sources") if isinstance(s.get("sources"), list) else []
|
||||
s.update({"name": name, "inventory": inv[:3],
|
||||
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
|
||||
"sources": src[:3]})
|
||||
# Clamp moral alignment score
|
||||
try:
|
||||
mas = int(s.get("moral_alignment_score") or 50)
|
||||
mas = max(0, min(100, mas))
|
||||
except (TypeError, ValueError):
|
||||
mas = 50
|
||||
arc = str(s.get("arc_direction") or "neutral").strip()
|
||||
if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"):
|
||||
arc = "neutral"
|
||||
s.update({
|
||||
"name": name, "inventory": inv[:3],
|
||||
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
|
||||
"sources": src[:5],
|
||||
"moral_alignment_score": mas,
|
||||
"arc_direction": arc,
|
||||
})
|
||||
clean.append(s)
|
||||
names.append(name)
|
||||
return {"sheets": clean, "characters": names}
|
||||
|
||||
|
||||
@router.post("/api/character-deep-analysis")
|
||||
async def character_deep_analysis(request: Request):
|
||||
"""Run a deep 5-area psychological analysis of a single character.
|
||||
|
||||
Body: {name, role, goal, summary, text_excerpt, language, llm_url, model}
|
||||
Returns: {analysis: {core_flaw, agency, dialogue_voice, narrative_arc, paradox}}
|
||||
"""
|
||||
data = await request.json()
|
||||
name: str = (data.get("name") or "").strip()
|
||||
role: str = (data.get("role") or "unknown").strip()
|
||||
goal: str = (data.get("goal") or "").strip()
|
||||
summary: str = (data.get("summary") or "").strip()
|
||||
excerpt: str = (data.get("text_excerpt") or "").strip()
|
||||
language: str = (data.get("language") or "").strip()
|
||||
_settings = _load_settings()
|
||||
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
model: str = (data.get("model") or _settings.get("llm_model") or "").strip()
|
||||
|
||||
if not name:
|
||||
raise HTTPException(400, "Character name required")
|
||||
|
||||
lang_note = f" Write the analysis in {language}." if language else ""
|
||||
|
||||
system = (
|
||||
f"You are an expert developmental editor, literary coach, and psychologist specializing in "
|
||||
f"profound character studies.{lang_note} Provide a deep, multi-layered psychological and "
|
||||
f"narrative analysis of the character in exactly this JSON structure:\n"
|
||||
'{"core_flaw":"<analysis of primary flaw, its psychological origin, and conflict with their desire>",'
|
||||
'"agency":"<are they active or reactive? where does their momentum stall? what harder choices could force growth?>",'
|
||||
'"dialogue_voice":"<speech pattern analysis: how does their dialogue reflect background, anxieties, hidden motives? how do they sound distinct?>",'
|
||||
'"narrative_arc":"<how does their worldview shift? what is the lie they believe vs the truth they must accept?>",'
|
||||
'"paradox":"<3 specific contradictions in behaviour/personality that make them feel authentic and unpredictable>"}\n'
|
||||
"Be ruthlessly comprehensive and honest. Use concrete examples from the text. "
|
||||
"Map where character development might break down or feel cliché. "
|
||||
"Respond with STRICT JSON only — no markdown, no explanation./no-think"
|
||||
)
|
||||
user = (
|
||||
f'CHARACTER: "{name}"\n'
|
||||
f"Role in story: {role}\n"
|
||||
f"Core goal: {goal or 'not specified'}\n"
|
||||
f"Background/key actions: {summary or 'not specified'}\n\n"
|
||||
+ (f"Text excerpt for analysis:\n{excerpt[:4000]}\n\n" if excerpt else "")
|
||||
+ "Provide the deep psychological analysis now."
|
||||
)
|
||||
payload: dict = {
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.6,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{llm_url}/chat/completions", json=payload,
|
||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_msg = resp.json()["choices"][0]["message"]
|
||||
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"LLM deep analysis failed: {e}")
|
||||
|
||||
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||
analysis = {}
|
||||
for cand in (content, _extract_json_block(content)):
|
||||
if not cand:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(cand)
|
||||
if isinstance(parsed, dict) and "core_flaw" in parsed:
|
||||
analysis = parsed
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not analysis:
|
||||
analysis = {"core_flaw": raw, "agency": "", "dialogue_voice": "", "narrative_arc": "", "paradox": ""}
|
||||
return {"name": name, "analysis": analysis}
|
||||
|
||||
|
||||
|
||||
@router.post("/api/attribute-dialogue")
|
||||
async def attribute_dialogue(request: Request):
|
||||
"""Split a prose passage into attributed segments for a multi-voice audiobook.
|
||||
@ -582,10 +693,10 @@ async def attribute_dialogue(request: Request):
|
||||
"You attribute dialogue in prose fiction for a multi-voice audiobook. "
|
||||
"Split the passage into consecutive segments in reading order. For each segment output:\n"
|
||||
"- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. "
|
||||
"Use 'Unknown' ONLY as an absolute last resort.\n"
|
||||
"Use the exact English word 'Unknown' ONLY as an absolute last resort.\n"
|
||||
"- type: 'narration' or 'dialogue'\n"
|
||||
"- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration\n"
|
||||
"- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration\n"
|
||||
"- emotion: for dialogue, one or two words in the SAME LANGUAGE as the text (e.g. for German: wütend, traurig, flüsternd); '' for narration\n"
|
||||
f"{lang_hint}\n"
|
||||
"QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:\n"
|
||||
" English straight \"...\" and curly “...”; German »...« (guillemets pointing inward) and „...“; "
|
||||
@ -606,7 +717,8 @@ async def attribute_dialogue(request: Request):
|
||||
"- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.\n"
|
||||
"- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts "
|
||||
"into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.\n"
|
||||
"- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order."
|
||||
"- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.\n"
|
||||
"- DO NOT hallucinate, summarize, or alter the text. The combined text of your segments MUST exactly match the original passage, word for word, except for dropped quotation marks."
|
||||
)
|
||||
else:
|
||||
if lang_hint:
|
||||
@ -614,13 +726,15 @@ async def attribute_dialogue(request: Request):
|
||||
|
||||
system = (
|
||||
f"{base_prompt}\n\n"
|
||||
"Respond with STRICT JSON only:\n"
|
||||
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}\n/no-think'
|
||||
"You MUST respond with a single, valid JSON object containing a 'segments' array.\n"
|
||||
"Do NOT output any reasoning, chain of thought, or conversational text. Output ONLY the raw JSON object:\n"
|
||||
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}'
|
||||
)
|
||||
user = (
|
||||
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
||||
+ ("Recent dialogue (the immediately preceding lines — continue the same conversation/turn-taking):\n" + recent + "\n\n" if recent else "")
|
||||
+ "Passage:\n" + text
|
||||
+ "Passage to attribute:\n" + text + "\n\n"
|
||||
+ "Return ONLY valid JSON. Do not include markdown blocks or any other text."
|
||||
)
|
||||
payload: dict = {
|
||||
"messages": [
|
||||
@ -663,56 +777,12 @@ async def attribute_dialogue(request: Request):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Fallback if the user's custom prompt forced the LLM to output a raw text script
|
||||
# instead of the requested JSON schema.
|
||||
if not segments:
|
||||
for line in content.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Match formats like "ANNA: text", "**THOMAS** (angry): text", or "1. Narrator: text"
|
||||
# Strip all markdown asterisks and leading list numbers
|
||||
line_clean = line.replace("*", "").strip()
|
||||
line_clean = re.sub(r"^[\d\.\-\s]+", "", line_clean).strip()
|
||||
|
||||
m = re.match(r"^([A-ZÄÖÜa-zäöüß0-9\s]+?)(?:\s*\([^)]+\))?\s*:\s*(.*)", line_clean)
|
||||
if m:
|
||||
sp = m.group(1).strip()
|
||||
txt = m.group(2).strip()
|
||||
|
||||
# If the LLM regurgitated the prompt as Markdown, ignore these fake "speakers"
|
||||
if len(sp) > 30 or sp.lower() in {
|
||||
"task", "output format", "segments", "language", "quotation styles",
|
||||
"speaker attribution", "constraint", "input", "output text",
|
||||
"fields per segment", "rules", "note", "context setup",
|
||||
"introductory narration", "closing narration", "the text says",
|
||||
"speaker name", "note on language", "note on metadata",
|
||||
"note on quotation marks", "segment 1", "segment 2", "segment 3",
|
||||
"segment 4", "segment 5", "text", "speaker", "emotion", "type"
|
||||
} or sp.lower().startswith("segment "):
|
||||
# Treat it as garbage/narration so it doesn't pollute the character list
|
||||
segments.append({"speaker": "Narrator", "type": "narration", "text": line_clean, "emotion": ""})
|
||||
continue
|
||||
# If the LLM completely failed to output JSON despite the strict system prompt,
|
||||
# it means it dropped into a conversational/reasoning hallucination.
|
||||
# Do NOT try to parse its reasoning as a script. Abort and preserve the book text.
|
||||
segments = [{"speaker": "Narrator", "type": "narration", "text": text, "emotion": ""}]
|
||||
|
||||
# Strip leading/trailing quotation marks that LLM might have included
|
||||
txt = re.sub(r'^["\'«»„“\*]+', '', txt)
|
||||
txt = re.sub(r'["\'«»„“\*]+$', '', txt)
|
||||
|
||||
# Extract emotion if present in parentheses before the colon
|
||||
emo = ""
|
||||
emo_m = re.search(r"\(([^)]+)\)", line_clean.split(":", 1)[0])
|
||||
if emo_m:
|
||||
emo = emo_m.group(1).strip()
|
||||
|
||||
if sp.upper() in ("NARRATOR", "ERZÄHLER"):
|
||||
segments.append({"speaker": "Narrator", "type": "narration", "text": txt, "emotion": emo})
|
||||
else:
|
||||
segments.append({"speaker": sp, "type": "dialogue", "text": txt, "emotion": emo})
|
||||
else:
|
||||
# No speaker prefix, assume it's narration (or LLM is just rambling)
|
||||
txt = re.sub(r'^["\'«»„“\*]+', '', line)
|
||||
txt = re.sub(r'["\'«»„“\*]+$', '', txt)
|
||||
segments.append({"speaker": "Narrator", "type": "narration", "text": txt, "emotion": ""})
|
||||
# Normalise + collect speaker roster
|
||||
clean, chars = [], []
|
||||
for seg in segments:
|
||||
|
||||
@ -23,6 +23,7 @@ const MAIN = [
|
||||
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
|
||||
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
||||
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
||||
'characters-library',
|
||||
].map(n => join(jsDir, n + '.js'));
|
||||
|
||||
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
||||
|
||||
@ -26,7 +26,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.8.0-11">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.9.0-1">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -157,6 +157,7 @@
|
||||
<div class="nav-tree-item" data-reader-view="cast" onclick="navReaderView('cast')"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
|
||||
<div class="nav-tree-item" data-reader-view="library" onclick="navReaderView('library')"><span class="mdi mdi-bookshelf"></span> Library</div>
|
||||
</div>
|
||||
<div class="nav-item" data-nav-section="s-characters" onclick="navTo('s-characters')"> <span class="nav-icon"><span class="mdi mdi-account-box-multiple-outline"></span></span> Characters</div>
|
||||
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
|
||||
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
|
||||
|
||||
@ -271,6 +272,7 @@
|
||||
<section class="page-section" id="s-llms" style="display:none"></section>
|
||||
<section class="page-section" id="s-rehearser" style="display:none"></section>
|
||||
<section class="page-section" id="s-reader" style="display:none"></section>
|
||||
<section class="page-section" id="s-characters" style="display:none"></section>
|
||||
<section class="page-section" id="s-conversation" style="display:none"></section>
|
||||
</main>
|
||||
|
||||
@ -306,7 +308,7 @@
|
||||
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.8.0-3"></script>
|
||||
<script src="/static/loader.js?v=1.9.0-1"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -16,7 +16,7 @@ const _audiobook = { running: false, cancel: false };
|
||||
|
||||
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
|
||||
// German »...«/„...“, French «...», single ‘...’/›...‹, CJK 「...」『...』, em-dash speech.
|
||||
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「]|(?:^|\n)\s*[—–]\s/;
|
||||
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「<]|(?:^|\n)\s*[—–]\s/;
|
||||
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
|
||||
|
||||
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
|
||||
@ -82,7 +82,7 @@ function audiobookTurnTaking(segs) {
|
||||
let a = null, b = null; // two most recent distinct named speakers (b = latest)
|
||||
for (const s of segs) {
|
||||
if (s.type !== 'dialogue') continue;
|
||||
if (s.speaker && s.speaker !== 'Unknown') {
|
||||
if (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)) {
|
||||
if (s.speaker !== b) { a = b; b = s.speaker; }
|
||||
} else if (a && b && a !== b) {
|
||||
s.speaker = a; // the other of the two → alternate
|
||||
@ -165,7 +165,7 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
|
||||
<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>
|
||||
</select>
|
||||
<span style="margin-right:8px;"></span>
|
||||
<button class="ab-castpanel-btn" id="ab-cv-prompt-btn" title="Edit Casting Prompt"><span class="mdi mdi-text-box-edit-outline"></span></button>
|
||||
<button class="ab-castpanel-btn" id="ab-cv-prompt-btn" title="Edit Casting Prompt" style="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="ab-cv-prompt-chevron"></span></button>
|
||||
</div>
|
||||
<div class="ab-castpanel-prompt" id="ab-cv-prompt-panel" style="padding:10px 12px; background:var(--panel); border-bottom:1px solid var(--border);">
|
||||
<textarea id="ab-cv-prompt-text" rows="8" style="width:100%; font-family:monospace; font-size:13px; 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;" placeholder="LLM casting instructions..."></textarea>
|
||||
@ -178,12 +178,15 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
|
||||
<button class="btn-primary btn-sm" id="ab-cv-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 ab-castpanel-bar"><div class="reader-synth-fill" id="ab-cv-fill"></div></div>
|
||||
<div class="reader-synth-track ab-castpanel-bar" style="position:relative; height:18px;">
|
||||
<div class="reader-synth-fill" id="ab-cv-fill" style="height:100%;"></div>
|
||||
<div id="ab-cv-fill-text" style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:600; color:var(--text); text-shadow: 0px 1px 2px var(--bg), 0px -1px 2px var(--bg); pointer-events:none;">0%</div>
|
||||
</div>
|
||||
<div class="ab-cv-body">
|
||||
<div class="ab-cv-feed" id="ab-cv-feed"></div>
|
||||
<div class="ab-cv-side">
|
||||
<div class="ab-cv-side-head">Characters found</div>
|
||||
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">listening…</span></div>
|
||||
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">reading…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -195,24 +198,29 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
|
||||
<button class="btn-primary btn-sm" id="ab-cv-start-cast" style="display:none;"><span class="mdi mdi-play"></span> Cast now</button>
|
||||
</div>`;
|
||||
|
||||
const AB_DEFAULT_PROMPT = `You attribute dialogue in prose fiction for a multi-voice audiobook. Split the passage into consecutive segments in reading order. For each segment output:
|
||||
- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. Use 'Unknown' ONLY as an absolute last resort.
|
||||
- type: 'narration' or 'dialogue'
|
||||
- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration
|
||||
- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration
|
||||
const AB_DEFAULT_PROMPT = `Du bist ein erfahrener Drehbuchautor und Hörbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente für Erzähler und Dialoge (wörtliche Rede) zu unterteilen.
|
||||
|
||||
QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:
|
||||
English straight "..." and curly “...”; German »...« (guillemets pointing inward) and „...“; French «...» (pointing outward); single ‘...’; CJK 「...」 『...』; and em-dash speech where a line starts with — or – (Spanish/French/Polish style).
|
||||
German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.
|
||||
ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):
|
||||
1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns (er/sie/he/she) to the actual name from nearby context.
|
||||
2. UNTAGGED lines: use **conversational turn-taking**. In a two-person exchange the speaker ALTERNATES every line — if Riskan just spoke, the next untagged quote is the other person, then back to Riskan, and so on.
|
||||
3. Use the scene context, action beats around a quote (the person doing the action usually speaks), the 'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the known-characters list. Reuse the EXACT known names.
|
||||
4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and context — this should be rare. Prefer the most likely named character over 'Unknown'.
|
||||
RULES:
|
||||
- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.
|
||||
- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.
|
||||
- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.`;
|
||||
WICHTIGE DEFINITION VON DIALOG:
|
||||
Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden.
|
||||
Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).
|
||||
|
||||
ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv):
|
||||
1. Direkte Zuordnung: Achte auf Wörter wie "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) zum tatsächlichen Namen auf.
|
||||
2. Handlungs-Hinweise (Action Beats): Wenn ein Charakter eine Handlung ausführt und direkt davor/danach wörtliche Rede steht, spricht meist dieser Charakter (z.B. "Thomas trat ans Fenster. »Es regnet.«").
|
||||
3. Das Ping-Pong-Prinzip: Wenn zwei Personen sprechen, wechseln sie sich ab. Verfolge diese Kette lückenlos zurück zur letzten eindeutigen Nennung.
|
||||
4. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt die Aussage zum Wissen oder Tonfall eines bestimmten Charakters?
|
||||
5. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens').
|
||||
6. Unbekannte Sprecher: Nur wenn eine Zuordnung durch Kontext absolut nicht möglich ist, verwende 'Unknown'. Rate nicht blind, aber bevorzuge immer einen namentlich genannten Charakter gegenüber 'Unknown'.
|
||||
|
||||
FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS:
|
||||
- speaker: 'Narrator' für Narration/Erzählertext, oder den EXAKTEN Namen des Charakters für gesprochene Dialoge.
|
||||
- type: 'narration' oder 'dialogue'
|
||||
- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen.
|
||||
- emotion: Bei Dialogen 1-2 deutsche Wörter, die den Tonfall beschreiben (z.B. wütend, flüsternd, ängstlich). Bei Narration leer lassen ('').
|
||||
|
||||
STRIKTE FORMAT- UND TEXTREGELN:
|
||||
- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt.").
|
||||
- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!`;
|
||||
|
||||
const globalPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT;
|
||||
panel.querySelector('#ab-cv-prompt-text').value = globalPrompt;
|
||||
@ -239,6 +247,10 @@ RULES:
|
||||
panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => {
|
||||
const p = panel.querySelector('#ab-cv-prompt-panel');
|
||||
p.hidden = !p.hidden;
|
||||
const chevron = panel.querySelector('#ab-cv-prompt-chevron');
|
||||
if (chevron) {
|
||||
chevron.className = p.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up';
|
||||
}
|
||||
});
|
||||
|
||||
// Prompt Library logic
|
||||
@ -386,11 +398,27 @@ RULES:
|
||||
if (!roster.has(name)) roster.set(name, { count: 0, color: _AB_PALETTE[roster.size % _AB_PALETTE.length] });
|
||||
return roster.get(name).color;
|
||||
};
|
||||
const highlightText = (text) => {
|
||||
if (!text) return '';
|
||||
let html = escHtml(text);
|
||||
const names = [...roster.keys()]
|
||||
.filter(n => n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const name of names) {
|
||||
if (name.length < 2) continue;
|
||||
const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`\\b(${safeName})\\b`, 'gi');
|
||||
html = html.replace(regex, (match) => {
|
||||
return `<span style="border-bottom: 2px solid ${colorFor(name)}; font-weight: 600;">${match}</span>`;
|
||||
});
|
||||
}
|
||||
return html;
|
||||
};
|
||||
const renderRoster = () => {
|
||||
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||
chars.innerHTML = items.length
|
||||
? items.map(([n, info]) => `<span class="ab-chip" style="--c:${info.color}"><span class="ab-chip-dot"></span>${escHtml(n)}<b>${info.count}</b></span>`).join('')
|
||||
: '<span class="ab-cv-empty">listening…</span>';
|
||||
: '<span class="ab-cv-empty">reading…</span>';
|
||||
};
|
||||
const MAXROWS = 80;
|
||||
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
|
||||
@ -411,7 +439,7 @@ RULES:
|
||||
assignPopup.appendChild(header);
|
||||
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text'; inp.placeholder = 'Type new character...';
|
||||
inp.type = 'text'; inp.placeholder = 'Search or add character...';
|
||||
inp.style.cssText = 'width:100%; padding:6px; font-size:13px; border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text);';
|
||||
assignPopup.appendChild(inp);
|
||||
const list = document.createElement('div');
|
||||
@ -434,12 +462,47 @@ RULES:
|
||||
});
|
||||
inp.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = inp.value.trim();
|
||||
if (val) assignName(val);
|
||||
const addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn');
|
||||
const visibleBtns = Array.from(assignPopup.querySelectorAll('.ab-cv-popup-list > div[data-name]')).filter(b => b.style.display !== 'none');
|
||||
if (addBtn && addBtn.style.display !== 'none') {
|
||||
addBtn.click();
|
||||
} else if (visibleBtns.length === 1) {
|
||||
visibleBtns[0].click();
|
||||
} else {
|
||||
const val = inp.value.trim();
|
||||
if (val) assignName(val);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
closeAssignPopup();
|
||||
}
|
||||
});
|
||||
inp.addEventListener('input', () => {
|
||||
const val = inp.value.trim().toLowerCase();
|
||||
const listItems = assignPopup.querySelectorAll('.ab-cv-popup-list > div[data-name]');
|
||||
let exactMatch = false;
|
||||
listItems.forEach(item => {
|
||||
const name = item.dataset.name.toLowerCase();
|
||||
if (name === val) exactMatch = true;
|
||||
if (name.includes(val)) item.style.display = 'flex';
|
||||
else item.style.display = 'none';
|
||||
});
|
||||
let addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn');
|
||||
if (val && !exactMatch && val !== 'narrator') {
|
||||
if (!addBtn) {
|
||||
addBtn = document.createElement('div');
|
||||
addBtn.className = 'ab-cv-popup-add-btn';
|
||||
addBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--primary); border-top:1px solid var(--border); margin-top:4px; padding-top:8px;';
|
||||
addBtn.onmouseover = () => addBtn.style.background = 'var(--panel)';
|
||||
addBtn.onmouseout = () => addBtn.style.background = 'transparent';
|
||||
}
|
||||
addBtn.innerHTML = `<span style="font-size:14px; font-weight:bold;">+</span> Add "${escHtml(inp.value.trim())}"`;
|
||||
addBtn.onclick = () => assignName(inp.value.trim());
|
||||
addBtn.style.display = 'flex';
|
||||
assignPopup.querySelector('.ab-cv-popup-list').appendChild(addBtn); // keep at bottom
|
||||
} else if (addBtn) {
|
||||
addBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeAssignPopup() {
|
||||
@ -457,20 +520,15 @@ RULES:
|
||||
if (isNarrator) assignModeSeg.emotion = '';
|
||||
assignModeRow.classList.remove('is-assigning');
|
||||
|
||||
if (isNarrator) {
|
||||
assignModeRow.className = 'ab-cv-row is-narr';
|
||||
assignModeRow.querySelector('.ab-cv-spk').innerHTML = 'Narrator';
|
||||
assignModeRow.querySelector('.ab-cv-spk').style.color = '';
|
||||
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
|
||||
} else {
|
||||
const c = colorFor(name);
|
||||
if (!roster.has(name)) roster.set(name, { count: 0, color: c });
|
||||
roster.get(name).count++;
|
||||
assignModeRow.className = 'ab-cv-row';
|
||||
assignModeRow.querySelector('.ab-cv-spk').innerHTML = `${escHtml(name)}${assignModeSeg.emotion ? ' · ' + escHtml(assignModeSeg.emotion) : ''}`;
|
||||
assignModeRow.querySelector('.ab-cv-spk').style.color = c;
|
||||
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
|
||||
}
|
||||
const speakerName = isNarrator ? 'Narrator' : name;
|
||||
const c = colorFor(speakerName);
|
||||
if (!roster.has(speakerName)) roster.set(speakerName, { count: 0, color: c });
|
||||
roster.get(speakerName).count++;
|
||||
|
||||
assignModeRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||||
assignModeRow.querySelector('.ab-cv-spk').innerHTML = `${escHtml(speakerName)}${assignModeSeg.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(assignModeSeg.emotion) + ')</span>' : ''}`;
|
||||
assignModeRow.querySelector('.ab-cv-spk').style.color = c;
|
||||
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
|
||||
renderRoster();
|
||||
toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success');
|
||||
closeAssignPopup();
|
||||
@ -499,6 +557,7 @@ RULES:
|
||||
|
||||
// Always show Narrator as the first option
|
||||
const narrBtn = document.createElement('div');
|
||||
narrBtn.dataset.name = 'Narrator';
|
||||
narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;';
|
||||
narrBtn.innerHTML = `<span style="font-size:13px;">📖</span> Narrator`;
|
||||
narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)';
|
||||
@ -509,6 +568,7 @@ RULES:
|
||||
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||
for (const [n, info] of items) {
|
||||
const btn = document.createElement('div');
|
||||
btn.dataset.name = n;
|
||||
btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);';
|
||||
btn.innerHTML = `<span style="width:8px;height:8px;border-radius:50%;background:${info.color};"></span> ${escHtml(n)}`;
|
||||
btn.onmouseover = () => btn.style.background = 'var(--panel)';
|
||||
@ -578,39 +638,34 @@ RULES:
|
||||
const before = fullText.substring(0, idx).trim();
|
||||
const after = fullText.substring(idx + text.length).trim();
|
||||
|
||||
// Try committed segments first, then live array (during active casting)
|
||||
let arr = _audiobook.segments || [];
|
||||
// if it's currently casting, they are in allSegments (which we can't easily reference), but we can just update the array when it finishes.
|
||||
// To be safe, we just modify the object and insert new objects.
|
||||
const globalIdx = arr.indexOf(seg);
|
||||
let globalIdx = arr.indexOf(seg);
|
||||
if (globalIdx === -1 && Array.isArray(_audiobook.liveSegments)) {
|
||||
arr = _audiobook.liveSegments;
|
||||
globalIdx = arr.indexOf(seg);
|
||||
}
|
||||
const newSegs = [];
|
||||
if (before) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before });
|
||||
newSegs.push({ speaker: 'Unknown', type: 'dialogue', emotion: '', text: text });
|
||||
if (after) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: after });
|
||||
|
||||
if (globalIdx !== -1) arr.splice(globalIdx, 1, ...newSegs);
|
||||
else {
|
||||
// It's casting right now. We can just replace the row visually, and at the end of casting it uses the segments array.
|
||||
// Wait, allSegments holds the real ones. If we can't find it in _audiobook.segments, we're in trouble.
|
||||
// Just flag the row. Actually, we should just prevent splitting during active casting.
|
||||
if (panel.querySelector('#ab-cv-status-msg').textContent !== 'Ready to recast.') {
|
||||
toast('Wait for casting to finish or stop it before splitting', 'error');
|
||||
splitBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalIdx !== -1) {
|
||||
arr.splice(globalIdx, 1, ...newSegs);
|
||||
} else {
|
||||
// DOM-only split — will be reconciled once casting finishes
|
||||
console.warn('[audiobook] split during casting: DOM-only, segment not yet in array');
|
||||
}
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const s of newSegs) {
|
||||
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
|
||||
const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator';
|
||||
const speakerName = isNarrator ? 'Narrator' : s.speaker;
|
||||
const c = colorFor(speakerName);
|
||||
const newRow = document.createElement('div');
|
||||
newRow.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
|
||||
newRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||||
newRow.__seg = s;
|
||||
if (dialog) {
|
||||
const c = colorFor(s.speaker);
|
||||
newRow.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
|
||||
} else {
|
||||
newRow.innerHTML = `<span class="ab-cv-spk" title="Click to assign character">Narrator</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
|
||||
}
|
||||
newRow.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||||
frag.appendChild(newRow);
|
||||
}
|
||||
row.parentNode.insertBefore(frag, row);
|
||||
@ -664,7 +719,13 @@ RULES:
|
||||
});
|
||||
|
||||
return {
|
||||
update(done) { if (fill) fill.style.width = (done / total * 100) + '%'; if (count) count.textContent = `passage ${done} / ${total}`; },
|
||||
update(done) {
|
||||
const pct = Math.round((done / total) * 100);
|
||||
if (fill) fill.style.width = pct + '%';
|
||||
if (count) count.textContent = `passage ${done} / ${total}`;
|
||||
const fillText = panel.querySelector('#ab-cv-fill-text');
|
||||
if (fillText) fillText.textContent = `${pct}% (Passage ${done} of ${total})`;
|
||||
},
|
||||
processing(text) {
|
||||
if (this._procRow) this._procRow.remove();
|
||||
this._procRow = document.createElement('div');
|
||||
@ -680,32 +741,86 @@ RULES:
|
||||
this.clearProcessing();
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const s of segs) {
|
||||
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
|
||||
const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator';
|
||||
const speakerName = isNarrator ? 'Narrator' : s.speaker;
|
||||
const c = colorFor(speakerName);
|
||||
roster.get(speakerName).count++;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
|
||||
row.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||||
row.__seg = s;
|
||||
if (dialog) { const c = colorFor(s.speaker); roster.get(s.speaker).count++;
|
||||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
|
||||
} else {
|
||||
row.innerHTML = `<span class="ab-cv-spk" title="Click to assign character">Narrator</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
|
||||
}
|
||||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||||
frag.appendChild(row);
|
||||
}
|
||||
feed.appendChild(frag); trim(); renderRoster();
|
||||
},
|
||||
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
|
||||
// Visual gap (three dots) marking that the passages before/after are not
|
||||
// contiguous — used by Recast unknown, which only shows scattered segments.
|
||||
divider() {
|
||||
this.clearProcessing();
|
||||
const r = document.createElement('div');
|
||||
r.className = 'ab-cv-divider';
|
||||
r.textContent = '⋯';
|
||||
r.title = 'There is more text before and after this passage';
|
||||
feed.appendChild(r); trim();
|
||||
},
|
||||
// Park the panel in a "done" state with a Review button instead of auto-popping
|
||||
// the preview — so it waits for you if you wandered off to do something else.
|
||||
complete(summary, onOpen, onRecast, onRecastUnknown) {
|
||||
if (count) count.textContent = 'done';
|
||||
if (fill) fill.style.width = '100%';
|
||||
if (typeof allSegments !== 'undefined' && allSegments.length) {
|
||||
feed.innerHTML = '';
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const s of allSegments) {
|
||||
const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator';
|
||||
const speakerName = isNarrator ? 'Narrator' : s.speaker;
|
||||
const c = colorFor(speakerName);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||||
row.__seg = s;
|
||||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||||
frag.appendChild(row);
|
||||
}
|
||||
feed.appendChild(frag);
|
||||
}
|
||||
const fillText = panel.querySelector('#ab-cv-fill-text');
|
||||
if (fillText) fillText.textContent = '100% - Ready for review';
|
||||
const cancelBtn = panel.querySelector('#ab-cv-cancel');
|
||||
if (cancelBtn) cancelBtn.hidden = true;
|
||||
const foot = panel.querySelector('#ab-cv-foot');
|
||||
foot.hidden = false;
|
||||
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-save-script" style="margin-right:8px;" title="Save to Script Rehearsals without leaving this page"><span class="mdi mdi-content-save-outline"></span> Save script</button><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-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review & cast</button>`;
|
||||
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-save-script" style="margin-right:8px;" title="Save to Script Rehearsals without leaving this page"><span class="mdi mdi-content-save-outline"></span> Save script</button><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-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review & cast</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-left:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality increase run — re-checks every speaker assignment against context and resolves all Unknowns"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run — Verify</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 und jedem Segment einen Sprecher zugewiesen. Deine Aufgabe ist es JEDEN Sprecher-Zuweisung kritisch zu überprüfen und zu korrigieren.
|
||||
|
||||
AUFGABE (2. Qualitätslauf — Verifizierung):
|
||||
1. ÜBERPRÜFE jede Sprecher-Zuweisung: Ist der angegebene Sprecher wirklich derjenige, der spricht? Nutze den Kontext (Inquit-Formeln wie "sagte X", "fragte sie", "rief er", Handlungsbeschreibungen, das Ping-Pong-Prinzip zwischen Sprechern, und den Gesamtkontext der Szene).
|
||||
2. KORRIGIERE falsch zugewiesene Sprecher mit dem korrekten Namen direkt aus dem Text.
|
||||
3. LÖSE alle 'Unknown'-Segmente auf: Nutze umgebenden Kontext, die Reihenfolge der Sprecher und textliche Hinweise. 'Unknown' ist NUR dann erlaubt, wenn der Sprecher absolut nicht bestimmbar ist.
|
||||
4. BEHALTE alle korrekten Zuweisungen exakt unverändert bei.
|
||||
|
||||
DEFINITION VON DIALOG (KRITISCH):
|
||||
Text ist NUR 'dialogue', wenn er in Anführungszeichen steht (»...«, „...", "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE ist 'narration' (speaker: 'Narrator').
|
||||
|
||||
FÜR JEDES SEGMENT AUSGABE:
|
||||
- speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters.
|
||||
- type: 'narration' oder 'dialogue'
|
||||
- text: EXAKT der WORTWÖRTLICHE Originaltext — KEINE Änderungen, KEINE Auslassungen, KEINE Ergänzungen.
|
||||
- emotion: Bei Dialogen 1-2 deutsche Wörter für den Tonfall. Bei Narration leer ('').
|
||||
|
||||
ABSOLUTE REGELN:
|
||||
- Alle Segmente zusammen MÜSSEN den Originaltext exakt, lückenlos und wortgetreu rekonstruieren.
|
||||
- Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu.
|
||||
- Mische NIEMALS Narration und Dialog in einem Segment.`;
|
||||
panel.querySelector('#ab-cv-prompt-text').value = verificationPrompt;
|
||||
foot.querySelector('#ab-cv-recast-unk').click();
|
||||
};
|
||||
|
||||
foot.querySelector('#ab-cv-review').addEventListener('click', () => { closePanel(); onOpen(); });
|
||||
foot.querySelector('#ab-cv-save-script').addEventListener('click', audiobookSaveAsRehearsal);
|
||||
foot.querySelector('#ab-cv-verify').addEventListener('click', runVerificationPass);
|
||||
|
||||
const applyPromptAndRun = (callback) => {
|
||||
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
|
||||
@ -735,7 +850,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
|
||||
|
||||
const unknownIdxs = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
if (segs[i].type === 'dialogue' && (!segs[i].speaker || segs[i].speaker === 'Unknown')) {
|
||||
if (segs[i].type === 'dialogue' && (!segs[i].speaker || /^Unknown|Unbekannt/i.test(segs[i].speaker))) {
|
||||
unknownIdxs.push(i);
|
||||
}
|
||||
}
|
||||
@ -769,11 +884,17 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
|
||||
}
|
||||
|
||||
let done = 0;
|
||||
let prevIdx = -2;
|
||||
try {
|
||||
for (let i of unknownIdxs) {
|
||||
if (_audiobook.cancel) break;
|
||||
const targetSeg = segs[i];
|
||||
|
||||
|
||||
// These Unknown lines come from scattered places in the document. Mark a
|
||||
// gap with "⋯" whenever this passage isn't directly after the previous one.
|
||||
if (i !== prevIdx + 1) view.divider();
|
||||
prevIdx = i;
|
||||
|
||||
const start = Math.max(0, i - 12);
|
||||
const end = Math.min(segs.length, i + 6);
|
||||
const contextSegs = segs.slice(start, end);
|
||||
@ -809,7 +930,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
|
||||
}
|
||||
if (data && data.segments) {
|
||||
const match = data.segments.find(s => s.type === 'dialogue' && targetSeg.text.includes(s.text.slice(0, 15)));
|
||||
if (match && match.speaker && match.speaker !== 'Unknown') {
|
||||
if (match && match.speaker && !/^Unknown|Unbekannt/i.test(match.speaker)) {
|
||||
targetSeg.speaker = match.speaker;
|
||||
if (match.emotion) targetSeg.emotion = match.emotion;
|
||||
if (!_audiobook.roster.includes(match.speaker)) _audiobook.roster.push(match.speaker);
|
||||
@ -893,6 +1014,7 @@ async function audiobookCast(overrideUrl, overrideModel) {
|
||||
}
|
||||
|
||||
const allSegments = [];
|
||||
_audiobook.liveSegments = allSegments; // expose so split works during casting
|
||||
const roster = [];
|
||||
let narrationOnly = 0; // passages with no quotes at all — legitimately all narration
|
||||
let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
|
||||
@ -907,37 +1029,129 @@ async function audiobookCast(overrideUrl, overrideModel) {
|
||||
continue;
|
||||
}
|
||||
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
|
||||
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown')
|
||||
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker))
|
||||
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
|
||||
|
||||
view.processing(chunks[i]);
|
||||
// ── Helper: run one attribution call and return parsed segments (or null on error) ──
|
||||
const attributeChunk = async (chunkText, recentCtx) => {
|
||||
try {
|
||||
const r = await fetch('/api/attribute-dialogue', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
signal: ac.signal,
|
||||
body: JSON.stringify({ text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model }),
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); }
|
||||
const d = await r.json();
|
||||
return Array.isArray(d.segments) ? d.segments : null;
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') throw err; // propagate cancel
|
||||
return { error: err.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ── First attempt ──
|
||||
let result = await attributeChunk(chunks[i], recent);
|
||||
let data = null;
|
||||
try {
|
||||
const r = await fetch('/api/attribute-dialogue', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
signal: ac.signal,
|
||||
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), recent, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model }),
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); }
|
||||
data = await r.json();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') break;
|
||||
view.note(`API Error: ${err.message}`);
|
||||
data = null;
|
||||
|
||||
if (result && !result.error) {
|
||||
data = result;
|
||||
} else if (result && result.error) {
|
||||
// ── Retry: split the failing chunk in half and run both halves ──
|
||||
view.note(`⚠️ Passage ${i + 1} timed out — retrying in two halves…`);
|
||||
const half = Math.floor(chunks[i].length / 2);
|
||||
const splitAt = chunks[i].lastIndexOf(' ', half) || half;
|
||||
const chunkA = chunks[i].slice(0, splitAt).trim();
|
||||
const chunkB = chunks[i].slice(splitAt).trim();
|
||||
const resA = await attributeChunk(chunkA, recent);
|
||||
const resB = await attributeChunk(chunkB, recent);
|
||||
|
||||
const segsA = (resA && !resA.error) ? resA : null;
|
||||
const segsB = (resB && !resB.error) ? resB : null;
|
||||
|
||||
if (segsA || segsB) {
|
||||
data = [...(segsA || audiobookSplitByQuotes(chunkA)), ...(segsB || audiobookSplitByQuotes(chunkB))];
|
||||
if (!segsA) { degraded++; view.note(`⚠️ Passage ${i + 1} (first half) — auto-detected (retry also failed)`); }
|
||||
if (!segsB) { degraded++; view.note(`⚠️ Passage ${i + 1} (second half) — auto-detected (retry also failed)`); }
|
||||
} else {
|
||||
// Both halves also failed
|
||||
view.note(`❌ Passage ${i + 1} — LLM error: ${result.error}`);
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
let segs = data && Array.isArray(data.segments) ? data.segments : [];
|
||||
if (!segs.length) {
|
||||
// LLM unavailable or returned nothing, but this passage HAS quotes —
|
||||
// extract dialogue + attribute speakers from speech tags so it isn't lost.
|
||||
let segs = Array.isArray(data) ? data : (data && Array.isArray(data.segments) ? data.segments : []);
|
||||
const hasDialogue = segs.some(s => s.type === 'dialogue');
|
||||
if (!segs.length || (!hasDialogue && audiobookHasDialogue(chunks[i]))) {
|
||||
// LLM completely unavailable — extract dialogue from quotes without speaker attribution
|
||||
segs = audiobookSplitByQuotes(chunks[i]);
|
||||
degraded++;
|
||||
const named = segs.filter(s => s.type === 'dialogue' && s.speaker !== 'Unknown').length;
|
||||
const named = segs.filter(s => s.type === 'dialogue' && !/^Unknown|Unbekannt/i.test(s.speaker)).length;
|
||||
view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`);
|
||||
} else {
|
||||
// Clean up LLM hallucinations where it outputs the same text twice or leaves quotes in narration
|
||||
let cleanedSegs = [];
|
||||
for (let s of segs) {
|
||||
s.text = s.text || '';
|
||||
|
||||
// Strict validation: if LLM claims it's dialogue, but the original text has NO quotes around it, it's a hallucination.
|
||||
if (s.type === 'dialogue' && s.text.trim().length > 10) {
|
||||
const tText = s.text.trim();
|
||||
// Try to find the text in the original chunk to check its surroundings
|
||||
const idx = chunks[i].indexOf(tText);
|
||||
if (idx !== -1) {
|
||||
const surround = chunks[i].slice(Math.max(0, idx - 8), idx) + chunks[i].slice(idx + tText.length, idx + tText.length + 8);
|
||||
if (!/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)) {
|
||||
s.type = 'narration';
|
||||
s.speaker = 'Narrator';
|
||||
s.emotion = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedSegs.length > 0) {
|
||||
let last = cleanedSegs[cleanedSegs.length - 1];
|
||||
if (s.text.trim() === last.text.trim()) {
|
||||
if (s.type === 'dialogue' && last.type !== 'dialogue') {
|
||||
cleanedSegs[cleanedSegs.length - 1] = s; continue;
|
||||
} else if (s.type !== 'dialogue' && last.type === 'dialogue') {
|
||||
continue;
|
||||
} else { continue; }
|
||||
}
|
||||
if (s.type === 'dialogue' && last.type === 'narration') {
|
||||
const tS = s.text.trim(), tL = last.text.trim();
|
||||
if (tL.endsWith(tS)) {
|
||||
last.text = tL.slice(0, -tS.length).trim();
|
||||
if (!last.text) cleanedSegs.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (s.text.trim()) cleanedSegs.push(s);
|
||||
}
|
||||
|
||||
// Merge consecutive narration segments to preserve paragraph flow
|
||||
let mergedSegs = [];
|
||||
for (let s of cleanedSegs) {
|
||||
if (mergedSegs.length > 0) {
|
||||
let last = mergedSegs[mergedSegs.length - 1];
|
||||
if (s.type === 'narration' && last.type === 'narration' && (s.speaker || 'Narrator').toLowerCase() === 'narrator' && (last.speaker || 'Narrator').toLowerCase() === 'narrator') {
|
||||
// Only add a newline if they don't already flow perfectly (e.g. LLM split mid-sentence)
|
||||
// But usually we just join with double newline to preserve paragraphs, or single space if it's mid-sentence.
|
||||
// A safe heuristic: if it ends with punctuation, use double newline (paragraph break).
|
||||
if (/[.!?]$/.test(last.text.trim())) {
|
||||
last.text = last.text.trimEnd() + '\n\n' + s.text.trimStart();
|
||||
} else {
|
||||
last.text = last.text.trimEnd() + ' ' + s.text.trimStart();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
mergedSegs.push(s);
|
||||
}
|
||||
segs = mergedSegs;
|
||||
}
|
||||
// harvest speaker names (from LLM or tag heuristic) into the running roster
|
||||
(data && data.characters || []).forEach(n => { if (n && n !== 'Unknown' && !roster.includes(n)) roster.push(n); });
|
||||
segs.forEach(s => { if (s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown' && !roster.includes(s.speaker)) roster.push(s.speaker); });
|
||||
segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); });
|
||||
segs.forEach(s => allSegments.push(s));
|
||||
view.addSegments(segs);
|
||||
}
|
||||
@ -973,7 +1187,7 @@ function audiobookShowPreview() {
|
||||
ov.className = 'audiobook-overlay';
|
||||
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
|
||||
const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])];
|
||||
const charCount = speakerSet.filter(n => n !== 'Narrator' && n !== 'Unknown').length;
|
||||
const charCount = speakerSet.filter(n => !/^Unknown|Unbekannt/i.test(n)).length;
|
||||
const opts = speakerSet.map(n => `<option value="${escHtml(n)}">`).join('');
|
||||
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
|
||||
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Review cast & lines
|
||||
@ -990,7 +1204,7 @@ function audiobookShowPreview() {
|
||||
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
|
||||
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
|
||||
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
|
||||
<div class="audiobook-seg-text">${escHtml(s.text)}</div>
|
||||
<div class="audiobook-seg-text">${highlightText(s.text || '')}</div>
|
||||
</div>`).join('');
|
||||
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
|
||||
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
|
||||
|
||||
@ -2,11 +2,7 @@
|
||||
//
|
||||
// Actor-facing, RPG-style character sheets extracted by the user's LLM from a
|
||||
// document (Read Aloud) or a script (Script Rehearser). Each claim cites a
|
||||
// source (page + short quote). Shared by both sections via one overlay.
|
||||
//
|
||||
// Reuses: /api/character-sheets (LLM), readerState/readerScopeIndices (reader.js),
|
||||
// rehState/stripMarkdown/rehDefaultLlmUrl (rehearser.js), splitTextIntoChunks
|
||||
// (generation.js), $ / escHtml / toast (utils.js).
|
||||
// source (page + short quote + category hint). Shared by both sections via one overlay.
|
||||
|
||||
const CS_CHUNK_CHARS = 4000;
|
||||
const _cs = { running: false, cancel: false, cache: {} };
|
||||
@ -44,29 +40,41 @@ function csRehearserText() {
|
||||
|
||||
// ── Generation (chunked + merged by character) ───────────────────────────────
|
||||
|
||||
// Compact summary of the sheets built so far → tells the LLM what's known and
|
||||
// which fields each character still needs, so it fills gaps instead of restarting.
|
||||
const CS_SCALAR_FIELDS = ['aliases', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
|
||||
'attribute_high', 'attribute_low', 'skills', 'capabilities',
|
||||
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
|
||||
'secret', 'conflict_style', 'win_condition'];
|
||||
|
||||
function csExistingSummary(map) {
|
||||
if (!map.size) return '';
|
||||
const FIELDS = ['archetype', 'physical', 'alignment', 'attribute_high', 'attribute_low', 'skills', 'secret', 'conflict_style', 'win_condition'];
|
||||
return [...map.values()].slice(0, 30).map(s => {
|
||||
const missing = FIELDS.filter(f => !(s[f] || '').trim());
|
||||
const missing = CS_SCALAR_FIELDS.filter(f => !(s[f] || '').trim());
|
||||
return `- ${s.name}${s.aliases ? ` (${s.aliases})` : ''}${s.archetype ? ` — ${s.archetype}` : ''}`
|
||||
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function csMerge(map, sheets) {
|
||||
const SCALARS = ['aliases', 'archetype', 'physical', 'alignment', 'attribute_high', 'attribute_low', 'skills', 'secret', 'conflict_style', 'win_condition'];
|
||||
for (const s of sheets) {
|
||||
const name = (s.name || '').trim(); if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (!map.has(key)) { map.set(key, { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] }); continue; }
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] });
|
||||
continue;
|
||||
}
|
||||
const e = map.get(key);
|
||||
SCALARS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
|
||||
CS_SCALAR_FIELDS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
|
||||
if (s.tier === 'main') e.tier = 'main';
|
||||
if (s.moral_alignment_score != null) {
|
||||
e.moral_alignment_score = e.moral_alignment_score != null
|
||||
? Math.round((e.moral_alignment_score + s.moral_alignment_score) / 2)
|
||||
: s.moral_alignment_score;
|
||||
}
|
||||
if (s.arc_direction && s.arc_direction !== 'neutral') e.arc_direction = s.arc_direction;
|
||||
(s.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
|
||||
(s.sources || []).forEach(src => { if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); });
|
||||
(s.sources || []).forEach(src => {
|
||||
if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +133,95 @@ function csProgress(total) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||
// ── Alignment bar ─────────────────────────────────────────────────────────────
|
||||
|
||||
function csAlignmentBar(score, arcDirection, arcNote) {
|
||||
const pct = Math.max(0, Math.min(100, score ?? 50));
|
||||
const arcMap = {
|
||||
'stable-good': { arrow: '→', label: 'Stable Good', color: '#c8e6c9' },
|
||||
'stable-bad': { arrow: '→', label: 'Stable Evil', color: '#777' },
|
||||
'neutral': { arrow: '→', label: 'Neutral', color: '#aaa' },
|
||||
'good-to-bad': { arrow: '↘', label: 'Descends', color: '#ff7043' },
|
||||
'bad-to-good': { arrow: '↗', label: 'Redeems', color: '#66bb6a' },
|
||||
'complex': { arrow: '↕', label: 'Complex arc', color: '#ab47bc' },
|
||||
};
|
||||
const arc = arcMap[arcDirection] || arcMap['neutral'];
|
||||
const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Neutral/Ambiguous';
|
||||
return `<div class="cs-alignment-wrap" title="${escHtml(arcNote || '')}">
|
||||
<div class="cs-alignment-labels"><span>◼ Evil</span><span>Good ◻</span></div>
|
||||
<div class="cs-alignment-bar">
|
||||
<div class="cs-alignment-track"></div>
|
||||
<div class="cs-alignment-marker" style="left:${pct}%" title="${pct}/100 — ${label}"></div>
|
||||
</div>
|
||||
<div class="cs-arc-row">
|
||||
<span class="cs-arc-arrow" style="color:${arc.color}" title="${escHtml(arc.label)}">${arc.arrow}</span>
|
||||
<span class="cs-arc-label" style="color:${arc.color}">${escHtml(arc.label)}</span>
|
||||
${arcNote ? `<span class="cs-arc-note">${escHtml(arcNote)}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Deep analysis modal ───────────────────────────────────────────────────────
|
||||
|
||||
async function csDeepAnalysis(sheet, sourceText) {
|
||||
const llm_url = csLlmUrl(), model = csLlmModel(), language = csLang();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'audiobook-overlay';
|
||||
modal.innerHTML = `<div class="audiobook-box cs-deep-box">
|
||||
<div class="cs-titlebar">
|
||||
<span class="audiobook-title"><span class="mdi mdi-brain"></span> Deep Analysis — ${escHtml(sheet.name)}</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-secondary btn-sm" id="cs-deep-close">Close</button>
|
||||
</div>
|
||||
<div class="cs-deep-body" id="cs-deep-body">
|
||||
<div style="text-align:center;padding:40px;color:var(--subtext)">
|
||||
<span class="mdi mdi-loading mdi-spin" style="font-size:28px"></span><br>Running deep psychological analysis…
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector('#cs-deep-close').addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
|
||||
|
||||
try {
|
||||
const summary = [sheet.archetype, sheet.alignment, sheet.secret, sheet.win_condition, sheet.conflict_style]
|
||||
.filter(Boolean).join('; ');
|
||||
const r = await fetch('/api/character-deep-analysis', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: sheet.name, role: sheet.tier, goal: sheet.win_condition,
|
||||
summary, text_excerpt: (sourceText || '').slice(0, 8000), language, llm_url, model,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
||||
const data = await r.json();
|
||||
const a = data.analysis || {};
|
||||
const section = (icon, title, content) => content
|
||||
? `<div class="cs-deep-section">
|
||||
<div class="cs-deep-section-head"><span class="mdi ${icon}"></span> ${title}</div>
|
||||
<div class="cs-deep-section-body">${escHtml(String(content))}</div>
|
||||
</div>` : '';
|
||||
modal.querySelector('#cs-deep-body').innerHTML = `
|
||||
<div class="cs-deep-header">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<span class="cs-name" style="font-size:20px">${escHtml(sheet.name)}</span>
|
||||
${sheet.archetype ? `<span class="cs-archetype">${escHtml(sheet.archetype)}</span>` : ''}
|
||||
</div>
|
||||
${csAlignmentBar(sheet.moral_alignment_score, sheet.arc_direction, sheet.arc_note)}
|
||||
</div>
|
||||
${section('mdi-heart-broken-outline', '1 — Core Flaw & Desire', a.core_flaw)}
|
||||
${section('mdi-run-fast', '2 — Agency & Passivity', a.agency)}
|
||||
${section('mdi-comment-quote-outline', '3 — Dialogue & Voice', a.dialogue_voice)}
|
||||
${section('mdi-timeline-outline', '4 — Narrative Arc', a.narrative_arc)}
|
||||
${section('mdi-infinity', '5 — Paradox & Depth', a.paradox)}
|
||||
`;
|
||||
} catch (err) {
|
||||
modal.querySelector('#cs-deep-body').innerHTML =
|
||||
`<div style="color:var(--error);padding:20px">Error: ${escHtml(String(err.message))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card renderer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function csField(label, value) {
|
||||
if (!value) return '';
|
||||
@ -134,25 +230,50 @@ function csField(label, value) {
|
||||
|
||||
function csCardHtml(s) {
|
||||
const inv = (s.inventory || []).filter(Boolean);
|
||||
const invHtml = inv.length ? `<div class="cs-row"><dt>Signature Inventory</dt><dd><ul>${inv.map(i => `<li>${escHtml(i)}</li>`).join('')}</ul></dd></div>` : '';
|
||||
const invHtml = inv.length
|
||||
? `<div class="cs-row"><dt>Signature Items</dt><dd><ul>${inv.map(i => `<li>${escHtml(i)}</li>`).join('')}</ul></dd></div>`
|
||||
: '';
|
||||
const attrs = (s.attribute_high || s.attribute_low)
|
||||
? `<div class="cs-row"><dt>Core Attributes</dt><dd>▲ ${escHtml(s.attribute_high || '—')} · ▼ ${escHtml(s.attribute_low || '—')}</dd></div>` : '';
|
||||
? `<div class="cs-row"><dt>Core Attributes</dt><dd>▲ ${escHtml(s.attribute_high || '—')} · ▼ ${escHtml(s.attribute_low || '—')}</dd></div>`
|
||||
: '';
|
||||
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
|
||||
const srcHtml = sources.length
|
||||
? `<div class="cs-sources"><span class="mdi mdi-book-open-page-variant-outline"></span> ${sources.map(x => `${x.page != null ? '<b>p.' + escHtml(String(x.page)) + '</b> ' : ''}${x.quote ? '“' + escHtml(x.quote) + '”' : ''}`).join(' · ')}</div>` : '';
|
||||
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}">
|
||||
? `<div class="cs-sources"><span class="mdi mdi-book-open-page-variant-outline"></span> ${
|
||||
sources.map(x =>
|
||||
`${x.line_hint ? `<span class="cs-src-hint">[${escHtml(x.line_hint)}]</span> ` : ''}` +
|
||||
`${x.page != null ? `<b>p.${escHtml(String(x.page))}</b> ` : ''}` +
|
||||
`${x.quote ? `"${escHtml(x.quote)}"` : ''}`
|
||||
).join(' · ')
|
||||
}</div>`
|
||||
: '';
|
||||
|
||||
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}">
|
||||
<div class="cs-head">
|
||||
<span class="cs-name">${escHtml(s.name)}</span>
|
||||
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
|
||||
<span class="cs-tier">${s.tier === 'main' ? 'Main' : 'Supporting'}</span>
|
||||
<button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}"
|
||||
title="5-area psychological deep analysis"
|
||||
style="margin-left:auto;font-size:11px;padding:2px 8px;">
|
||||
<span class="mdi mdi-brain"></span> Deep Analysis
|
||||
</button>
|
||||
</div>
|
||||
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
|
||||
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
|
||||
<dl class="cs-fields">
|
||||
${csField('Physical', s.physical)}
|
||||
${csField('Alignment & Ethos', s.alignment)}
|
||||
${csField('Clothing & Appearance', s.clothing)}
|
||||
${csField('Alignment & Ethos', s.alignment)}
|
||||
${attrs}
|
||||
${csField('Trained Skills', s.skills)}
|
||||
${csField('Capabilities', s.capabilities)}
|
||||
${invHtml}
|
||||
${csField('Backstory & Origin', s.backstory)}
|
||||
${csField('Relationships', s.relationships)}
|
||||
${csField('Motivation', s.motivation)}
|
||||
${csField('Fears', s.fears)}
|
||||
${csField('Mannerisms & Habits', s.mannerisms)}
|
||||
${s.voice_pattern ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice & Speech</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''}
|
||||
${csField('Dark Secret / Fatal Flaw', s.secret)}
|
||||
${csField('Conflict Style', s.conflict_style)}
|
||||
${csField('Win Condition', s.win_condition)}
|
||||
@ -171,28 +292,44 @@ function csToMarkdown(sheets) {
|
||||
for (const s of list) {
|
||||
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
|
||||
if (s.aliases) md += `*aka ${s.aliases}*\n`;
|
||||
const mas = s.moral_alignment_score ?? 50;
|
||||
const masLabel = mas >= 70 ? 'Good' : mas <= 30 ? 'Evil' : 'Neutral';
|
||||
md += `- **Moral alignment:** ${mas}/100 (${masLabel}) — Arc: ${s.arc_direction || 'neutral'}\n`;
|
||||
if (s.arc_note) md += ` *${s.arc_note}*\n`;
|
||||
const f = (l, v) => v ? `- **${l}:** ${v}\n` : '';
|
||||
md += f('Physical', s.physical) + f('Alignment & Ethos', s.alignment)
|
||||
md += f('Physical', s.physical) + f('Clothing', s.clothing)
|
||||
+ f('Alignment & Ethos', s.alignment)
|
||||
+ f('Core Attributes', [s.attribute_high && '▲ ' + s.attribute_high, s.attribute_low && '▼ ' + s.attribute_low].filter(Boolean).join(' · '))
|
||||
+ f('Trained Skills', s.skills) + f('Signature Inventory', (s.inventory || []).join(', '))
|
||||
+ f('Dark Secret / Fatal Flaw', s.secret) + f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition);
|
||||
const src = (s.sources || []).filter(x => x && x.quote).map(x => `${x.page != null ? 'p.' + x.page + ' ' : ''}“${x.quote}”`).join('; ');
|
||||
+ f('Trained Skills', s.skills) + f('Capabilities', s.capabilities)
|
||||
+ f('Backstory & Origin', s.backstory) + f('Relationships', s.relationships)
|
||||
+ f('Motivation', s.motivation) + f('Fears', s.fears)
|
||||
+ f('Mannerisms & Habits', s.mannerisms) + f('Voice & Speech', s.voice_pattern)
|
||||
+ f('Signature Items', (s.inventory || []).join(', '))
|
||||
+ f('Dark Secret / Fatal Flaw', s.secret)
|
||||
+ f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition);
|
||||
const src = (s.sources || []).filter(x => x && x.quote)
|
||||
.map(x => `${x.line_hint ? '[' + x.line_hint + '] ' : ''}${x.page != null ? 'p.' + x.page + ' ' : ''}"${x.quote}"`).join('; ');
|
||||
if (src) md += `- *Sources:* ${src}\n`;
|
||||
}
|
||||
}
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
function csShow(sheets, title) {
|
||||
function csShow(sheets, title, sourceText) {
|
||||
document.getElementById('cs-overlay')?.remove();
|
||||
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('') : '';
|
||||
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 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>
|
||||
@ -202,30 +339,56 @@ function csShow(sheets, title) {
|
||||
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'));
|
||||
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 Deep Analysis buttons
|
||||
ov.querySelectorAll('.cs-deep-btn').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.name;
|
||||
const sheet = sheets.find(s => s.name === name);
|
||||
if (sheet) csDeepAnalysis(sheet, sourceText);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Entry points (reader + rehearser) ────────────────────────────────────────
|
||||
|
||||
// Persist a fresh batch of sheets into the Character Library (auto-save).
|
||||
async function csSaveToLibrary(book, sheets) {
|
||||
if (typeof clUpsertMany !== 'function') return;
|
||||
try {
|
||||
const n = await clUpsertMany(book, sheets);
|
||||
if (n) toast(`${n} character${n !== 1 ? 's' : ''} saved to library`, 'success');
|
||||
} catch (e) { /* non-fatal — the overlay still works */ }
|
||||
}
|
||||
|
||||
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]) { csShow(_cs.cache[key], readerState.title || 'Character sheets'); return; }
|
||||
const sheets = await csGenerate(csReaderText(), key);
|
||||
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets', text); return; }
|
||||
const sheets = await csGenerate(text, key);
|
||||
if (!sheets) return;
|
||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||
csShow(sheets, readerState.title || 'Character sheets');
|
||||
csSaveToLibrary(book, sheets);
|
||||
csShow(sheets, readerState.title || 'Character sheets', text);
|
||||
}
|
||||
|
||||
async function csForRehearser() {
|
||||
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
|
||||
const book = $('reh-script-title')?.value.trim() || 'Untitled script';
|
||||
const text = csRehearserText();
|
||||
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
|
||||
if (_cs.cache[key]) { csShow(_cs.cache[key], title); return; }
|
||||
const sheets = await csGenerate(csRehearserText(), key);
|
||||
if (_cs.cache[key]) { csShow(_cs.cache[key], title, text); return; }
|
||||
const sheets = await csGenerate(text, key);
|
||||
if (!sheets) return;
|
||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||
csShow(sheets, title);
|
||||
csSaveToLibrary(book, sheets);
|
||||
csShow(sheets, title, text);
|
||||
}
|
||||
|
||||
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
|
||||
|
||||
281
static/js/characters-library.js
Normal file
281
static/js/characters-library.js
Normal file
@ -0,0 +1,281 @@
|
||||
// ── Character library ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Persistent, book-scoped character sheets. Running "Character sheets" in Read
|
||||
// Aloud or the Rehearser upserts each extracted character into this library,
|
||||
// keyed by (book + name). Cards reuse the renderers from character-sheets.js
|
||||
// (csCardHtml, csAlignmentBar, csDeepAnalysis), which loads before this module.
|
||||
|
||||
const CL_DB_NAME = 'character-library';
|
||||
const CL_STORE = 'characters';
|
||||
|
||||
// Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics.
|
||||
const CL_EDIT_FIELDS = [
|
||||
['name', 'Name'], ['aliases', 'Aliases'], ['archetype', 'Archetype'],
|
||||
['physical', 'Physical'], ['clothing', 'Clothing & Appearance'],
|
||||
['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'],
|
||||
['skills', 'Trained Skills'], ['capabilities', 'Capabilities'],
|
||||
['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'],
|
||||
['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'],
|
||||
['voice_pattern', 'Voice & Speech'], ['secret', 'Dark Secret / Fatal Flaw'],
|
||||
['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'],
|
||||
];
|
||||
|
||||
// ── IndexedDB ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function clDbOpen() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(CL_DB_NAME, 1);
|
||||
req.onupgradeneeded = e => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains(CL_STORE)) {
|
||||
const store = db.createObjectStore(CL_STORE, { keyPath: 'id' });
|
||||
store.createIndex('book', 'book', { unique: false });
|
||||
}
|
||||
};
|
||||
req.onsuccess = e => resolve(e.target.result);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function clDbOp(mode, fn) {
|
||||
const db = await clDbOpen();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(CL_STORE, mode);
|
||||
const req = fn(tx.objectStore(CL_STORE));
|
||||
req.onsuccess = e => resolve(e.target.result);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function clGetAll() {
|
||||
const db = await clDbOpen();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(CL_STORE, 'readonly').objectStore(CL_STORE).getAll();
|
||||
req.onsuccess = e => resolve(e.target.result || []);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function clGet(id) { return clDbOp('readonly', s => s.get(id)); }
|
||||
async function clPut(rec) { return clDbOp('readwrite', s => s.put(rec)); }
|
||||
async function clDelete(id) { return clDbOp('readwrite', s => s.delete(id)); }
|
||||
|
||||
function clKey(book, name) {
|
||||
return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase();
|
||||
}
|
||||
|
||||
// Merge an incoming sheet into an existing one without blanking solid facts —
|
||||
// same rule as csMerge in character-sheets.js (longer value wins; sources append;
|
||||
// alignment score averages).
|
||||
function clMergeSheet(existing, incoming) {
|
||||
const e = { ...existing };
|
||||
const scalars = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS
|
||||
: CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name');
|
||||
scalars.forEach(f => { if ((incoming[f] || '').length > (e[f] || '').length) e[f] = incoming[f]; });
|
||||
if (incoming.tier === 'main') e.tier = 'main';
|
||||
if (incoming.moral_alignment_score != null) {
|
||||
e.moral_alignment_score = e.moral_alignment_score != null
|
||||
? Math.round((e.moral_alignment_score + incoming.moral_alignment_score) / 2)
|
||||
: incoming.moral_alignment_score;
|
||||
}
|
||||
if (incoming.arc_direction && incoming.arc_direction !== 'neutral') e.arc_direction = incoming.arc_direction;
|
||||
e.inventory = [...(existing.inventory || [])];
|
||||
(incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
|
||||
e.sources = [...(existing.sources || [])];
|
||||
(incoming.sources || []).forEach(src => {
|
||||
if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
|
||||
});
|
||||
return e;
|
||||
}
|
||||
|
||||
// Upsert one sheet into the library under a book. Returns the stored record.
|
||||
async function clUpsert(book, sheet) {
|
||||
const name = (sheet.name || '').trim();
|
||||
if (!name) return null;
|
||||
const bk = (book || '').trim() || 'Unsorted';
|
||||
const id = clKey(bk, name);
|
||||
const now = new Date();
|
||||
const prev = await clGet(id).catch(() => null);
|
||||
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sheet, name };
|
||||
const rec = {
|
||||
id, book: bk, name,
|
||||
sheet: merged,
|
||||
analysis: prev?.analysis || null,
|
||||
voice: prev?.voice || null,
|
||||
created: prev?.created || now,
|
||||
updated: now,
|
||||
};
|
||||
await clPut(rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
// Bulk upsert a list of sheets for one book (used by the analysis side-effect).
|
||||
async function clUpsertMany(book, sheets) {
|
||||
let n = 0;
|
||||
for (const s of (sheets || [])) { if (await clUpsert(book, s)) n++; }
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let _clRecords = [];
|
||||
|
||||
async function clRender() {
|
||||
const grid = document.getElementById('cl-grid');
|
||||
if (!grid) return;
|
||||
// Bind filter/search once the section fragment exists (it loads after this JS).
|
||||
const filterEl = document.getElementById('cl-book-filter');
|
||||
const searchEl = document.getElementById('cl-search');
|
||||
if (filterEl && !filterEl.dataset.bound) { filterEl.dataset.bound = '1'; filterEl.addEventListener('change', clApplyFilter); }
|
||||
if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); }
|
||||
try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; }
|
||||
|
||||
// Populate the book filter (preserve current selection if still present).
|
||||
const filter = document.getElementById('cl-book-filter');
|
||||
const books = [...new Set(_clRecords.map(r => r.book))].sort((a, b) => a.localeCompare(b));
|
||||
if (filter) {
|
||||
const cur = filter.value;
|
||||
filter.innerHTML = `<option value="">All books (${books.length})</option>` +
|
||||
books.map(b => `<option value="${escHtml(b)}">${escHtml(b)}</option>`).join('');
|
||||
if (cur && books.includes(cur)) filter.value = cur;
|
||||
}
|
||||
clApplyFilter();
|
||||
}
|
||||
|
||||
function clApplyFilter() {
|
||||
const grid = document.getElementById('cl-grid');
|
||||
if (!grid) return;
|
||||
const book = document.getElementById('cl-book-filter')?.value || '';
|
||||
const q = (document.getElementById('cl-search')?.value || '').trim().toLowerCase();
|
||||
|
||||
let recs = _clRecords.slice();
|
||||
if (book) recs = recs.filter(r => r.book === book);
|
||||
if (q) recs = recs.filter(r =>
|
||||
(r.name || '').toLowerCase().includes(q) ||
|
||||
(r.sheet?.aliases || '').toLowerCase().includes(q) ||
|
||||
(r.sheet?.archetype || '').toLowerCase().includes(q) ||
|
||||
(r.book || '').toLowerCase().includes(q));
|
||||
|
||||
if (!recs.length) {
|
||||
grid.innerHTML = `<div class="cl-empty">
|
||||
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
|
||||
<p>${_clRecords.length ? 'No characters match your filter.' : 'No characters yet.'}</p>
|
||||
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your library.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by book.
|
||||
const byBook = new Map();
|
||||
for (const r of recs) { if (!byBook.has(r.book)) byBook.set(r.book, []); byBook.get(r.book).push(r); }
|
||||
const groups = [...byBook.keys()].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
grid.innerHTML = groups.map(bk => {
|
||||
const list = byBook.get(bk).sort((a, b) => {
|
||||
const t = (a.sheet?.tier === 'main' ? 0 : 1) - (b.sheet?.tier === 'main' ? 0 : 1);
|
||||
return t || (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
return `<div class="cl-book-group">
|
||||
<div class="cl-book-head"><span class="mdi mdi-book-open-page-variant-outline"></span> ${escHtml(bk)}
|
||||
<span class="cl-book-count">${list.length}</span></div>
|
||||
<div class="cl-book-cards">${list.map(clCardHtml).join('')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function clCardHtml(rec) {
|
||||
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
|
||||
<div class="cl-card-tools">
|
||||
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
|
||||
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
|
||||
</div>
|
||||
${csCardHtml(rec.sheet)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Edit modal ──────────────────────────────────────────────────────────────
|
||||
|
||||
function clEdit(id) {
|
||||
const rec = _clRecords.find(r => r.id === id);
|
||||
if (!rec) return;
|
||||
const s = rec.sheet || {};
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'audiobook-overlay';
|
||||
const rows = CL_EDIT_FIELDS.map(([k, label]) => {
|
||||
const multiline = !['name', 'aliases', 'archetype'].includes(k);
|
||||
const val = escHtml(String(s[k] || ''));
|
||||
return `<label class="cl-edit-row"><span>${label}</span>${
|
||||
multiline
|
||||
? `<textarea data-field="${k}" rows="2">${val}</textarea>`
|
||||
: `<input type="text" data-field="${k}" value="${val}">`
|
||||
}</label>`;
|
||||
}).join('');
|
||||
modal.innerHTML = `<div class="audiobook-box cl-edit-box">
|
||||
<div class="cs-titlebar">
|
||||
<span class="audiobook-title"><span class="mdi mdi-pencil-outline"></span> Edit — ${escHtml(rec.name)}</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-secondary btn-sm" id="cl-edit-cancel">Cancel</button>
|
||||
<button class="btn-primary btn-sm" id="cl-edit-save"><span class="mdi mdi-content-save-outline"></span> Save</button>
|
||||
</div>
|
||||
<div class="cl-edit-body">
|
||||
<label class="cl-edit-row"><span>Moral alignment (0 evil – 100 good)</span>
|
||||
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
|
||||
<label class="cl-edit-row"><span>Arc direction</span>
|
||||
<select data-field="arc_direction">${
|
||||
['stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex']
|
||||
.map(o => `<option value="${o}"${(s.arc_direction || 'neutral') === o ? ' selected' : ''}>${o}</option>`).join('')
|
||||
}</select></label>
|
||||
${rows}
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
const close = () => modal.remove();
|
||||
modal.querySelector('#cl-edit-cancel').addEventListener('click', close);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
||||
modal.querySelector('#cl-edit-save').addEventListener('click', async () => {
|
||||
const patch = {};
|
||||
modal.querySelectorAll('[data-field]').forEach(el => { patch[el.dataset.field] = el.value; });
|
||||
const newName = (patch.name || '').trim() || rec.name;
|
||||
let mas = parseInt(patch.moral_alignment_score, 10);
|
||||
mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas));
|
||||
const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: mas };
|
||||
// If the name changed, the key changes too — delete old, write new.
|
||||
const newId = clKey(rec.book, newName);
|
||||
rec.sheet = newSheet; rec.name = newName; rec.updated = new Date();
|
||||
try {
|
||||
if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; }
|
||||
await clPut(rec);
|
||||
toast('Character saved', 'success');
|
||||
close();
|
||||
clRender();
|
||||
} catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
async function clDeleteRec(id) {
|
||||
const rec = _clRecords.find(r => r.id === id);
|
||||
if (!rec) return;
|
||||
if (!confirm(`Delete "${rec.name}" from ${rec.book}?`)) return;
|
||||
try { await clDelete(id); toast('Deleted', 'success'); clRender(); }
|
||||
catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
|
||||
}
|
||||
|
||||
// ── Wiring ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Document-level delegation — the #cl-grid is (re)built on every render and the
|
||||
// section fragment loads after this script, so we can't bind to it directly.
|
||||
document.addEventListener('click', e => {
|
||||
const edit = e.target.closest?.('.cl-edit');
|
||||
if (edit) { e.stopPropagation(); clEdit(edit.dataset.id); return; }
|
||||
const del = e.target.closest?.('.cl-delete');
|
||||
if (del) { e.stopPropagation(); clDeleteRec(del.dataset.id); return; }
|
||||
const deep = e.target.closest?.('.cl-card-wrap .cs-deep-btn');
|
||||
if (deep) {
|
||||
e.stopPropagation();
|
||||
const rec = _clRecords.find(r => r.id === deep.closest('.cl-card-wrap')?.dataset.id);
|
||||
if (rec && typeof csDeepAnalysis === 'function') csDeepAnalysis(rec.sheet, '');
|
||||
}
|
||||
});
|
||||
|
||||
window.clRender = clRender;
|
||||
window.clUpsertMany = clUpsertMany;
|
||||
@ -1247,20 +1247,27 @@ async function readerRenderLibrary() {
|
||||
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
|
||||
const synthPct = total ? Math.round((synth / total) * 100) : 0;
|
||||
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
|
||||
|
||||
let h = 0;
|
||||
const titleStr = rec.title || 'Untitled';
|
||||
for (let i = 0; i < titleStr.length; i++) h = (h * 31 + titleStr.charCodeAt(i)) % 360;
|
||||
const h2 = (h + 40) % 360;
|
||||
const c1 = `hsl(${h}, 55%, 42%)`, c2 = `hsl(${h2}, 58%, 30%)`;
|
||||
|
||||
// A timestamp busts the cache if the cover was updated
|
||||
const coverUrl = `${READER_API}/${rec.id}/cover?t=${new Date(rec.updated||Date.now()).getTime()}`;
|
||||
const bgStyle = rec.hasCover ? `style="background-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.8) 100%), url('${coverUrl}'); background-size: cover; background-position: center; color: #fff;"` : '';
|
||||
const textStyle = rec.hasCover ? `style="color: #fff;"` : '';
|
||||
const subtextStyle = rec.hasCover ? `style="color: rgba(255,255,255,0.7);"` : '';
|
||||
const statsStyle = rec.hasCover ? `style="color: rgba(255,255,255,0.8);"` : '';
|
||||
const bgStyle = rec.hasCover ? `style="background-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.8) 100%), url('${coverUrl}'); background-size: cover; background-position: center; color: #fff;"` : `style="--bk1:${c1};--bk2:${c2}"`;
|
||||
|
||||
return `<div class="reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}" ${bgStyle}>
|
||||
<button class="reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
|
||||
<div class="reader-book-icon"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span></div>
|
||||
<div class="reader-book-title" ${textStyle}>${escHtml(rec.title)}</div>
|
||||
<div class="reader-book-meta" ${subtextStyle}>${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''} · ${date}</div>
|
||||
<div class="reader-book-bar" title="${synthPct}% synthesised"><div class="reader-book-bar-synth" style="width:${synthPct}%"></div><div class="reader-book-bar-read" style="width:${readPct}%"></div></div>
|
||||
<div class="reader-book-stats" ${statsStyle}><span><span class="mdi mdi-lightning-bolt"></span> ${synthPct}% audio</span><span><span class="mdi mdi-bookmark-outline"></span> ${readPct}% read</span></div>
|
||||
return `<div class="reh-book reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}" ${bgStyle}>
|
||||
<div class="reh-book-actions">
|
||||
<button class="reh-book-act reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
|
||||
</div>
|
||||
<div class="reh-book-title">${escHtml(rec.title)}</div>
|
||||
<div class="reh-book-meta">
|
||||
${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''}
|
||||
<div class="reh-book-progress" title="${synthPct}% audio"><div style="width:${synthPct}%"></div></div>
|
||||
<div style="margin-top:4px;opacity:.8"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span> ${readPct}% read · ${date}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
@ -1269,6 +1276,50 @@ async function readerRenderLibrary() {
|
||||
if (e.target.closest('.reader-book-del')) return;
|
||||
readerOpenLibraryDoc(el.dataset.id);
|
||||
});
|
||||
|
||||
const delBtn = el.querySelector('.reader-book-del');
|
||||
if (delBtn) {
|
||||
delBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (el.querySelector('.reader-book-del-confirm')) return;
|
||||
|
||||
const confirmOverlay = document.createElement('div');
|
||||
confirmOverlay.className = 'reader-book-del-confirm';
|
||||
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
|
||||
confirmOverlay.innerHTML = `
|
||||
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Book?</div>
|
||||
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">Audio files will be removed.</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
|
||||
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
|
||||
|
||||
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
|
||||
ce.stopPropagation();
|
||||
confirmOverlay.remove();
|
||||
});
|
||||
|
||||
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
|
||||
ce.stopPropagation();
|
||||
confirmOverlay.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px;"></span>';
|
||||
try {
|
||||
const r = await fetch(`${READER_API}/${el.dataset.id}`, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error('Failed to delete book');
|
||||
toast('Book deleted', 'success');
|
||||
readerRenderLibrary();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
confirmOverlay.remove();
|
||||
}
|
||||
});
|
||||
|
||||
el.appendChild(confirmOverlay);
|
||||
});
|
||||
}
|
||||
|
||||
// Drag & drop for cover image
|
||||
el.addEventListener('dragover', e => {
|
||||
|
||||
@ -650,10 +650,37 @@ async function renderLibraryList() {
|
||||
|
||||
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Delete this rehearsal from the library?')) return;
|
||||
await rehDbDelete(parseInt(btn.dataset.id));
|
||||
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
|
||||
renderLibraryList();
|
||||
|
||||
const bookEl = btn.closest('.reh-book');
|
||||
if (bookEl.querySelector('.reh-book-del-confirm')) return;
|
||||
|
||||
const confirmOverlay = document.createElement('div');
|
||||
confirmOverlay.className = 'reh-book-del-confirm';
|
||||
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
|
||||
confirmOverlay.innerHTML = `
|
||||
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Rehearsal?</div>
|
||||
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">This cannot be undone.</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
|
||||
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
|
||||
|
||||
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
|
||||
ce.stopPropagation();
|
||||
confirmOverlay.remove();
|
||||
});
|
||||
|
||||
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
|
||||
ce.stopPropagation();
|
||||
await rehDbDelete(parseInt(btn.dataset.id));
|
||||
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
|
||||
renderLibraryList();
|
||||
});
|
||||
|
||||
bookEl.appendChild(confirmOverlay);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
const SECTIONS = [
|
||||
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
||||
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
|
||||
's-llms', 's-conversation',
|
||||
's-llms', 's-conversation', 's-characters',
|
||||
];
|
||||
|
||||
function _storedSection() {
|
||||
@ -126,6 +126,7 @@
|
||||
'/static/js/reader.js',
|
||||
'/static/js/audiobook.js',
|
||||
'/static/js/character-sheets.js',
|
||||
'/static/js/characters-library.js',
|
||||
];
|
||||
var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
|
||||
var _bundleOk = false;
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
llms: 's-llms'
|
||||
};
|
||||
|
||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
|
||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-characters'];
|
||||
|
||||
function storedSection() {
|
||||
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
||||
@ -98,6 +98,7 @@
|
||||
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running
|
||||
if (sectionId !== 's-reader' && typeof window.readerStop === 'function') window.readerStop();
|
||||
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
||||
if (sectionId === 's-characters' && typeof window.clRender === 'function') window.clRender();
|
||||
setStoredSection(sectionId);
|
||||
setSectionHash(sectionId);
|
||||
SECTIONS.forEach(function (id) {
|
||||
|
||||
26
static/sections/s-characters.html
Normal file
26
static/sections/s-characters.html
Normal file
@ -0,0 +1,26 @@
|
||||
<div class="section-head">
|
||||
<span class="section-icon"><span class="mdi mdi-account-box-multiple-outline"></span></span>
|
||||
<div class="section-title">
|
||||
<h2>Characters</h2>
|
||||
<p>Your character library — persistent, book-scoped sheets gathered by the LLM as you read or rehearse. Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to fill it up.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cl-toolbar card" style="padding:12px; display:flex; gap:10px; align-items:center; flex-wrap:wrap">
|
||||
<label class="cl-tool-field">
|
||||
<span class="cl-tool-label"><span class="mdi mdi-book-open-page-variant-outline"></span> Book</span>
|
||||
<select id="cl-book-filter"><option value="">All books</option></select>
|
||||
</label>
|
||||
<label class="cl-tool-field" style="flex:1; min-width:180px">
|
||||
<span class="cl-tool-label"><span class="mdi mdi-magnify"></span> Search</span>
|
||||
<input id="cl-search" type="text" placeholder="Name, alias, archetype…" spellcheck="false">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="cl-grid" class="cl-grid">
|
||||
<div class="cl-empty">
|
||||
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
|
||||
<p>No characters yet.</p>
|
||||
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your library.</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -3697,7 +3697,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
|
||||
/* Action / description block */
|
||||
.reh-action-block {
|
||||
font-family: 'Courier New', Courier, monospace; font-size: 13px; line-height: 1.75;
|
||||
font-family: 'Courier New', Courier, monospace; font-size: 15px; line-height: 1.75;
|
||||
color: #2c2c3e; padding: 2px 0 10px; cursor: pointer;
|
||||
}
|
||||
|
||||
@ -4074,9 +4074,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.reader-book-bar-read { position: absolute; inset: 0 auto 0 0; background: var(--accent); }
|
||||
.reader-book-stats { display: flex; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--subtext); z-index: 1; }
|
||||
.reader-book-del {
|
||||
position: absolute; top: 6px; right: 6px; border: none; background: rgba(0,0,0,.04);
|
||||
color: var(--subtext); border-radius: 6px; width: 24px; height: 24px; cursor: pointer; opacity: 0; transition: opacity .15s;
|
||||
position: absolute; top: 8px; right: 8px; border: none; background: rgba(0,0,0,.06);
|
||||
color: var(--subtext); border-radius: 6px; width: 28px; height: 28px; cursor: pointer; opacity: 0; transition: opacity .15s;
|
||||
display: flex; align-items: center; justify-content: center; z-index: 5;
|
||||
}
|
||||
.reader-book-del .mdi { font-size: 15px; }
|
||||
.reader-book:hover .reader-book-del { opacity: 1; }
|
||||
.reader-book-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.1); }
|
||||
|
||||
@ -4176,7 +4178,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.ab-cv-row.is-narr .ab-cv-spk {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--subtext);
|
||||
padding-left: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
@ -4257,6 +4258,35 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.cs-sources { margin-top: 8px; padding-top: 7px; border-top: 1px dashed var(--border); font-size: 11px; color: var(--subtext); }
|
||||
.cs-sources b { color: var(--text); }
|
||||
.cs-sources .mdi { color: var(--accent); }
|
||||
.cs-row-voice dt .mdi { color: var(--accent); }
|
||||
.cs-row-voice dd { font-style: italic; }
|
||||
|
||||
/* ── Character library section ─────────────────────────────────────────── */
|
||||
.cl-toolbar { margin-bottom: 14px; }
|
||||
.cl-tool-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.cl-tool-label { font-size: 10.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--subtext); }
|
||||
.cl-tool-field select, .cl-tool-field input { padding: 7px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font-size: 13px; }
|
||||
.cl-grid { display: flex; flex-direction: column; gap: 20px; }
|
||||
.cl-book-group { }
|
||||
.cl-book-head { display: flex; align-items: center; gap: 8px; font-weight: 800; font-size: 14px; padding: 4px 0 10px; border-bottom: 2px solid var(--border); margin-bottom: 12px; }
|
||||
.cl-book-head .mdi { color: var(--accent); }
|
||||
.cl-book-count { margin-left: 6px; font-size: 11px; font-weight: 700; color: var(--subtext); border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; }
|
||||
.cl-book-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; align-items: start; }
|
||||
.cl-card-wrap { position: relative; }
|
||||
.cl-card-tools { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; z-index: 2; opacity: 0; transition: opacity .15s; }
|
||||
.cl-card-wrap:hover .cl-card-tools { opacity: 1; }
|
||||
.cl-card-tools .btn-sm { font-size: 11px; padding: 2px 7px; }
|
||||
.cl-empty { text-align: center; padding: 60px 20px; color: var(--subtext); }
|
||||
.cl-empty p { margin: 8px 0 0; }
|
||||
.cl-empty-hint { font-size: 12.5px; }
|
||||
.cl-edit-box { max-width: 620px; width: 92vw; }
|
||||
.cl-edit-body { display: flex; flex-direction: column; gap: 10px; max-height: 70vh; overflow-y: auto; padding: 4px 2px; }
|
||||
.cl-edit-row { display: flex; flex-direction: column; gap: 3px; }
|
||||
.cl-edit-row > span { font-size: 11px; font-weight: 700; color: var(--subtext); }
|
||||
.cl-edit-row input, .cl-edit-row textarea, .cl-edit-row select { padding: 7px 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit; resize: vertical; }
|
||||
|
||||
/* ── Recast unknown: gap between non-contiguous passages ───────────────── */
|
||||
.ab-cv-divider { text-align: center; color: var(--subtext); font-size: 20px; letter-spacing: .35em; line-height: 1; padding: 10px 0 6px; opacity: .55; user-select: none; }
|
||||
.reader-synth-prog { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.reader-synth-track { width: 130px; height: 6px; border-radius: 4px; background: var(--panel); overflow: hidden; }
|
||||
.reader-synth-fill { height: 100%; width: 0; background: rgba(234,179,8,.9); transition: width .15s; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user