From e02ca4d7034a4025a2d72247995518ffc8d2ed20 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 4 Jul 2026 12:48:00 +0200 Subject: [PATCH] Fix casting-feed freeze, zip cast export, character generation prompts (v1.12.90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-word wrapping behind "click any word to assign" created ~100k DOM nodes at book scale and froze the tab on every feed redraw; replaced with native caretRangeFromPoint word detection plus a single reused hover overlay — same UX, zero extra DOM. Export button gained a 2s re-entry guard (queued clicks during a freeze fired as a download burst) and now delivers one zip: the cast script in Markdown plus a sheet per character. Character detail view gains a Generation Prompts section — four fold-out copy boxes (Voice Design, Character Image, SillyTavern card, Concept Art sheet) filled by one LLM call over the full profile via the new /api/character-generate-prompts endpoint. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 45 ++++ VERSION | 2 +- routes/conversation.py | 110 +++++++++ routes/reader.py | 167 ++++++++++++-- static/index.html | 6 +- static/js/audiobook.js | 385 ++++++++++++++++++++++++++------ static/js/character-sheets.js | 3 +- static/js/characters-library.js | 4 +- static/js/generation.js | 10 +- static/js/library-characters.js | 69 ++++++ static/js/reader.js | 43 +++- static/js/utils.js | 124 +++++++++- static/style.css | 94 +++++++- 13 files changed, 952 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1d592b..0bfdbb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,51 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.12.90] — 2026-07-04 + +### Fixed +- **Page freezes ("Page Unresponsive") after opening a book / the casting feed** — the recent "click any word to assign" feature wrapped every single word of every casting segment in its own `` with hover styles; at book scale (~1,500 segments) that meant ~100,000 extra DOM nodes rebuilt synchronously on every feed redraw, freezing the tab. The spans are gone: the word under the cursor is now found via the browser's native caret-position API (`caretRangeFromPoint`) and highlighted with a single reused overlay element — same click/hover/drag-to-assign behaviour, zero extra DOM. +- **Burst of identical export downloads** — clicks queued up while the page was frozen could all fire at once on the export button when the tab unblocked, spawning one download + save-dialog per queued click. The export now ignores re-triggers for 2 seconds (and the freeze itself is fixed above). + +### Changed +- **"Export cast .md" is now "Export cast .zip"** — one zip bundle containing the cast as a readable Markdown script plus a Markdown sheet per character of the book (identity, appearance, personality, story, abilities, and the generation prompts), instead of a single cast file and no character sheets at all. + +### Added +- **Generation Prompts on every character sheet** — a new section in the Character Library detail view with four fold-out, copy-ready prompt boxes: Voice Design (Qwen3 TTS), Character Image (profile portrait), SillyTavern character card, and Concept Art (turnaround/model sheet). A "Generate" button fills all four in one LLM call over the character's complete profile — the previous behaviour generated voice/image prompts passage-by-passage during sheet extraction, where the model only ever saw a fraction of the character. The boxes are editable in place (autosaved like every other sheet field) and the results are included in the cast .zip export. + +--- + +## [1.12.89] — 2026-07-04 + +### Added +- **Quick "add alias" shortcut in the Casting sidebar** — hovering a character in the "Characters found" list now reveals a small tag icon; clicking it opens a tiny popup to add an "also known as" name (e.g. "Garthai" for "Sharraz Garthai") without leaving the casting screen. Writes through the same `clUpsert` the rest of the app uses, so the alias is immediately shared with Rehearser/Character sheets and starts getting recognized/underlined in the casting text right away. + +--- + +## [1.12.88] — 2026-07-04 + +### Added +- **Pipeline stepper prev/next navigation** — small chevron buttons flank the stepper to step to the nearest reachable stage in either direction, instead of only being able to jump directly to a specific stop. +- **Footer engine chips are now clickable fly-up menus** — click the LLM/STT/TTS chip in the footer status bar to quickly switch the active model (LLM: fetches the live model list for the current endpoint) or backend (STT/TTS: applies your pick to every matching picker across the app) without hunting through Settings or each screen's own dropdown. +- **Click or drag a name inside the casting text to assign it** — every word in the narration/dialogue text is now hoverable and clickable, not just the speaker label. Clicking a word opens the assign popup pre-filled with it; dragging across several words (for a multi-word name the roster doesn't know yet, e.g. "Sharraz Garthai") pre-fills the full phrase; double-clicking an already-known name/alias assigns it immediately with no popup. Built on the existing text-selection infrastructure (the "select text to split this segment" feature) rather than a separate mechanism, so the two don't fight over the same drag. + +--- + +## [1.12.87] — 2026-07-04 + +### Added +- **Real paragraph/chapter-break detection for PDFs** — extraction now flags where a new paragraph starts (`readerMarkParagraphBreaks`) by comparing each line's vertical gap against the page's typical line spacing; a heading/image band followed by a large gap before body text is caught by the same check. The break is preserved as a real blank line all the way through sentence-building, unit-grouping, `audiobookScopeText`, and `splitTextIntoChunks` — previously every paragraph and chapter heading in a book was silently joined into one run-on blob before the casting LLM ever saw the text. The attribution prompts (default, saved-prompt auto-upgrade, "2nd Quality Run", and the server-side fallback) now explain how to read the blank lines, including treating a short standalone line before one as a chapter heading rather than dialogue. + +### Fixed +- **Merging two casting segments dropped their page number** — `_abMergedSegment` built a fresh segment object and never carried over `.page` from either side, so a merged row would silently render as if it belonged to whatever page card came before it. It now anchors to the earlier segment's page. +- **Export cast .md was a JSON dump with a Markdown label** — the exported file's entire content was one big fenced `json` code block; it's now an actual readable script (plain paragraphs for narration, `**SPEAKER** (emotion): "line"` for dialogue, grouped under page headings), on both the server export route and the client-side fallback used when a book has no server id yet. + +### Changed +- **"Edit in Rehearser" renamed to "Edit Characters"** — the button always lands on Rehearser's Cast/voice-assignment screen, not general script editing, so the label now says what it does. +- **Casting text no longer edits via double-click** — only the pencil icon opens a row for editing now, so selecting/dragging across a name to assign a character (a much more common action) can't accidentally drop you into edit mode instead. + +--- + ## [1.12.86] — 2026-07-04 ### Changed diff --git a/VERSION b/VERSION index 6088240..8849819 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.86 +1.12.90 diff --git a/routes/conversation.py b/routes/conversation.py index 9854972..780eaeb 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -814,6 +814,111 @@ async def character_deep_analysis(request: Request): return {"name": name, "analysis": analysis} +@router.post("/api/character-generate-prompts") +async def character_generate_prompts(request: Request): + """Turn an already-extracted character sheet into four ready-to-use external + prompts, in one LLM call over the character's full profile (rather than + generating them incrementally passage-by-passage, where the model only + sees a fraction of the character at a time). + + Body: {name, book, sheet: {...character sheet fields...}, language, llm_url, model} + Returns: {voice_design_prompt, image_prompt, silly_tavern_prompt, concept_art_prompt} + """ + data = await request.json() + name: str = (data.get("name") or "").strip() + book: str = (data.get("book") or "").strip() + sheet: dict = data.get("sheet") or {} + 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") + + def _f(key: str) -> str: + v = sheet.get(key) + if isinstance(v, list): + return ", ".join(str(x) for x in v if x) + return str(v or "").strip() + + profile = "\n".join(f"{label}: {v}" for label, v in [ + ("Name", name), ("Aliases/titles", _f("aliases") or _f("title")), + ("Archetype", _f("archetype")), ("Physical", _f("physical")), ("Clothing", _f("clothing")), + ("Mannerisms", _f("mannerisms")), ("Voice/speech pattern", _f("voice_pattern")), + ("Backstory", _f("backstory")), ("Motivation", _f("motivation")), ("Fears", _f("fears")), + ("Relationships", _f("relationships")), ("Conflict style", _f("conflict_style")), + ("Secret/flaw", _f("secret")), ("Inventory", _f("inventory")), + ] if v) + + lang_note = f" Write every prompt in {language} EXCEPT where told to use English." if language else "" + system = ( + "You are a prompt engineer who turns a fiction character's profile into four ready-to-paste " + "prompts for other tools. Use ONLY details present in the profile below; mark anything you must " + f"reasonably infer with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n\n" + "Produce exactly these four fields:\n" + "- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, " + "no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, " + "accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not " + "mention plot events — describe only how the voice should SOUND.\n" + "- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. " + "Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, " + "clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style " + "(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors " + "are fine.\n" + "- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their " + "own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), " + "'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character " + "greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character " + "lines showing their manner of speech). Keep each section a few lines at most.\n" + "- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — " + "a turnaround/reference sheet with multiple views and expressions: front view, side or back view, " + "2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, " + "in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, " + "white background').\n\n" + "Respond with STRICT JSON only: " + '{"voice_design_prompt":"","image_prompt":"","silly_tavern_prompt":"","concept_art_prompt":""}/no-think' + ) + user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\nGenerate the four prompts now." + payload: dict = { + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": 0.7, + "max_tokens": 1600, + } + 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 prompt generation failed: {e}") + + content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw + result = {} + for cand in (content, _extract_json_block(content)): + if not cand: + continue + try: + parsed = json.loads(cand) + if isinstance(parsed, dict) and "voice_design_prompt" in parsed: + result = parsed + break + except Exception: + continue + return { + "voice_design_prompt": str(result.get("voice_design_prompt") or "").strip(), + "image_prompt": str(result.get("image_prompt") or "").strip(), + "silly_tavern_prompt": str(result.get("silly_tavern_prompt") or "").strip(), + "concept_art_prompt": str(result.get("concept_art_prompt") or "").strip(), + } + @router.post("/api/attribute-dialogue") async def attribute_dialogue(request: Request): @@ -856,6 +961,11 @@ async def attribute_dialogue(request: Request): " 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).\n" + "PARAGRAPH/CHAPTER STRUCTURE: a blank line marks a real paragraph break or a chapter/scene start. " + "A very short standalone line right before a blank line (e.g. 'Chapter 1', 'Prologue', a bare number) " + "is a chapter heading — always narration/'Narrator', never dialogue. Keep blank lines as part of the " + "surrounding narration segment or their own narration segment; never invent dialogue from them and " + "never drop them from the reconstructed text.\n" "German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.\n" "ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):\n" "1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns " diff --git a/routes/reader.py b/routes/reader.py index b53136f..3b35cb3 100644 --- a/routes/reader.py +++ b/routes/reader.py @@ -9,9 +9,12 @@ Stores each document under the writable config volume: Endpoints are deliberately small (file I/O) and mirror the previous IndexedDB shape so the frontend swap is mechanical. """ +import io import json import shutil import uuid +import zipfile +from datetime import datetime from pathlib import Path from fastapi import APIRouter, HTTPException, Request @@ -19,6 +22,7 @@ from fastapi.responses import FileResponse, Response from core.constants import CONFIG_DIR from core.database import ( + char_get_all, reader_script_delete as db_reader_script_delete, reader_script_get as db_reader_script_get, reader_script_list as db_reader_script_list, @@ -216,20 +220,59 @@ def _valid_script_name(name: str) -> bool: return bool(name and name.replace("-", "").replace("_", "").isalnum()) def _cast_to_md(data: dict) -> str: - """Serialise cast JSON as a fenced code-block Markdown file.""" - meta = {k: v for k, v in data.items() if k != "segments"} - lines = [ - "# Cast Script", - "", - f"**Book:** {data.get('title', '')} ", - f"**Saved:** {data.get('savedAt', '')} ", - f"**Segments:** {len(data.get('segments', []))} ", - "", - "```json", - json.dumps(data, ensure_ascii=False, indent=2), - "```", - "", - ] + """Render the cast as a readable Markdown script — narrator lines as plain + paragraphs, dialogue as **SPEAKER** (emotion): "line", grouped under page + headings from each segment's .page. This is a one-way export for reading/ + sharing; the app's own persistence is the SQLite script store, so the file + doesn't need to round-trip back through _md_to_cast (that function only + still matters for pre-migration legacy .md files).""" + segments = data.get("segments") or [] + roster = data.get("roster") or [] + saved_at = data.get("savedAt") + saved_str = "" + if saved_at: + try: + saved_str = datetime.fromtimestamp(float(saved_at) / 1000).strftime("%Y-%m-%d %H:%M") + except Exception: + saved_str = str(saved_at) + speakers = sorted({ + str(s.get("speaker") or "Narrator") for s in segments + if isinstance(s, dict) and s.get("type") == "dialogue" and s.get("speaker") + }) + lines = [f"# {data.get('title') or 'Cast Script'}", ""] + meta_bits = [] + if saved_str: + meta_bits.append(f"exported {saved_str}") + meta_bits.append(f"{len(speakers)} character{'s' if len(speakers) != 1 else ''}") + meta_bits.append(f"{len(segments)} segment{'s' if len(segments) != 1 else ''}") + lines.append(f"*{' · '.join(meta_bits)}*") + lines.append("") + if speakers: + lines.append(f"**Characters:** {', '.join(speakers)}") + lines.append("") + last_page = None + for seg in segments: + if not isinstance(seg, dict): + continue + text = str(seg.get("text") or "").strip() + if not text: + continue + page = seg.get("page") + if page is not None and page != last_page: + lines.append("---") + lines.append("") + lines.append(f"## Page {page}") + lines.append("") + last_page = page + is_dialogue = seg.get("type") == "dialogue" + speaker = str(seg.get("speaker") or "Narrator") + if is_dialogue: + emotion = str(seg.get("emotion") or "").strip() + tag = f" *({emotion})*" if emotion else "" + lines.append(f'**{speaker.upper()}**{tag}: "{text}"') + else: + lines.append(text) + lines.append("") return "\n".join(lines) def _md_to_cast(text: str) -> dict: @@ -241,6 +284,74 @@ def _md_to_cast(text: str) -> dict: return json.loads(m.group(1)) +_CHAR_MD_SECTIONS = [ + ("Identity", [("Aliases / also known as", "aliases"), ("Full name", "full_name"), + ("Title / role", "title"), ("Archetype", "archetype")]), + ("Appearance", [("Physical", "physical"), ("Clothing", "clothing")]), + ("Personality", [("Mannerisms & habits", "mannerisms"), ("Voice & speech", "voice_pattern"), + ("Motivation", "motivation"), ("Fears", "fears")]), + ("Story", [("Backstory", "backstory"), ("Relationships", "relationships"), + ("Dark secret / fatal flaw", "secret"), ("Character arc", "arc_note")]), + ("Abilities", [("Skills", "skills"), ("Capabilities", "capabilities"), + ("Conflict style", "conflict_style"), ("Win condition", "win_condition")]), + ("Generation prompts", [("Voice design prompt", "voice_design_prompt"), + ("Image prompt", "image_prompt"), + ("SillyTavern prompt", "silly_tavern_prompt"), + ("Concept art prompt", "concept_art_prompt")]), +] + + +def _character_to_md(rec: dict) -> str: + """Render one character-library record as a readable Markdown sheet.""" + sheet = rec.get("sheet") or {} + + def _s(key: str) -> str: + v = sheet.get(key) + if isinstance(v, list): + return ", ".join(str(x) for x in v if x) + return str(v or "").strip() + + lines = [f"# {rec.get('name') or 'Character'}", ""] + if rec.get("book"): + lines.append(f"*{rec['book']}*") + lines.append("") + for section, fields in _CHAR_MD_SECTIONS: + rows = [(label, _s(key)) for label, key in fields] + rows = [(label, v) for label, v in rows if v] + if not rows: + continue + lines.append(f"## {section}") + lines.append("") + for label, v in rows: + lines.append(f"**{label}:** {v}") + lines.append("") + inv = sheet.get("inventory") or [] + if inv: + lines.append("## Inventory") + lines.append("") + lines.extend(f"- {item}" for item in inv if item) + lines.append("") + return "\n".join(lines) + + +def _characters_for_title(title: str) -> list[dict]: + """Character records belonging to a book, by origin book OR tag membership + (mirrors clGetAllByTagOrBook in characters-library.js).""" + key = str(title or "").strip().lower() + if not key: + return [] + out = [] + try: + for rec in char_get_all(): + book = str(rec.get("book") or "").strip().lower() + tags = [t.strip().lower() for t in str(rec.get("tags") or "").split(",")] + if book == key or key in tags: + out.append(rec) + except Exception: + return [] + return out + + @router.get("/api/reader/docs/{doc_id}/scripts") async def reader_list_scripts(doc_id: str): _doc_dir(doc_id) @@ -315,16 +426,28 @@ async def reader_export_script(doc_id: str, name: str): data = _md_to_cast(f.read_text(encoding="utf-8")) except Exception as e: raise HTTPException(500, str(e)) - filename = f"{name}.md" title = str(data.get("title") or "").strip() - if title: - safe_title = "".join(c if c.isalnum() or c in "._- " else "_" for c in title).strip().replace(" ", "_") - if safe_title: - filename = f"{safe_title}_{name}.md" + safe_title = "".join(c if c.isalnum() or c in "._- " else "_" for c in title).strip().replace(" ", "_") or name + # One zip bundle: the cast script plus a sheet per character — instead of + # a separate browser download (and save-dialog) for every single file. + characters = _characters_for_title(title) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"{safe_title}_cast.md", _cast_to_md(data)) + seen = set() + for rec in characters: + cname = "".join(c if c.isalnum() or c in "._- " else "_" for c in str(rec.get("name") or "character")).strip() or "character" + base = cname + i = 2 + while cname.lower() in seen: + cname = f"{base}_{i}" + i += 1 + seen.add(cname.lower()) + zf.writestr(f"characters/{cname}.md", _character_to_md(rec)) return Response( - _cast_to_md(data), - media_type="text/markdown; charset=utf-8", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{safe_title}_{name}.zip"'}, ) diff --git a/static/index.html b/static/index.html index 72d4972..23fed76 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 11fe68d..99dae51 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -74,6 +74,44 @@ function audiobookSegmentText(seg) { return `"${seg.text || ''}"`; } +// Word-under-cursor for the casting feed WITHOUT wrapping every word in its +// own — an earlier version did exactly that, and at book scale +// (1500+ segments × dozens of words) the ~100k extra DOM nodes with hover +// styles froze the page on every feed redraw. Instead this asks the browser +// which text position sits under the pointer (caretRangeFromPoint / +// caretPositionFromPoint — both native and cheap), then expands to word +// boundaries. Returns { word, range } or null; the Range provides the rect +// for the hover highlight overlay and popup anchoring. +function _abWordRangeAtPoint(x, y) { + let node = null, offset = 0; + if (document.caretRangeFromPoint) { + const r = document.caretRangeFromPoint(x, y); + if (!r) return null; + node = r.startContainer; offset = r.startOffset; + } else if (document.caretPositionFromPoint) { + const p = document.caretPositionFromPoint(x, y); + if (!p) return null; + node = p.offsetNode; offset = p.offset; + } else return null; + if (!node || node.nodeType !== 3) return null; + const text = node.nodeValue || ''; + const isW = ch => ch != null && /[\p{L}\p{N}'’-]/u.test(ch); + if (offset >= text.length) offset = text.length - 1; + if (!isW(text[offset])) { + if (offset > 0 && isW(text[offset - 1])) offset--; + else return null; + } + let a = offset, b = offset; + while (a > 0 && isW(text[a - 1])) a--; + while (b + 1 < text.length && isW(text[b + 1])) b++; + const word = text.slice(a, b + 1).trim(); + if (!word || word.length < 2) return null; + const range = document.createRange(); + range.setStart(node, a); + range.setEnd(node, b + 1); + return { word, range }; +} + function audiobookJoinSegmentText(a, b) { const left = String(a || ''); const right = String(b || ''); @@ -208,20 +246,41 @@ function _abSafeFilename(name, fallback = 'cast') { return (s || fallback || 'cast').slice(0, 120); } +// Mirrors routes/reader.py's _cast_to_md — a readable Markdown script (plain +// paragraphs for narration, **SPEAKER** (emotion): "line" for dialogue, +// grouped under page headings), used when there's no server-side bookId to +// export through. One-way export for reading/sharing, not meant to round-trip. function _abCastMarkdown(data) { const payload = data || {}; - return [ - '# Cast Script', - '', - `**Book:** ${payload.title || ''} `, - `**Saved:** ${payload.savedAt || ''} `, - `**Segments:** ${Array.isArray(payload.segments) ? payload.segments.length : 0} `, - '', - '```json', - JSON.stringify(payload, null, 2), - '```', - '', - ].join('\n'); + const segments = Array.isArray(payload.segments) ? payload.segments : []; + const speakers = [...new Set( + segments.filter(s => s?.type === 'dialogue' && s.speaker).map(s => String(s.speaker)) + )].sort(); + const lines = [`# ${payload.title || 'Cast Script'}`, '']; + const metaBits = []; + if (payload.savedAt) metaBits.push('exported ' + new Date(payload.savedAt).toISOString().slice(0, 16).replace('T', ' ')); + metaBits.push(`${speakers.length} character${speakers.length !== 1 ? 's' : ''}`); + metaBits.push(`${segments.length} segment${segments.length !== 1 ? 's' : ''}`); + lines.push(`*${metaBits.join(' · ')}*`, ''); + if (speakers.length) lines.push(`**Characters:** ${speakers.join(', ')}`, ''); + let lastPage = null; + for (const seg of segments) { + const text = String(seg?.text || '').trim(); + if (!text) continue; + if (seg.page != null && seg.page !== lastPage) { + lines.push('---', '', `## Page ${seg.page}`, ''); + lastPage = seg.page; + } + if (seg.type === 'dialogue') { + const speaker = String(seg.speaker || 'Narrator'); + const tag = seg.emotion ? ` *(${seg.emotion})*` : ''; + lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`); + } else { + lines.push(text); + } + lines.push(''); + } + return lines.join('\n'); } // One-time migration for drafts saved before segments carried a .page number: @@ -284,7 +343,14 @@ function _abSaveDraft(segs, roster, text, done, total) { } } +let _abExportBusy = false; async function audiobookExportCastMd() { + // Re-entry guard: while the page is busy, a user's queued-up clicks can all + // fire at once when the main thread unblocks — without this, that meant a + // burst of identical downloads and one save-dialog per copy to cancel. + if (_abExportBusy) return; + _abExportBusy = true; + setTimeout(() => { _abExportBusy = false; }, 2000); const segs = _audiobook.segments || []; if (!segs.length) { toast('No cast to export', 'error'); return; } const bookId = _abBookId(); @@ -299,7 +365,7 @@ async function audiobookExportCastMd() { const blob = await r.blob(); const disp = r.headers.get('Content-Disposition') || ''; const match = disp.match(/filename="([^"]+)"/i); - const name = match?.[1] || `${_abSafeFilename(title)}_cast.md`; + const name = match?.[1] || `${_abSafeFilename(title)}_cast.zip`; if (typeof readerDownload === 'function') readerDownload(blob, name); else { const a = document.createElement('a'); @@ -308,7 +374,7 @@ async function audiobookExportCastMd() { a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 500); } - toast('Exported cast Markdown', 'success'); + toast('Exported cast + character sheets (.zip)', 'success'); return; } catch (err) { toast('Server export failed, downloading local cast copy', 'error'); @@ -709,7 +775,19 @@ function audiobookScopeText() { lastPage = pg; } } - const raw = idxs.map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim(); + // Join units with a blank line wherever one starts a real paragraph (per + // readerMarkParagraphBreaks/.paraStart) instead of unconditionally with a + // single space — otherwise chapter headings and every paragraph break in + // the book collapse into one run-on blob before the LLM ever sees the text. + let raw = ''; + for (const i of idxs) { + const u = readerState.sentences[i]; + if (!u?.text) continue; + raw += !raw ? u.text : (u.paraStart ? '\n\n' : ' ') + u.text; + } + // Collapse only horizontal whitespace runs and cap excessive blank lines — + // a plain /\s+/ collapse here would erase the \n\n paragraph markers just inserted. + raw = raw.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); const text = audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching // Locate each page anchor in the final text → page-break offsets. let from = 0; @@ -881,6 +959,9 @@ WICHTIGE DEFINITION VON DIALOG: Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden. Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog). +ABSATZ- UND KAPITELSTRUKTUR: +Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitelüberschrift — immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenständige narration-Segmente oder als Teil des umgebenden Erzähler-Segments bei; erfinde daraus keinen Dialog und lösche sie nicht aus dem rekonstruierten Text. + GRAMMATIK-REGELN FÜR NARRATION: - Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt."). - Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht. @@ -958,6 +1039,14 @@ STRIKTE FORMAT- UND TEXTREGELN: '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).\n\nGRAMMATIK-REGELN FÜR NARRATION:\n- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").\n- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.\n- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.\n- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.' ); } + // Paragraph/heading structure is now preserved as blank lines upstream + // (readerMarkParagraphBreaks); tell older saved prompts how to read it. + if (!/ABSATZ- UND KAPITELSTRUKTUR/.test(p)) { + p = p.replace( + '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).', + '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).\n\nABSATZ- UND KAPITELSTRUKTUR:\nEine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitelüberschrift — immer \'narration\'/\'Narrator\', niemals Dialog. Behalte Leerzeilen als eigenständige narration-Segmente oder als Teil des umgebenden Erzähler-Segments bei; erfinde daraus keinen Dialog und lösche sie nicht aus dem rekonstruierten Text.' + ); + } return p; }; @@ -1265,7 +1354,7 @@ STRIKTE FORMAT- UND TEXTREGELN: }; } for (const { name, regex } of _hlCache.list) { - html = html.replace(regex, (match) => `${match}`); + html = html.replace(regex, (match) => `${match}`); } return html; }; @@ -1608,13 +1697,68 @@ STRIKTE FORMAT- UND TEXTREGELN: ${escHtml((n||'?')[0].toUpperCase())} ${escHtml(n)} ${roster.get(n)?.count||0} + `; }).join(''); chars.querySelectorAll('.ab-char-item').forEach(item => { item.addEventListener('click', () => _abSelectChar(item.dataset.name)); }); + chars.querySelectorAll('.ab-char-alias-btn').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); _abOpenAliasPopup(btn.dataset.name, btn); }); + }); }; + // Quick "also known as" shortcut right in the sidebar, so adding an alias + // (e.g. "Garthai" for "Sharraz Garthai", or "Ork" for a role-only speaker) + // doesn't require leaving the casting screen for the full Character Library + // editor. Writes through the same clUpsert used everywhere else, so the + // alias is immediately shared with Rehearser/Character sheets too. + let _abAliasPopup = null; + function _abCloseAliasPopup() { if (_abAliasPopup) { _abAliasPopup.remove(); _abAliasPopup = null; } } + function _abOpenAliasPopup(name, anchorEl) { + _abCloseAliasPopup(); + const el = document.createElement('div'); + el.className = 'ab-alias-popup'; + el.innerHTML = ` +
Also known as — ${escHtml(name)}
+ +
+ + +
`; + document.body.appendChild(el); + const rect = anchorEl.getBoundingClientRect(); + el.style.left = Math.min(rect.left, window.innerWidth - 260) + 'px'; + el.style.top = Math.min(rect.bottom + 4, window.innerHeight - 120) + 'px'; + const inp = el.querySelector('.ab-alias-popup-inp'); + setTimeout(() => inp.focus(), 30); + const save = async () => { + const alias = inp.value.trim(); + if (!alias) { _abCloseAliasPopup(); return; } + try { + const book = window.readerState?.title || ''; + const rec = await clUpsert(book, { name, aliases: alias }); + if (rec) { registerCharacterRecord(rec); renderRoster(); if (_hlCache) _hlCache.ver = -1; } + toast(`"${alias}" added as an alias for ${name}`, 'success'); + } catch (err) { + toast('Could not save alias: ' + (err.message || err), 'error'); + } + _abCloseAliasPopup(); + }; + el.querySelector('.ab-alias-save').addEventListener('click', save); + el.querySelector('.ab-alias-cancel').addEventListener('click', () => _abCloseAliasPopup()); + inp.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); save(); } + else if (e.key === 'Escape') { e.preventDefault(); _abCloseAliasPopup(); } + }); + setTimeout(() => { + document.addEventListener('click', function onDoc(e) { + if (!el.contains(e.target) && e.target !== anchorEl) { _abCloseAliasPopup(); document.removeEventListener('click', onDoc); } + }); + }, 0); + _abAliasPopup = el; + } + (async () => { const title = window.readerState?.title || ''; try { @@ -1853,7 +1997,7 @@ STRIKTE FORMAT- UND TEXTREGELN: row.className = `ab-cv-row${extraClass ? ' ' + extraClass : ''}${isNarrator ? ' is-narr' : ''}`; row.__seg = s; row.innerHTML = ` - + @@ -2082,6 +2226,52 @@ STRIKTE FORMAT- UND TEXTREGELN: _abPersistManualEdit(); } + // Shared by clicking the speaker label AND clicking/dragging a name inside + // the narration text itself — same popup, optionally pre-filled with the + // word(s) you clicked so you don't have to retype an exact match. + function _abOpenAssignPopup(row, anchorEl, prefill) { + if (!row || !row.__seg || row.classList.contains('is-processing')) return; + if (assignModeRow) assignModeRow.classList.remove('is-assigning'); + assignModeSeg = row.__seg; + assignModeRow = row; + row.classList.add('is-assigning'); + window.getSelection().removeAllRanges(); + + assignPopup.style.display = 'flex'; + const rect = anchorEl.getBoundingClientRect(); + const top = Math.min(rect.bottom + 4, window.innerHeight - 300); + assignPopup.style.top = top + 'px'; + assignPopup.style.left = rect.left + 'px'; + + const list = assignPopup.querySelector('.ab-cv-popup-list'); + list.innerHTML = ''; + + const narrBtn = document.createElement('div'); + narrBtn.dataset.name = 'Narrator'; + narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;'; + narrBtn.innerHTML = `📖 Narrator`; + narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)'; + narrBtn.onmouseout = () => narrBtn.style.background = 'transparent'; + narrBtn.onclick = () => assignName('Narrator'); + list.appendChild(narrBtn); + + const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count); + for (const [n, info] of items) { + const btn = document.createElement('div'); + btn.dataset.name = n; + btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);'; + btn.innerHTML = ` ${escHtml(n)}`; + btn.onmouseover = () => btn.style.background = 'var(--panel)'; + btn.onmouseout = () => btn.style.background = 'transparent'; + btn.onclick = () => assignName(n); + list.appendChild(btn); + } + + const inp = assignPopup.querySelector('input'); + inp.value = prefill || ''; + setTimeout(() => { inp.focus(); if (prefill) inp.dispatchEvent(new Event('input')); }, 50); + } + const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker); const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker); const _abMergedSegment = (a, b) => { @@ -2095,6 +2285,11 @@ STRIKTE FORMAT- UND TEXTREGELN: type, emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '', text: audiobookJoinSegmentText(a.text || '', b.text || ''), + // Anchor the merged row at its earlier segment's page — merging never + // built this at all before, so a merge would silently drop the row's + // page number and it would render as if it belonged to whatever page + // card came before it. + page: a.page ?? b.page, }; }; const _abMergeByRow = (row, dir) => { @@ -2117,10 +2312,10 @@ STRIKTE FORMAT- UND TEXTREGELN: toast('Segments merged', 'success'); }; - feed.addEventListener('dblclick', e => { - const txt = e.target.closest('.ab-cv-txt'); - if (txt) _abStartRowEdit(txt.closest('.ab-cv-row')); - }); + // Editing is pencil-icon only (see the click handler's .ab-cv-edit-text + // branch) — double-click was removed so selecting/dragging text to assign + // a character (see the .ab-cv-txt mousedown/mouseup handling below) can't + // accidentally drop you into edit mode instead. feed.addEventListener('click', e => { const pageLabel = e.target.closest('.ab-cv-page-label[data-page]'); @@ -2146,52 +2341,82 @@ STRIKTE FORMAT- UND TEXTREGELN: } const spk = e.target.closest('.ab-cv-spk'); if (spk) { - const row = spk.closest('.ab-cv-row'); - if (row && row.__seg && !row.classList.contains('is-processing')) { - if (assignModeRow) assignModeRow.classList.remove('is-assigning'); - assignModeSeg = row.__seg; - assignModeRow = row; - row.classList.add('is-assigning'); - window.getSelection().removeAllRanges(); - - assignPopup.style.display = 'flex'; - const rect = spk.getBoundingClientRect(); - // ensure popup doesn't go off bottom of screen - const top = Math.min(rect.bottom + 4, window.innerHeight - 300); - assignPopup.style.top = top + 'px'; - assignPopup.style.left = rect.left + 'px'; - - const list = assignPopup.querySelector('.ab-cv-popup-list'); - list.innerHTML = ''; - - // Always show Narrator as the first option - const narrBtn = document.createElement('div'); - narrBtn.dataset.name = 'Narrator'; - narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;'; - narrBtn.innerHTML = `📖 Narrator`; - narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)'; - narrBtn.onmouseout = () => narrBtn.style.background = 'transparent'; - narrBtn.onclick = () => assignName('Narrator'); - list.appendChild(narrBtn); - - const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count); - for (const [n, info] of items) { - const btn = document.createElement('div'); - btn.dataset.name = n; - btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);'; - btn.innerHTML = ` ${escHtml(n)}`; - btn.onmouseover = () => btn.style.background = 'var(--panel)'; - btn.onmouseout = () => btn.style.background = 'transparent'; - btn.onclick = () => assignName(n); - list.appendChild(btn); + _abOpenAssignPopup(spk.closest('.ab-cv-row'), spk, ''); + return; + } + // Click a name/word inside the narration text itself — same popup, + // pre-filled with the clicked word so you don't have to retype it. + // Dragging across several words is handled by the native-selection + // listener below instead of a custom span-drag tracker, so it can't + // conflict with the existing "select text to split this segment" flow, + // which also reacts to window.getSelection() over the same text. That + // listener clears the selection once it acts, so this guard (rather than + // just isCollapsed) stops the click that follows a drag's mouseup from + // re-opening the popup with just the single word under the pointer. + if (_abJustHandledSelection) { _abJustHandledSelection = false; return; } + const txtEl = e.target.closest('.ab-cv-txt'); + if (txtEl && window.getSelection().isCollapsed) { + const row = txtEl.closest('.ab-cv-row'); + const nameHit = e.target.closest('.ab-name-hit'); + if (nameHit) { + _abOpenAssignPopup(row, nameHit, nameHit.dataset.name || nameHit.textContent.trim()); + } else { + const hit = _abWordRangeAtPoint(e.clientX, e.clientY); + if (hit) { + const rect = hit.range.getBoundingClientRect(); + _abOpenAssignPopup(row, { getBoundingClientRect: () => rect }, hit.word); } - - const inp = assignPopup.querySelector('input'); - inp.value = ''; - setTimeout(() => inp.focus(), 50); } } }); + let _abJustHandledSelection = false; + + // Hover highlight for the word under the cursor — one reused overlay div + // positioned from the caret-range rect, instead of per-word spans (see + // _abWordRangeAtPoint for why spans froze the page at book scale). + let _abHoverHL = document.getElementById('ab-word-hover'); + if (!_abHoverHL) { + _abHoverHL = document.createElement('div'); + _abHoverHL.id = 'ab-word-hover'; + _abHoverHL.className = 'ab-word-hover'; + _abHoverHL.hidden = true; + document.body.appendChild(_abHoverHL); + } + let _abHoverRaf = 0; + let _abHoverPt = null; + const _abHideHoverHL = () => { _abHoverHL.hidden = true; }; + feed.addEventListener('mousemove', e => { + _abHoverPt = { x: e.clientX, y: e.clientY, target: e.target }; + if (_abHoverRaf) return; + _abHoverRaf = requestAnimationFrame(() => { + _abHoverRaf = 0; + const pt = _abHoverPt; + if (!pt || !pt.target?.closest) return; + const txtEl = pt.target.closest('.ab-cv-txt'); + if (!txtEl || pt.target.closest('.ab-name-hit') || feed.querySelector('.ab-cv-edit-ta')) { _abHideHoverHL(); return; } + const hit = _abWordRangeAtPoint(pt.x, pt.y); + if (!hit) { _abHideHoverHL(); return; } + const r = hit.range.getBoundingClientRect(); + if (!r.width) { _abHideHoverHL(); return; } + _abHoverHL.style.left = (r.left - 2) + 'px'; + _abHoverHL.style.top = (r.top - 1) + 'px'; + _abHoverHL.style.width = (r.width + 4) + 'px'; + _abHoverHL.style.height = (r.height + 2) + 'px'; + _abHoverHL.hidden = false; + }); + }); + feed.addEventListener('mouseleave', _abHideHoverHL); + feed.addEventListener('scroll', _abHideHoverHL, { passive: true }); + feed.addEventListener('dblclick', e => { + const hit = e.target.closest('.ab-name-hit'); + if (!hit) return; // only a known name/alias can fast-assign; unknown text still needs the popup + const row = hit.closest('.ab-cv-row'); + const name = hit.dataset.name; + if (row && name) { + _abOpenAssignPopup(row, hit, ''); + assignName(name); + } + }); let splitBtn = document.getElementById('ab-cv-split-btn'); if (!splitBtn) { @@ -2285,15 +2510,28 @@ STRIKTE FORMAT- UND TEXTREGELN: }); feed.addEventListener('mouseup', () => { - if (!assignModeSeg) return; const sel = window.getSelection(); - if (!sel.isCollapsed && feed.contains(sel.anchorNode)) { - const text = sel.toString().trim(); - if (text && text.length < 40 && !text.includes('\n')) { - assignName(text); - sel.removeAllRanges(); - } + if (sel.isCollapsed || !feed.contains(sel.anchorNode)) return; + const text = sel.toString().trim(); + if (!text || text.length >= 40 || text.includes('\n')) return; + _abJustHandledSelection = true; // stop the click that follows this mouseup from also firing + if (assignModeSeg) { + // Popup already open (e.g. from clicking the speaker label) — a short + // selection refines/confirms the assignment directly, as before. + assignName(text); + sel.removeAllRanges(); + return; } + // No popup yet: dragging across a name inside the narration text (e.g. a + // multi-word name like "Sharraz Garthai" the roster doesn't have) opens + // the popup pre-filled with the dragged text instead of doing nothing. + const anchorEl = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode : sel.anchorNode; + const txtSpan = anchorEl.closest('.ab-cv-txt'); + if (!txtSpan) { _abJustHandledSelection = false; return; } + const row = txtSpan.closest('.ab-cv-row'); + if (!row) { _abJustHandledSelection = false; return; } + _abOpenAssignPopup(row, anchorEl, text); + sel.removeAllRanges(); }); chars.addEventListener('click', e => { @@ -2506,7 +2744,7 @@ STRIKTE FORMAT- UND TEXTREGELN: const continueBtnHtml = resumable ? `` : ''; - foot.innerHTML = `${continueBtnHtml}`; + foot.innerHTML = `${continueBtnHtml}`; 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. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern. @@ -2528,6 +2766,9 @@ DEDUKTIONS-WERKZEUGE (wende sie in dieser Reihenfolge an): DIALOG-ERKENNUNG BEI PDF/OCR-TEXTEN: Viele PDF-Extraktionen verlieren Anführungszeichen oder Guillemets. Ein kurzer Satz kann also trotzdem Dialog sein, auch wenn »...« oder „..." im übergebenen Segment fehlen. Entscheide nach Satzform, Antwortstruktur, Sprecherwechsel, Inquit-Formeln und Szene. Markiere eine Zeile NICHT allein deshalb als Narration, weil sichtbare Anführungszeichen fehlen. +ABSATZ- UND KAPITELSTRUKTUR: +Eine Leerzeile markiert einen Absatzwechsel oder Kapitel-/Szenenanfang. Eine sehr kurze, alleinstehende Zeile vor einer Leerzeile ist eine Kapitelüberschrift — 'narration'/'Narrator', niemals Dialog. + GRAMMATIK-CHECK FÜR NARRATION: - Inquit-Formeln / Sprecher-Tags sind narration, niemals dialogue: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name. - Beispiele: "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.", "fragte Uriens leise." sind Narrator/narration. @@ -2619,7 +2860,7 @@ ABSOLUTE REGELN: const foot = panel.querySelector('#ab-cv-foot'); const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0; foot.hidden = false; - foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; + foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel); foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', async () => { diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index 9d542f4..3d076d7 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -54,7 +54,8 @@ function csRehearserText() { const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note', 'attribute_high', 'attribute_low', 'skills', 'capabilities', 'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern', - 'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt']; + 'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt', + 'silly_tavern_prompt', 'concept_art_prompt']; const CS_DETAIL_FIELDS = CS_SCALAR_FIELDS.filter(f => !['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(f)); const CS_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title']; const CS_ALIAS_MAX_TOKENS = 12; diff --git a/static/js/characters-library.js b/static/js/characters-library.js index 91bfdda..14df05c 100644 --- a/static/js/characters-library.js +++ b/static/js/characters-library.js @@ -13,7 +13,9 @@ const CL_EDIT_FIELDS = [ ['skills', 'Trained Skills'], ['capabilities', 'Capabilities'], ['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'], ['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'], - ['voice_pattern', 'Voice & Speech'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'], ['secret', 'Dark Secret / Fatal Flaw'], + ['voice_pattern', 'Voice & Speech'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'], + ['silly_tavern_prompt', 'SillyTavern Character Prompt'], ['concept_art_prompt', 'Concept Art Prompt'], + ['secret', 'Dark Secret / Fatal Flaw'], ['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'], ]; diff --git a/static/js/generation.js b/static/js/generation.js index e9c99f0..82e6c54 100644 --- a/static/js/generation.js +++ b/static/js/generation.js @@ -61,8 +61,13 @@ function splitTextIntoChunks(text, maxLen = 800) { // Gather segments from each line: sentences first, then any remaining line text. // This ensures newline-delimited text (e.g. German bullet lists) gets split too. + // A blank line (paragraph break) becomes a '\x01' marker so the chunk-builder + // below can keep it as a blank line instead of flattening it to a space — + // otherwise every paragraph/chapter break in a book collapses into one run-on + // block by the time text reaches an LLM or TTS call. const segments = []; for (const line of safe.split('\n')) { + if (!line.trim()) { if (segments.length) segments.push('\x01'); continue; } const sentences = line.match(/[^.!?]+[.!?]+\s*/g) || []; const rest = line.replace(/[^.!?]+[.!?]+\s*/g, '').trim(); segments.push(...sentences); @@ -72,10 +77,13 @@ function splitTextIntoChunks(text, maxLen = 800) { const chunks = []; let cur = ''; + let paraPending = false; for (const seg of segments) { - const joined = cur ? cur + ' ' + seg.trim() : seg.trim(); + if (seg === '\x01') { paraPending = true; continue; } + const joined = cur ? cur + (paraPending ? '\n\n' : ' ') + seg.trim() : seg.trim(); if (joined.length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = seg.trim(); } else cur = joined; + paraPending = false; } if (cur.trim()) chunks.push(restore(cur.trim())); return chunks.length ? chunks : [text]; diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 37dd703..fdfd248 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -292,6 +292,21 @@ function _lcdSectionFull(icon, label, fields) { + ''; } +// A collapsible "ready to copy" box for one of the four external-tool prompts +// (Voice Design / Character Image / SillyTavern / Concept Art). Closed by +// default; editable in place via the same contenteditable+data-sheet-key +// convention the rest of the detail view uses, so edits autosave for free. +function _lcdPromptBox(label, value, sheetKey) { + const has = !!(value && String(value).trim()); + return '
' + + '' + escHtml(label) + (has ? '' : ' — not generated yet') + '' + + '
' + + '
' + escHtml(value || '') + '
' + + '' + + '
' + + '
'; +} + function _lcdFieldEdit(label, value, sheetKey) { const v = _libStr(value); return '
' @@ -371,6 +386,16 @@ async function _charDetailPage(rec, allChars) { + '
' ) : ''; + const promptsHtml = '
' + + '' + + _lcdPromptBox('Voice Design Prompt', sh.voice_design_prompt, 'voice_design_prompt') + + _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt') + + _lcdPromptBox('SillyTavern Character Prompt', sh.silly_tavern_prompt, 'silly_tavern_prompt') + + _lcdPromptBox('Concept Art Prompt', sh.concept_art_prompt, 'concept_art_prompt') + + '
'; + const relText = _libStr(sh.relationships).toLowerCase(); const relHits = (allChars || []) .filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; }) @@ -490,6 +515,7 @@ async function _charDetailPage(rec, allChars) { _lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'), _lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'), ]) + + promptsHtml + '' + sourcesHtml + (rec.analysis ? '
' + escHtml(String(rec.analysis)) + '
' : '') @@ -552,6 +578,49 @@ async function _charDetailPage(rec, allChars) { pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); }); pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoice(rec); }); + // Generation Prompts section: copy buttons + one-call generation of all four + // external-tool prompts (Voice Design / Image / SillyTavern / Concept Art) + // from the character's full profile. + pg.querySelectorAll('.lcd-prompt-copy').forEach(function (btn) { + btn.addEventListener('click', async function () { + const box = btn.closest('.lcd-prompt-body'); + const text = box?.querySelector('.lcd-prompt-text')?.textContent.trim() || ''; + if (!text) { toast('Nothing to copy yet — click Generate first', 'error'); return; } + if (typeof copyText === 'function') await copyText(text); + toast('Prompt copied', 'success'); + }); + }); + pg.querySelector('.lcd-gen-prompts')?.addEventListener('click', async function () { + const btn = this; + const orig = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' Generating…'; + try { + const sh2 = rec.sheet || {}; + const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' '); + const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : ''; + const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' }; + const r = await fetch('/api/character-generate-prompts', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model }), + }); + if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText); + const d = await r.json(); + if (!rec.sheet) rec.sheet = {}; + ['voice_design_prompt', 'image_prompt', 'silly_tavern_prompt', 'concept_art_prompt'].forEach(function (k) { + if (d[k]) rec.sheet[k] = d[k]; + }); + rec.updated = new Date(); + if (typeof clPut === 'function') await clPut(rec); + toast('Generation prompts created', 'success'); + _charDetailPage(rec, allChars); // re-render so the boxes show the new content + } catch (err) { + toast('Prompt generation failed: ' + (err.message || err), 'error'); + btn.disabled = false; + btn.innerHTML = orig; + } + }); + const slider = pg.querySelector('.lcd-align-slider'); const sliderVal = pg.querySelector('.lcd-align-slider-val'); const arcEl = pg.querySelector('.lcd-align-arc'); diff --git a/static/js/reader.js b/static/js/reader.js index 9374f68..760504a 100644 --- a/static/js/reader.js +++ b/static/js/reader.js @@ -308,7 +308,11 @@ function readerBuildSentences(words) { const endRe = /[.!?]["'”’)\]]?$/; const abbrev = /^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i; for (const w of words) { - if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null }; + // A detected paragraph break (readerMarkParagraphBreaks, PDF only) always + // ends the current sentence, even mid-punctuation, so the boundary isn't + // lost — it's carried as .paraStart for audiobookScopeText/TTS pacing. + if (w.para && cur && cur.words.length) { sentences.push(cur); cur = null; } + if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, paraStart: !!w.para }; cur.words.push(w); cur.text += (cur.text ? ' ' : '') + w.text; const isEnd = endRe.test(w.text) && !abbrev.test(w.text.replace(/[^a-z.]/gi, '')); @@ -327,7 +331,7 @@ function readerBuildSentences(words) { // • page — merge a whole PDF page (or ~2000-char blocks of text) function readerGroupUnits(base, mode) { if (mode === 'sentence' || !base.length) { - return base.map(s => ({ text: s.text, words: s.words, status: 'pending', _stat: null })); + return base.map(s => ({ text: s.text, words: s.words, status: 'pending', _stat: null, paraStart: !!s.paraStart })); } const maxChars = mode === 'page' ? 2000 : 500; const isPdf = readerState.mode === 'pdf'; @@ -344,14 +348,44 @@ function readerGroupUnits(base, mode) { ); const tooLong = cur && cur.words.length && (cur.text.length + s.text.length + 1 > maxChars); if (boundary || tooLong) { units.push(cur); cur = null; } - if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, _key: k }; - cur.text += (cur.text ? ' ' : '') + s.text; + if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, _key: k, paraStart: !!s.paraStart }; + // A real paragraph break inside a merged (paragraph/page) unit is kept as + // a blank line rather than collapsed to a space, so casting/synthesis can + // still tell paragraphs apart even when several are merged into one unit. + cur.text += !cur.text ? s.text : (s.paraStart ? '\n\n' : ' ') + s.text; cur.words.push(...s.words); } if (cur) units.push(cur); return units; } +// Flag the first word of each line that starts a new paragraph, by grouping +// words into visual lines (shared `top`) and comparing consecutive lines' +// vertical gap against the page's typical single-line spacing — a gap clearly +// bigger than normal (paragraph spacing, or the gap after a heading/image) +// marks a paragraph break. This is the only place paragraph structure is +// ever recovered for PDFs; downstream (readerBuildSentences, audiobookScopeText) +// just honours the `.para` flag it sets. +function readerMarkParagraphBreaks(pageWords) { + if (!pageWords.length) return; + const lines = []; + let curLine = null, curTop = null; + for (const w of pageWords) { + if (curLine && Math.abs(w.top - curTop) < w.h * 0.4) curLine.push(w); + else { curLine = [w]; curTop = w.top; lines.push(curLine); } + } + lines[0][0].para = true; // first line on the page always starts a paragraph + if (lines.length < 3) return; + const gaps = []; + for (let i = 1; i < lines.length; i++) gaps.push(lines[i][0].top - lines[i - 1][0].top); + const sorted = [...gaps].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)] || 0; + if (median <= 0) return; + for (let i = 1; i < lines.length; i++) { + if (lines[i][0].top - lines[i - 1][0].top > median * 1.35) lines[i][0].para = true; + } +} + // ── Headline OCR: recover chapter titles baked into the PDF as images ─────── // pdf.js getTextContent() only ever returns real text glyphs, so a headline // drawn as a raster/vector graphic (common with stylised chapter title pages) @@ -533,6 +567,7 @@ async function readerExtractPdfText(loaded) { if (ocrWords.length) pageWords.unshift(...ocrWords); } } + readerMarkParagraphBreaks(pageWords); // Build + append this page's sentences and units const pageBase = readerBuildSentences(pageWords); diff --git a/static/js/utils.js b/static/js/utils.js index 38c3805..64a6d1d 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -130,11 +130,12 @@ function statusActiveLlm() { function statusEngineChip(info) { const cls = info.ok ? 'ok' : 'bad'; - return ` + return ``; } function updateStatusBar() { @@ -145,6 +146,105 @@ function updateStatusBar() { engines.innerHTML = items.map(statusEngineChip).join(''); } +// ── Fly-up menus on the footer status chips — quickly switch the active +// LLM model / STT backend / TTS backend without hunting through Settings. ── + +let _statusFlyup = null; + +function _statusCloseFlyup() { + if (_statusFlyup) { _statusFlyup.remove(); _statusFlyup = null; } +} + +function _statusFlyupList(anchor, items, onPick, emptyLabel, kind) { + _statusCloseFlyup(); + const el = document.createElement('div'); + el.className = 'status-flyup'; + if (kind) el.dataset.forKind = kind; + el.innerHTML = items.length + ? items.map(it => ``).join('') + : `
${escHtml(emptyLabel || 'Nothing available')}
`; + document.body.appendChild(el); + const rect = anchor.getBoundingClientRect(); + el.style.left = Math.min(rect.left, window.innerWidth - el.offsetWidth - 12) + 'px'; + el.style.bottom = (window.innerHeight - rect.top + 6) + 'px'; + el.querySelectorAll('.status-flyup-item').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); onPick(btn.dataset.val); _statusCloseFlyup(); }); + }); + _statusFlyup = el; +} + +// Apply a chosen backend id to every matching