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
|
## [1.8.1] — 2026-06-25
|
||||||
|
|
||||||
### Added
|
### 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 ""
|
lang_hint = f" The text language is {language}; write the sheet in that language." if language else ""
|
||||||
system = (
|
system = (
|
||||||
"You are an expert dramaturge and tabletop RPG game master building character sheets that FILL UP "
|
"You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich "
|
||||||
"as a book is read passage by passage. Read this passage and extract playable, action-oriented "
|
"character sheets passage by passage as a book is read. Extract playable, action-oriented sheets "
|
||||||
"character sheets — sheets an actor can use to immediately know how to PLAY the character.\n"
|
"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 "
|
"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 "
|
"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 "
|
"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"
|
"For each character output these fields:\n"
|
||||||
"- name, aliases\n"
|
"- name, aliases\n"
|
||||||
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\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"
|
"- 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"
|
"- 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"
|
"- 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"
|
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
|
||||||
"- secret: dark secret or fatal flaw\n"
|
"- secret: dark secret or fatal flaw\n"
|
||||||
"- conflict_style: fight, flight, or manipulate — how they act when cornered\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"
|
"- win_condition: the specific event that would make them feel they have won\n"
|
||||||
"- tier: 'main' or 'supporting'\n"
|
"- tier: 'main' or 'supporting'\n"
|
||||||
"- sources: array of {page, quote} — the page number from the nearest [p.N] marker and a short "
|
"- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, "
|
||||||
"verbatim quote that supports the sheet (1-3 entries). Use null page if unknown.\n"
|
"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. "
|
"Reuse the EXACT names from the known-characters list for returning characters. "
|
||||||
"Respond with STRICT JSON only:\n"
|
"Respond with STRICT JSON only:\n"
|
||||||
'{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","alignment":"",'
|
'{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","clothing":"",'
|
||||||
'"attribute_high":"","attribute_low":"","skills":"","inventory":[],"secret":"",'
|
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
|
||||||
'"conflict_style":"","win_condition":"","tier":"main","sources":[{"page":1,"quote":""}]}]}\n/no-think'
|
'"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 = (
|
user = (
|
||||||
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
("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},
|
{"role": "user", "content": user},
|
||||||
],
|
],
|
||||||
"temperature": 0.4,
|
"temperature": 0.4,
|
||||||
"max_tokens": 3500,
|
"max_tokens": 4096,
|
||||||
}
|
}
|
||||||
if model:
|
if model:
|
||||||
payload["model"] = model
|
payload["model"] = model
|
||||||
@ -546,14 +561,110 @@ async def character_sheets(request: Request):
|
|||||||
elif not isinstance(inv, list):
|
elif not isinstance(inv, list):
|
||||||
inv = []
|
inv = []
|
||||||
src = s.get("sources") if isinstance(s.get("sources"), list) else []
|
src = s.get("sources") if isinstance(s.get("sources"), list) else []
|
||||||
s.update({"name": name, "inventory": inv[:3],
|
# Clamp moral alignment score
|
||||||
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
|
try:
|
||||||
"sources": src[:3]})
|
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)
|
clean.append(s)
|
||||||
names.append(name)
|
names.append(name)
|
||||||
return {"sheets": clean, "characters": names}
|
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")
|
@router.post("/api/attribute-dialogue")
|
||||||
async def attribute_dialogue(request: Request):
|
async def attribute_dialogue(request: Request):
|
||||||
"""Split a prose passage into attributed segments for a multi-voice audiobook.
|
"""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. "
|
"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"
|
"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. "
|
"- 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"
|
"- type: 'narration' or 'dialogue'\n"
|
||||||
"- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration\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"
|
f"{lang_hint}\n"
|
||||||
"QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:\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 „...“; "
|
" 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"
|
"- 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 "
|
"- 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"
|
"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:
|
else:
|
||||||
if lang_hint:
|
if lang_hint:
|
||||||
@ -614,13 +726,15 @@ async def attribute_dialogue(request: Request):
|
|||||||
|
|
||||||
system = (
|
system = (
|
||||||
f"{base_prompt}\n\n"
|
f"{base_prompt}\n\n"
|
||||||
"Respond with STRICT JSON only:\n"
|
"You MUST respond with a single, valid JSON object containing a 'segments' array.\n"
|
||||||
'{"segments":[{"speaker":"Narrator","type":"narration","text":"...","emotion":""}]}\n/no-think'
|
"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 = (
|
user = (
|
||||||
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
("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 "")
|
+ ("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 = {
|
payload: dict = {
|
||||||
"messages": [
|
"messages": [
|
||||||
@ -663,56 +777,12 @@ async def attribute_dialogue(request: Request):
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
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:
|
if not segments:
|
||||||
for line in content.split('\n'):
|
# If the LLM completely failed to output JSON despite the strict system prompt,
|
||||||
line = line.strip()
|
# it means it dropped into a conversational/reasoning hallucination.
|
||||||
if not line:
|
# Do NOT try to parse its reasoning as a script. Abort and preserve the book text.
|
||||||
continue
|
segments = [{"speaker": "Narrator", "type": "narration", "text": text, "emotion": ""}]
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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
|
# Normalise + collect speaker roster
|
||||||
clean, chars = [], []
|
clean, chars = [], []
|
||||||
for seg in segments:
|
for seg in segments:
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const MAIN = [
|
|||||||
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
|
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
|
||||||
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
||||||
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
||||||
|
'characters-library',
|
||||||
].map(n => join(jsDir, n + '.js'));
|
].map(n => join(jsDir, n + '.js'));
|
||||||
|
|
||||||
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
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) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.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) ── -->
|
<!-- ── 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="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 class="nav-tree-item" data-reader-view="library" onclick="navReaderView('library')"><span class="mdi mdi-bookshelf"></span> Library</div>
|
||||||
</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-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>
|
<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-llms" style="display:none"></section>
|
||||||
<section class="page-section" id="s-rehearser" 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-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>
|
<section class="page-section" id="s-conversation" style="display:none"></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -306,7 +308,7 @@
|
|||||||
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.8.0-3"></script>
|
<script src="/static/loader.js?v=1.9.0-1"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const _audiobook = { running: false, cancel: false };
|
|||||||
|
|
||||||
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
|
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
|
||||||
// German »...«/„...“, French «...», single ‘...’/›...‹, CJK 「...」『...』, em-dash speech.
|
// 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 || ''); }
|
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
|
||||||
|
|
||||||
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
|
// 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)
|
let a = null, b = null; // two most recent distinct named speakers (b = latest)
|
||||||
for (const s of segs) {
|
for (const s of segs) {
|
||||||
if (s.type !== 'dialogue') continue;
|
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; }
|
if (s.speaker !== b) { a = b; b = s.speaker; }
|
||||||
} else if (a && b && a !== b) {
|
} else if (a && b && a !== b) {
|
||||||
s.speaker = a; // the other of the two → alternate
|
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>
|
<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>
|
||||||
</select>
|
</select>
|
||||||
<span style="margin-right:8px;"></span>
|
<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>
|
||||||
<div class="ab-castpanel-prompt" id="ab-cv-prompt-panel" style="padding:10px 12px; background:var(--panel); border-bottom:1px solid var(--border);">
|
<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>
|
<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>
|
<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>
|
</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-body">
|
||||||
<div class="ab-cv-feed" id="ab-cv-feed"></div>
|
<div class="ab-cv-feed" id="ab-cv-feed"></div>
|
||||||
<div class="ab-cv-side">
|
<div class="ab-cv-side">
|
||||||
<div class="ab-cv-side-head">Characters found</div>
|
<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>
|
</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>
|
<button class="btn-primary btn-sm" id="ab-cv-start-cast" style="display:none;"><span class="mdi mdi-play"></span> Cast now</button>
|
||||||
</div>`;
|
</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:
|
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.
|
||||||
- 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
|
|
||||||
|
|
||||||
QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:
|
WICHTIGE DEFINITION VON DIALOG:
|
||||||
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).
|
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.
|
||||||
German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.
|
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).
|
||||||
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.
|
ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv):
|
||||||
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.
|
1. Direkte Zuordnung: Achte auf Wörter wie "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) zum tatsächlichen Namen auf.
|
||||||
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.
|
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.«").
|
||||||
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'.
|
3. Das Ping-Pong-Prinzip: Wenn zwei Personen sprechen, wechseln sie sich ab. Verfolge diese Kette lückenlos zurück zur letzten eindeutigen Nennung.
|
||||||
RULES:
|
4. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt die Aussage zum Wissen oder Tonfall eines bestimmten Charakters?
|
||||||
- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.
|
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').
|
||||||
- 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.
|
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'.
|
||||||
- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.`;
|
|
||||||
|
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;
|
const globalPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT;
|
||||||
panel.querySelector('#ab-cv-prompt-text').value = globalPrompt;
|
panel.querySelector('#ab-cv-prompt-text').value = globalPrompt;
|
||||||
@ -239,6 +247,10 @@ RULES:
|
|||||||
panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => {
|
panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => {
|
||||||
const p = panel.querySelector('#ab-cv-prompt-panel');
|
const p = panel.querySelector('#ab-cv-prompt-panel');
|
||||||
p.hidden = !p.hidden;
|
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
|
// 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] });
|
if (!roster.has(name)) roster.set(name, { count: 0, color: _AB_PALETTE[roster.size % _AB_PALETTE.length] });
|
||||||
return roster.get(name).color;
|
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 renderRoster = () => {
|
||||||
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||||
chars.innerHTML = items.length
|
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('')
|
? 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 MAXROWS = 80;
|
||||||
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
|
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
|
||||||
@ -411,7 +439,7 @@ RULES:
|
|||||||
assignPopup.appendChild(header);
|
assignPopup.appendChild(header);
|
||||||
|
|
||||||
const inp = document.createElement('input');
|
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);';
|
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);
|
assignPopup.appendChild(inp);
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
@ -434,12 +462,47 @@ RULES:
|
|||||||
});
|
});
|
||||||
inp.addEventListener('keydown', e => {
|
inp.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
const val = inp.value.trim();
|
const addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn');
|
||||||
if (val) assignName(val);
|
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') {
|
} else if (e.key === 'Escape') {
|
||||||
closeAssignPopup();
|
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() {
|
function closeAssignPopup() {
|
||||||
@ -457,20 +520,15 @@ RULES:
|
|||||||
if (isNarrator) assignModeSeg.emotion = '';
|
if (isNarrator) assignModeSeg.emotion = '';
|
||||||
assignModeRow.classList.remove('is-assigning');
|
assignModeRow.classList.remove('is-assigning');
|
||||||
|
|
||||||
if (isNarrator) {
|
const speakerName = isNarrator ? 'Narrator' : name;
|
||||||
assignModeRow.className = 'ab-cv-row is-narr';
|
const c = colorFor(speakerName);
|
||||||
assignModeRow.querySelector('.ab-cv-spk').innerHTML = 'Narrator';
|
if (!roster.has(speakerName)) roster.set(speakerName, { count: 0, color: c });
|
||||||
assignModeRow.querySelector('.ab-cv-spk').style.color = '';
|
roster.get(speakerName).count++;
|
||||||
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
|
|
||||||
} else {
|
assignModeRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||||||
const c = colorFor(name);
|
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>' : ''}`;
|
||||||
if (!roster.has(name)) roster.set(name, { count: 0, color: c });
|
assignModeRow.querySelector('.ab-cv-spk').style.color = c;
|
||||||
roster.get(name).count++;
|
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
|
||||||
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';
|
|
||||||
}
|
|
||||||
renderRoster();
|
renderRoster();
|
||||||
toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success');
|
toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success');
|
||||||
closeAssignPopup();
|
closeAssignPopup();
|
||||||
@ -499,6 +557,7 @@ RULES:
|
|||||||
|
|
||||||
// Always show Narrator as the first option
|
// Always show Narrator as the first option
|
||||||
const narrBtn = document.createElement('div');
|
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.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.innerHTML = `<span style="font-size:13px;">📖</span> Narrator`;
|
||||||
narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)';
|
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);
|
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||||
for (const [n, info] of items) {
|
for (const [n, info] of items) {
|
||||||
const btn = document.createElement('div');
|
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.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.innerHTML = `<span style="width:8px;height:8px;border-radius:50%;background:${info.color};"></span> ${escHtml(n)}`;
|
||||||
btn.onmouseover = () => btn.style.background = 'var(--panel)';
|
btn.onmouseover = () => btn.style.background = 'var(--panel)';
|
||||||
@ -578,39 +638,34 @@ RULES:
|
|||||||
const before = fullText.substring(0, idx).trim();
|
const before = fullText.substring(0, idx).trim();
|
||||||
const after = fullText.substring(idx + text.length).trim();
|
const after = fullText.substring(idx + text.length).trim();
|
||||||
|
|
||||||
|
// Try committed segments first, then live array (during active casting)
|
||||||
let arr = _audiobook.segments || [];
|
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.
|
let globalIdx = arr.indexOf(seg);
|
||||||
// To be safe, we just modify the object and insert new objects.
|
if (globalIdx === -1 && Array.isArray(_audiobook.liveSegments)) {
|
||||||
const globalIdx = arr.indexOf(seg);
|
arr = _audiobook.liveSegments;
|
||||||
|
globalIdx = arr.indexOf(seg);
|
||||||
|
}
|
||||||
const newSegs = [];
|
const newSegs = [];
|
||||||
if (before) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before });
|
if (before) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before });
|
||||||
newSegs.push({ speaker: 'Unknown', type: 'dialogue', emotion: '', text: text });
|
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 (after) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: after });
|
||||||
|
|
||||||
if (globalIdx !== -1) arr.splice(globalIdx, 1, ...newSegs);
|
if (globalIdx !== -1) {
|
||||||
else {
|
arr.splice(globalIdx, 1, ...newSegs);
|
||||||
// It's casting right now. We can just replace the row visually, and at the end of casting it uses the segments array.
|
} else {
|
||||||
// Wait, allSegments holds the real ones. If we can't find it in _audiobook.segments, we're in trouble.
|
// DOM-only split — will be reconciled once casting finishes
|
||||||
// Just flag the row. Actually, we should just prevent splitting during active casting.
|
console.warn('[audiobook] split during casting: DOM-only, segment not yet in array');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
for (const s of newSegs) {
|
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');
|
const newRow = document.createElement('div');
|
||||||
newRow.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
|
newRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||||||
newRow.__seg = s;
|
newRow.__seg = s;
|
||||||
if (dialog) {
|
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>`;
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
frag.appendChild(newRow);
|
frag.appendChild(newRow);
|
||||||
}
|
}
|
||||||
row.parentNode.insertBefore(frag, row);
|
row.parentNode.insertBefore(frag, row);
|
||||||
@ -664,7 +719,13 @@ RULES:
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
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) {
|
processing(text) {
|
||||||
if (this._procRow) this._procRow.remove();
|
if (this._procRow) this._procRow.remove();
|
||||||
this._procRow = document.createElement('div');
|
this._procRow = document.createElement('div');
|
||||||
@ -680,32 +741,86 @@ RULES:
|
|||||||
this.clearProcessing();
|
this.clearProcessing();
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
for (const s of segs) {
|
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');
|
const row = document.createElement('div');
|
||||||
row.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
|
row.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||||||
row.__seg = s;
|
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(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>`;
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
frag.appendChild(row);
|
frag.appendChild(row);
|
||||||
}
|
}
|
||||||
feed.appendChild(frag); trim(); renderRoster();
|
feed.appendChild(frag); trim(); renderRoster();
|
||||||
},
|
},
|
||||||
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
|
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
|
// 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.
|
// the preview — so it waits for you if you wandered off to do something else.
|
||||||
complete(summary, onOpen, onRecast, onRecastUnknown) {
|
complete(summary, onOpen, onRecast, onRecastUnknown) {
|
||||||
if (count) count.textContent = 'done';
|
if (count) count.textContent = 'done';
|
||||||
if (fill) fill.style.width = '100%';
|
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');
|
const cancelBtn = panel.querySelector('#ab-cv-cancel');
|
||||||
if (cancelBtn) cancelBtn.hidden = true;
|
if (cancelBtn) cancelBtn.hidden = true;
|
||||||
const foot = panel.querySelector('#ab-cv-foot');
|
const foot = panel.querySelector('#ab-cv-foot');
|
||||||
foot.hidden = false;
|
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-review').addEventListener('click', () => { closePanel(); onOpen(); });
|
||||||
foot.querySelector('#ab-cv-save-script').addEventListener('click', audiobookSaveAsRehearsal);
|
foot.querySelector('#ab-cv-save-script').addEventListener('click', audiobookSaveAsRehearsal);
|
||||||
|
foot.querySelector('#ab-cv-verify').addEventListener('click', runVerificationPass);
|
||||||
|
|
||||||
const applyPromptAndRun = (callback) => {
|
const applyPromptAndRun = (callback) => {
|
||||||
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
|
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
|
||||||
@ -735,7 +850,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
|
|||||||
|
|
||||||
const unknownIdxs = [];
|
const unknownIdxs = [];
|
||||||
for (let i = 0; i < segs.length; i++) {
|
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);
|
unknownIdxs.push(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -769,11 +884,17 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let done = 0;
|
let done = 0;
|
||||||
|
let prevIdx = -2;
|
||||||
try {
|
try {
|
||||||
for (let i of unknownIdxs) {
|
for (let i of unknownIdxs) {
|
||||||
if (_audiobook.cancel) break;
|
if (_audiobook.cancel) break;
|
||||||
const targetSeg = segs[i];
|
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 start = Math.max(0, i - 12);
|
||||||
const end = Math.min(segs.length, i + 6);
|
const end = Math.min(segs.length, i + 6);
|
||||||
const contextSegs = segs.slice(start, end);
|
const contextSegs = segs.slice(start, end);
|
||||||
@ -809,7 +930,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
|
|||||||
}
|
}
|
||||||
if (data && data.segments) {
|
if (data && data.segments) {
|
||||||
const match = data.segments.find(s => s.type === 'dialogue' && targetSeg.text.includes(s.text.slice(0, 15)));
|
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;
|
targetSeg.speaker = match.speaker;
|
||||||
if (match.emotion) targetSeg.emotion = match.emotion;
|
if (match.emotion) targetSeg.emotion = match.emotion;
|
||||||
if (!_audiobook.roster.includes(match.speaker)) _audiobook.roster.push(match.speaker);
|
if (!_audiobook.roster.includes(match.speaker)) _audiobook.roster.push(match.speaker);
|
||||||
@ -893,6 +1014,7 @@ async function audiobookCast(overrideUrl, overrideModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allSegments = [];
|
const allSegments = [];
|
||||||
|
_audiobook.liveSegments = allSegments; // expose so split works during casting
|
||||||
const roster = [];
|
const roster = [];
|
||||||
let narrationOnly = 0; // passages with no quotes at all — legitimately all narration
|
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
|
let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
|
||||||
@ -907,37 +1029,129 @@ async function audiobookCast(overrideUrl, overrideModel) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
|
// 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');
|
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
|
||||||
|
|
||||||
view.processing(chunks[i]);
|
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;
|
let data = null;
|
||||||
try {
|
|
||||||
const r = await fetch('/api/attribute-dialogue', {
|
if (result && !result.error) {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
data = result;
|
||||||
signal: ac.signal,
|
} else if (result && result.error) {
|
||||||
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 }),
|
// ── Retry: split the failing chunk in half and run both halves ──
|
||||||
});
|
view.note(`⚠️ Passage ${i + 1} timed out — retrying in two halves…`);
|
||||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); }
|
const half = Math.floor(chunks[i].length / 2);
|
||||||
data = await r.json();
|
const splitAt = chunks[i].lastIndexOf(' ', half) || half;
|
||||||
} catch (err) {
|
const chunkA = chunks[i].slice(0, splitAt).trim();
|
||||||
if (err.name === 'AbortError') break;
|
const chunkB = chunks[i].slice(splitAt).trim();
|
||||||
view.note(`API Error: ${err.message}`);
|
const resA = await attributeChunk(chunkA, recent);
|
||||||
data = null;
|
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 : [];
|
let segs = Array.isArray(data) ? data : (data && Array.isArray(data.segments) ? data.segments : []);
|
||||||
if (!segs.length) {
|
const hasDialogue = segs.some(s => s.type === 'dialogue');
|
||||||
// LLM unavailable or returned nothing, but this passage HAS quotes —
|
if (!segs.length || (!hasDialogue && audiobookHasDialogue(chunks[i]))) {
|
||||||
// extract dialogue + attribute speakers from speech tags so it isn't lost.
|
// LLM completely unavailable — extract dialogue from quotes without speaker attribution
|
||||||
segs = audiobookSplitByQuotes(chunks[i]);
|
segs = audiobookSplitByQuotes(chunks[i]);
|
||||||
degraded++;
|
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)'}`);
|
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
|
// 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 => { 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 => { if (s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown' && !roster.includes(s.speaker)) roster.push(s.speaker); });
|
|
||||||
segs.forEach(s => allSegments.push(s));
|
segs.forEach(s => allSegments.push(s));
|
||||||
view.addSegments(segs);
|
view.addSegments(segs);
|
||||||
}
|
}
|
||||||
@ -973,7 +1187,7 @@ function audiobookShowPreview() {
|
|||||||
ov.className = 'audiobook-overlay';
|
ov.className = 'audiobook-overlay';
|
||||||
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
|
// 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 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('');
|
const opts = speakerSet.map(n => `<option value="${escHtml(n)}">`).join('');
|
||||||
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
|
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
|
||||||
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Review cast & lines
|
<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' : ''}">
|
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-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'}>
|
<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('');
|
</div>`).join('');
|
||||||
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
|
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
|
||||||
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); 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
|
// 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
|
// document (Read Aloud) or a script (Script Rehearser). Each claim cites a
|
||||||
// source (page + short quote). Shared by both sections via one overlay.
|
// source (page + short quote + category hint). 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).
|
|
||||||
|
|
||||||
const CS_CHUNK_CHARS = 4000;
|
const CS_CHUNK_CHARS = 4000;
|
||||||
const _cs = { running: false, cancel: false, cache: {} };
|
const _cs = { running: false, cancel: false, cache: {} };
|
||||||
@ -44,29 +40,41 @@ function csRehearserText() {
|
|||||||
|
|
||||||
// ── Generation (chunked + merged by character) ───────────────────────────────
|
// ── Generation (chunked + merged by character) ───────────────────────────────
|
||||||
|
|
||||||
// Compact summary of the sheets built so far → tells the LLM what's known and
|
const CS_SCALAR_FIELDS = ['aliases', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note',
|
||||||
// which fields each character still needs, so it fills gaps instead of restarting.
|
'attribute_high', 'attribute_low', 'skills', 'capabilities',
|
||||||
|
'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern',
|
||||||
|
'secret', 'conflict_style', 'win_condition'];
|
||||||
|
|
||||||
function csExistingSummary(map) {
|
function csExistingSummary(map) {
|
||||||
if (!map.size) return '';
|
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 => {
|
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}` : ''}`
|
return `- ${s.name}${s.aliases ? ` (${s.aliases})` : ''}${s.archetype ? ` — ${s.archetype}` : ''}`
|
||||||
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
|
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function csMerge(map, sheets) {
|
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) {
|
for (const s of sheets) {
|
||||||
const name = (s.name || '').trim(); if (!name) continue;
|
const name = (s.name || '').trim(); if (!name) continue;
|
||||||
const key = name.toLowerCase();
|
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);
|
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.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.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) {
|
function csField(label, value) {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
@ -134,25 +230,50 @@ function csField(label, value) {
|
|||||||
|
|
||||||
function csCardHtml(s) {
|
function csCardHtml(s) {
|
||||||
const inv = (s.inventory || []).filter(Boolean);
|
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)
|
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 sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
|
||||||
const srcHtml = sources.length
|
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>` : '';
|
? `<div class="cs-sources"><span class="mdi mdi-book-open-page-variant-outline"></span> ${
|
||||||
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}">
|
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">
|
<div class="cs-head">
|
||||||
<span class="cs-name">${escHtml(s.name)}</span>
|
<span class="cs-name">${escHtml(s.name)}</span>
|
||||||
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
|
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
|
||||||
<span class="cs-tier">${s.tier === 'main' ? 'Main' : 'Supporting'}</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>
|
</div>
|
||||||
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</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">
|
<dl class="cs-fields">
|
||||||
${csField('Physical', s.physical)}
|
${csField('Physical', s.physical)}
|
||||||
${csField('Alignment & Ethos', s.alignment)}
|
${csField('Clothing & Appearance', s.clothing)}
|
||||||
|
${csField('Alignment & Ethos', s.alignment)}
|
||||||
${attrs}
|
${attrs}
|
||||||
${csField('Trained Skills', s.skills)}
|
${csField('Trained Skills', s.skills)}
|
||||||
|
${csField('Capabilities', s.capabilities)}
|
||||||
${invHtml}
|
${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('Dark Secret / Fatal Flaw', s.secret)}
|
||||||
${csField('Conflict Style', s.conflict_style)}
|
${csField('Conflict Style', s.conflict_style)}
|
||||||
${csField('Win Condition', s.win_condition)}
|
${csField('Win Condition', s.win_condition)}
|
||||||
@ -171,28 +292,44 @@ function csToMarkdown(sheets) {
|
|||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
|
md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`;
|
||||||
if (s.aliases) md += `*aka ${s.aliases}*\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` : '';
|
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('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('Trained Skills', s.skills) + f('Capabilities', s.capabilities)
|
||||||
+ f('Dark Secret / Fatal Flaw', s.secret) + f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition);
|
+ f('Backstory & Origin', s.backstory) + f('Relationships', s.relationships)
|
||||||
const src = (s.sources || []).filter(x => x && x.quote).map(x => `${x.page != null ? 'p.' + x.page + ' ' : ''}“${x.quote}”`).join('; ');
|
+ 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`;
|
if (src) md += `- *Sources:* ${src}\n`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return md.trim();
|
return md.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function csShow(sheets, title) {
|
function csShow(sheets, title, sourceText) {
|
||||||
document.getElementById('cs-overlay')?.remove();
|
document.getElementById('cs-overlay')?.remove();
|
||||||
const ov = document.createElement('div');
|
const ov = document.createElement('div');
|
||||||
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
|
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
|
||||||
const main = sheets.filter(s => s.tier === 'main');
|
const main = sheets.filter(s => s.tier === 'main');
|
||||||
const supp = 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">
|
ov.innerHTML = `<div class="audiobook-box cs-box">
|
||||||
<div class="cs-titlebar">
|
<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>
|
<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-copy"><span class="mdi mdi-content-copy"></span> Copy</button>
|
||||||
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
|
<button class="btn-secondary btn-sm" id="cs-close">Close</button>
|
||||||
@ -202,30 +339,56 @@ function csShow(sheets, title) {
|
|||||||
document.body.appendChild(ov);
|
document.body.appendChild(ov);
|
||||||
ov.querySelector('#cs-close').addEventListener('click', () => ov.remove());
|
ov.querySelector('#cs-close').addEventListener('click', () => ov.remove());
|
||||||
ov.querySelector('#cs-copy').addEventListener('click', () => {
|
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(); });
|
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) ────────────────────────────────────────
|
// ── 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() {
|
async function csForReader() {
|
||||||
|
const text = csReaderText();
|
||||||
|
const book = readerState.title || 'Untitled book';
|
||||||
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
|
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
|
||||||
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets'); return; }
|
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets', text); return; }
|
||||||
const sheets = await csGenerate(csReaderText(), key);
|
const sheets = await csGenerate(text, key);
|
||||||
if (!sheets) return;
|
if (!sheets) return;
|
||||||
if (!sheets.length) { toast('No characters found', 'error'); 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() {
|
async function csForRehearser() {
|
||||||
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
|
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);
|
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
|
||||||
if (_cs.cache[key]) { csShow(_cs.cache[key], title); return; }
|
if (_cs.cache[key]) { csShow(_cs.cache[key], title, text); return; }
|
||||||
const sheets = await csGenerate(csRehearserText(), key);
|
const sheets = await csGenerate(text, key);
|
||||||
if (!sheets) return;
|
if (!sheets) return;
|
||||||
if (!sheets.length) { toast('No characters found', 'error'); 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);
|
$('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 readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
|
||||||
const synthPct = total ? Math.round((synth / total) * 100) : 0;
|
const synthPct = total ? Math.round((synth / total) * 100) : 0;
|
||||||
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
|
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
|
// 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 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 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}"`;
|
||||||
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);"` : '';
|
|
||||||
|
|
||||||
return `<div class="reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}" ${bgStyle}>
|
return `<div class="reh-book 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="reh-book-actions">
|
||||||
<div class="reader-book-icon"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span></div>
|
<button class="reh-book-act reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
|
||||||
<div class="reader-book-title" ${textStyle}>${escHtml(rec.title)}</div>
|
</div>
|
||||||
<div class="reader-book-meta" ${subtextStyle}>${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''} · ${date}</div>
|
<div class="reh-book-title">${escHtml(rec.title)}</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="reh-book-meta">
|
||||||
<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>
|
${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>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@ -1270,6 +1277,50 @@ async function readerRenderLibrary() {
|
|||||||
readerOpenLibraryDoc(el.dataset.id);
|
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
|
// Drag & drop for cover image
|
||||||
el.addEventListener('dragover', e => {
|
el.addEventListener('dragover', e => {
|
||||||
if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) {
|
if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) {
|
||||||
|
|||||||
@ -650,10 +650,37 @@ async function renderLibraryList() {
|
|||||||
|
|
||||||
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
|
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!confirm('Delete this rehearsal from the library?')) return;
|
|
||||||
await rehDbDelete(parseInt(btn.dataset.id));
|
const bookEl = btn.closest('.reh-book');
|
||||||
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
|
if (bookEl.querySelector('.reh-book-del-confirm')) return;
|
||||||
renderLibraryList();
|
|
||||||
|
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 = [
|
const SECTIONS = [
|
||||||
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
||||||
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
|
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
|
||||||
's-llms', 's-conversation',
|
's-llms', 's-conversation', 's-characters',
|
||||||
];
|
];
|
||||||
|
|
||||||
function _storedSection() {
|
function _storedSection() {
|
||||||
@ -126,6 +126,7 @@
|
|||||||
'/static/js/reader.js',
|
'/static/js/reader.js',
|
||||||
'/static/js/audiobook.js',
|
'/static/js/audiobook.js',
|
||||||
'/static/js/character-sheets.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 _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
|
||||||
var _bundleOk = false;
|
var _bundleOk = false;
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
llms: 's-llms'
|
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() {
|
function storedSection() {
|
||||||
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
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
|
// 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.readerStop === 'function') window.readerStop();
|
||||||
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
||||||
|
if (sectionId === 's-characters' && typeof window.clRender === 'function') window.clRender();
|
||||||
setStoredSection(sectionId);
|
setStoredSection(sectionId);
|
||||||
setSectionHash(sectionId);
|
setSectionHash(sectionId);
|
||||||
SECTIONS.forEach(function (id) {
|
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 */
|
/* Action / description block */
|
||||||
.reh-action-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;
|
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-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-stats { display: flex; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--subtext); z-index: 1; }
|
||||||
.reader-book-del {
|
.reader-book-del {
|
||||||
position: absolute; top: 6px; right: 6px; border: none; background: rgba(0,0,0,.04);
|
position: absolute; top: 8px; right: 8px; border: none; background: rgba(0,0,0,.06);
|
||||||
color: var(--subtext); border-radius: 6px; width: 24px; height: 24px; cursor: pointer; opacity: 0; transition: opacity .15s;
|
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:hover .reader-book-del { opacity: 1; }
|
||||||
.reader-book-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.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 {
|
.ab-cv-row.is-narr .ab-cv-spk {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--subtext);
|
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
font-size: 11px;
|
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 { 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 b { color: var(--text); }
|
||||||
.cs-sources .mdi { color: var(--accent); }
|
.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-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-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; }
|
.reader-synth-fill { height: 100%; width: 0; background: rgba(234,179,8,.9); transition: width .15s; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user