From 586dc3dc3f285cb3a2a55ac18ccab902c8df2fb6 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 30 Jun 2026 13:53:08 +0200 Subject: [PATCH] release: v1.12.52 --- .gitignore | 3 + CHANGELOG.md | 303 +++++++++++ VERSION | 2 +- core/database.py | 213 ++++++++ routes/characters.py | 61 +++ routes/conversation.py | 18 +- routes/docker.py | 14 +- routes/reader.py | 90 +++ routes/rehearsals_db.py | 75 +++ scripts/minify.mjs | 4 +- server.py | 4 +- static/index.html | 34 +- static/js/ai-backends.js | 2 +- static/js/audiobook.js | 575 ++++++++++++++++++-- static/js/character-sheets.js | 287 ++++++++-- static/js/characters-library.js | 178 +++--- static/js/conversation.js | 13 +- static/js/engines.js | 21 +- static/js/library.js | 935 +++++++++++++++++++++++++++++++- static/js/reader.js | 21 +- static/js/rehearser.js | 126 +++-- static/js/routing.js | 9 +- static/js/settings.js | 4 +- static/js/sillytavern.js | 190 +++++++ static/js/utils.js | 32 ++ static/js/voice-library.js | 12 +- static/loader.js | 83 ++- static/nav.js | 40 +- static/sections/s-library.html | 7 +- static/sections/s-voices.html | 2 +- static/style.css | 588 +++++++++++++++++++- 31 files changed, 3632 insertions(+), 314 deletions(-) create mode 100644 core/database.py create mode 100644 routes/characters.py create mode 100644 routes/rehearsals_db.py create mode 100644 static/js/sillytavern.js diff --git a/.gitignore b/.gitignore index 74f2bdf..1a6be48 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ APP_ERKLÄRUNG.md config/settings.json config/voice_design_presets.json config/tts_routes.json +config/*.db +config/*.db-shm +config/*.db-wal config/reader_library/ logs/*.log logs/*.log.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 15154e7..05c6131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,309 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.12.52] — 2026-06-30 + +### Fixed +- **Uncaught TypeError in settings.js** — `$('voice-id-input').addEventListener` crashed on load when the Clone section HTML hadn't been injected yet; guarded with `?.` + +--- + +## [1.12.51] — 2026-06-30 + +### Fixed +- **Engines not loading** — `loadLocalContainers()` was called once at module load before the section HTML existed; now called every time the Engines section is shown and on every tab switch (all three tabs: TTS, STT, LLM) + +--- + +## [1.12.50] — 2026-06-30 + +### Added +- **Skeleton loading for engine cards** — the LOCAL section in Engines (TTS / STT / LLM tabs) now shows animated shimmer placeholder cards while querying Docker container status instead of a blank box + +### Performance +- **Parallel Docker status checks** — `GET /api/local-containers` now queries all 25 container definitions concurrently via `asyncio.gather + asyncio.to_thread` instead of sequentially; reduces load time from ~25× latency to ~1× (one Docker socket call worth of time) + +--- + +## [1.12.49] — 2026-06-30 + +### Added +- **SQLite persistence for characters and rehearsals** — character library and script rehearsals are now stored in `config/tts_creator.db` (SQLite) instead of browser IndexedDB; data survives browser-cache clears, works across browsers/devices, and is ready for future migration to PostgreSQL/Supabase +- New REST API: `GET/POST/PUT/DELETE /api/characters/{id}` and `GET/POST/PUT/DELETE /api/rehearsals/{id}` backed by `core/database.py` +- **One-time automatic migration** — on first load, if server DB is empty, existing IndexedDB data is automatically exported and imported to SQLite (characters and rehearsals separately) + +--- + +## [1.12.48] — 2026-06-29 + +### Added +- **Server-side cast persistence** — after every casting chunk and after manual corrections, the full cast (segments + roster) is saved as `Scripts/cast.md` inside the book's server directory (`config/reader_library//Scripts/`); survives browser-cache clears and works across devices/browsers +- **Cross-device restore** — `audiobookOpenCastView` now has a 3-tier restore: (1) in-memory, (2) localStorage, (3) server fetch; whichever fires first wins; server draft is cached into localStorage automatically +- New REST endpoints: `GET/PUT/DELETE /api/reader/docs/{id}/scripts/{name}` and `GET /api/reader/docs/{id}/scripts` (list) + +### Fixed +- **Casting tab didn't restore draft on click** — the "Casting" sidebar nav item called `showReaderView('cast')` which only shows the panel DOM; now calls `audiobookOpenCastView()` which runs the full restore logic + +--- + +## [1.12.47] — 2026-06-29 + +### Added +- **Skeleton loading UI** — the casting feed and "Characters Found" panel now show animated shimmer skeleton rows while waiting for results instead of a blank gray area +- **Version shown instantly** — app version in About page is read from a `` tag immediately on load (no async delay); server fetch confirms it in the background + +### Fixed +- **Progress bar hidden before casting starts** — the 0% bar no longer shows in the initial "Ready to cast" state; it only appears once casting begins +- **VERSION file updated** — the server-side version was stuck at 1.12.37; now correctly reports the current version after container restart + +--- + +## [1.12.46] — 2026-06-29 + +### Added +- **Edit pencil in casting profile** — pencil icon in the top-right of the character header banner; clicking it enables inline editing of all character sheet fields (appearance, personality, history, skills, relationships) with 900ms auto-save; clicking the checkmark exits edit mode +- **Sources in casting profile** — "Quellen im Text" section now appears at the bottom of the character profile; clicking a source page jumps to that page in the Read Aloud reader + +### Fixed +- **Progress bar hidden after casting** — the yellow progress bar and passage counter disappear automatically when the casting run is complete +- **Profil button toggles** — clicking "Profil" when the profile is already open closes it (returns to script); the button label flips between "Profil" and "← Skript" + +--- + +## [1.12.45] — 2026-06-29 + +### Changed +- **Casting character bar** — clicking a character in the casting view's character list no longer hides the feed; instead a compact bar appears above the feed showing the character's avatar, name, and dialogue-line count +- All dialogue rows for the selected character are highlighted in the feed (subtle purple tint) +- **Prev/next navigation** — up/down buttons in the bar scroll the feed to the previous or next line for that character, with a "3 / 76" position counter +- **Search within character lines** — typing in the search box filters to matching lines and scrolls to the first hit; pressing Enter advances to the next match +- **Open profile button** — the "Profil" button in the bar hides the feed and opens the full character sheet (voice assignment, alignment, history, etc.); the back button restores the feed and re-applies highlights +- Re-clicking the same character in the list closes the bar + +--- + +## [1.12.44] — 2026-06-29 + +### Added +- **Character detail in the casting view** — the "Characters Found" list in the Read Aloud casting panel now shows colored letter-circle avatars (matching the Library view colors) instead of small pill chips; clicking any character hides the cast feed and opens their full character detail (header, voice assignment, alignment, appearance, personality, history, skills, relationships) in the feed area; a "← Zurück zum Skript" button at the top restores the feed +- **Voice assignment from casting panel** — Auswählen, Automatisch, and Online suchen voice buttons work inline in the character detail panel; after assigning, the panel refreshes with the new voice + +--- + +## [1.12.43] — 2026-06-29 + +### Fixed +- **Character detail page scrolls properly** — `.lib-char-page` now has a fixed viewport height (`100dvh - 148px`) so the main content panel and the character sidebar each scroll independently; sidebar no longer floats outside or breaks sticky +- **Sidebar clearly separated** — 3 px border + inset box-shadow replaces the thin 1 px line; sidebar is now 240 px wide with more padding +- **Source quote → reader navigation always works** — clicking a source now always navigates to `s-reader` first; if the book is already open it scrolls to that page div (or finds the first sentence on that page via `readerJumpTo`); only shows a "reopen book" toast if neither source is available +- **Auto-assign voice respects German gender terms** — gender detection now handles both English (`female`/`male`) and German (`weiblich`/`männlich`) by checking `startsWith('w')` as female in addition to `startsWith('f')`, preventing female voices from being assigned to male characters whose gender field was written in German + +--- + +## [1.12.42] — 2026-06-29 + +### Changed +- **Character detail is now a full page, not a popup** — clicking a character card opens a two-panel detail page inside the Library section; center pane shows the full character sheet, right sidebar lists all characters in the same production sorted by appearances; "← Alle Charaktere" back button returns to the card grid +- **All character fields are inline-editable** — every text field (name, aliases, archetype, physical, backstory, relationships, etc.) is `contenteditable` with auto-save (900 ms debounce after each keystroke) via `clPut`; the moral alignment bar is now a draggable `` slider +- **Source quotes in character detail are clickable** — clicking a "Quellen im Text" source entry navigates to that page in Read Aloud (scrolls the reader to that page div); shows a toast if the book is not open yet + +--- + +## [1.12.41] — 2026-06-29 + +### Changed +- **Portrait-style character cards** — cards are now vertical with a colored gradient banner at the top holding the 72 px avatar; name + archetype centered below; snippet, alignment bar, and relationship dots fill the body; compact voice row with "Auswahl" / "Auto" at the bottom; grid uses `minmax(200px, 1fr)` so more fit per row +- **Voice assignment moved to top of character detail modal** — the "Stimme" row is now the first thing below the header, so voice picking is always visible without scrolling +- **Quellen (sources) section added at bottom of detail modal** — shows verbatim text quotes with page number and context label from the character sheet generation; gives evidence for each character detail +- **Stronger German language enforcement for character sheet generation** — system prompt now starts with a bilingual ⚠️ SPRACHE / LANGUAGE block listing every field that must be in German; the user-message reminder also now explicitly says no field may be in English + +### Fixed +- Existing character data generated in English can be refreshed by re-running "Cast Characters" from the production header — the language instruction is now strong enough that even stubborn models should comply + +--- + +## [1.12.40] — 2026-06-28 + +### Fixed +- **Character detail modal had transparent background** — `var(--card)` is not defined; replaced with `var(--surface)` and `var(--panel)` throughout the modal + +### Changed +- **Larger fonts and spacing in character detail modal** — field values 15 px, field labels 11 px, section labels 11 px bold; alignment bar 12 px tall; avatar 90 px; alternating panel/surface backgrounds for section rows give clear visual separation +- **Character detail modal fully translated to German** — all labels, section headings, tier names, arc descriptions, alignment descriptions, and button text are now in German (Erscheinung, Persönlichkeit, Geschichte, Fähigkeiten, Beziehungen, Konflikt & Strategie, Geheimnisse & Bogen, Moralische Gesinnung, Stimme zuweisen, etc.) + +--- + +## [1.12.39] — 2026-06-28 + +### Changed +- **Character cards enlarged** — cards are wider (320 px min), font sizes match the rest of the app (15 px name, 13 px body), avatar is 52 px, interaction dots and alignment bar slightly bigger +- **Click a card → full D&D/LARP character sheet modal** — shows all generated fields in labelled sections: Appearance (physical + clothing), Personality (mannerisms + voice pattern), Story (backstory, motivation, fears), Skills & Abilities, Relationships (full text + interaction dots), Conflict & Strategy, Secrets & Arc; moral alignment bar is larger with score and arc label; voice assignment buttons live in the modal too; Edit and Close buttons in the header; clicking the avatar inside the modal uploads a profile picture + +--- + +## [1.12.38] — 2026-06-28 + +### Fixed +- **Characters/Cast showed empty (only "Import" button) after character sheet generation** — `_charRelsHtml` called `.toLowerCase()` on the `relationships` field which the LLM sometimes returns as an array instead of a string, crashing the card renderer mid-`forEach`. Only the toolbar bar was appended; all production groups were silently lost. Added `_libStr()` coercion in `library.js` for all rendered fields, and `_clSanitize()` in `characters-library.js` applied at both first-insert and merge time so stored data is always clean strings going forward. + +--- + +## [1.12.37] — 2026-06-28 + +### Fixed +- **Progress dialog stuck showing "Cancel" after completion** — `.audiobook-overlay { display: flex }` in CSS overrides the `hidden` attribute, so `done()` calling `ov.hidden = true` had no effect. Fixed by using `ov.style.display = 'none'` / `'flex'` directly, same pattern as the cast panel fix. + +### Changed +- **Character sheets now navigates to Library → Characters/Cast on completion** — instead of opening a read-only overlay that requires closing, finishing character sheet generation saves all sheets to the library and immediately takes you to Library → Characters / Cast where you can see the cards with alignment bars, interaction dots, and profile pictures. If results are already cached, clicking "Character sheets" again goes straight there too. + +--- + +## [1.12.36b] — 2026-06-28 + +### Fixed +- **Character sheet descriptions generated in English for German books** — the language instruction was a weak one-liner buried after a long English system prompt, so the LLM defaulted to English. Now the language rule is the very first thing in the system prompt, explicit about every descriptive field ("write all values in {language}"), and also repeated as a short reminder in the user message directly before the passage. Requires **docker restart** (Python change). Cache the new sheets by re-running "Character sheets" — cached results from earlier runs won't change. + +--- + +## [1.12.36] — 2026-06-28 + +### Added +- **Profile picture on character sheets** — each character card in the overlay and in Library → Characters/Cast now has an avatar area. Click it to upload any image (PNG/JPG/WebP). The image is stored as a base64 data URL in the IndexedDB character record (`image` field) and survives re-generation. The letter-avatar falls back when no image is uploaded. Overlay cards show an 80×80 avatar beside the name; library cards replace the coloured initial dot with the photo. + +### Fixed +- **"Passage N failed: Error"** — the generic error message was caused by the JS not reading the HTTP status or response body before throwing. Now shows `HTTP 502: LLM character-sheet generation failed: ` (or JSON parse error if the server returns non-JSON). Also logs the full error to the console for debugging. +- **Context window overflow on early passages** — `csExistingSummary` was listing every missing field for all characters, which grew large quickly and could push the combined prompt over the model's context window. Trimmed to 5 key fields only (physical, backstory, motivation, voice_pattern, relationships). + +--- + +## [1.12.35] — 2026-06-28 + +### Fixed +- **Character sheets used wrong mental model** — the generation treated character discovery as its primary job, starting with an empty roster and counting "found" characters as progress. But the 45 characters are already known from the casting run. Now `csForReader` seeds `csGenerate` with `_audiobook.roster` (all known names from the cast) from passage 1. The LLM sees the full cast on every chunk and focuses on filling in details rather than rediscovering. Progress shows **"X / 45 characters detailed"** (how many have at least one filled field) instead of the misleading "1 characters found". + +--- + +## [1.12.34] — 2026-06-28 + +### Added +- **Alignment bar redesign** — replaced the text-based "◼ Evil → Good ◻" row with a proper gradient box (dark left = evil, light right = good). A white circle with a dark border marks the character's `moral_alignment_score` position. The arc direction arrow (↗ ↘ ↕ →) sits inside the circle and is colour-coded (green = redeeming, orange = descending, purple = complex). Arc note text appears below. +- **Character sheet progress: character counter** — the progress overlay now shows a live "X characters found · Y complete" line below the passage progress bar, updating on every passage. +- **Image prompt button** on each character card — copies a Stable Diffusion / ComfyUI portrait prompt built from physical description, clothing, archetype, gender, and moral alignment to the clipboard. +- **Voice prompt button** on each character card — copies a voice design instruction built from voice_pattern, mannerisms, archetype, gender, and moral tone to the clipboard; ready to paste into Design a Voice. + +### Fixed +- **`[object Object]` in Physical / Relationships fields** — when the LLM returned an array or nested object for a scalar field the value was stored as-is and rendered via `String()` as `[object Object]`. Added `_csStr()` sanitiser that joins arrays with ", " and falls back to `JSON.stringify` for any other non-string value; applied at both initial insert and merge in `csMerge`. + +--- + +## [1.12.33] — 2026-06-28 + +### Fixed +- **Blue box after "Recast Unknown → no unknown found"** — `audiobookCastView()` set `panel.style.display = 'flex'` as an inline style. When `showReaderView('main')` later set `cast.hidden = true`, the inline style won the specificity battle and the panel remained visible as a large empty box. Fix: clear the inline display style on hide, and stop setting redundant inline flex styles (the `.ab-castpanel-inline` CSS class already defines `display:flex; flex-direction:column`). + +--- + +## [1.12.32] — 2026-06-28 + +### Added +- **Alignment bar on character cards** — each character card in Library → Characters / Cast now shows a compact good↔evil gradient bar with a white dot positioned at the character's `moral_alignment_score` (0 = evil / black end, 100 = good / light end). An arc arrow beside it shows the character's trajectory: ↗ (redeems), ↘ (descends toward evil), ↕ (complex arc), → (stable). +- **Most-interacted characters** — below the alignment bar, up to 5 small coloured avatar dots show which other characters in the same production are most-mentioned in this character's relationships field. Each dot's colour matches that character's avatar and shows name + mention count on hover. + +--- + +## [1.12.31] — 2026-06-28 + +### Added +- **Read Aloud submenu** — "Read Aloud" in the sidebar is now an expandable tree with two sub-items: **Reader** (PDF view) and **Casting** (jumps directly to the casting panel for the open book). + +### Fixed +- **Character sheets "passage 1/1" / No characters found** — `generation.js` (which defines `splitTextIntoChunks`) was missing from the production bundle. When the bundle was active, character sheets always fell back to sending the entire book as a single LLM call, which almost always returned nothing for large texts. `generation.js` is now included in the bundle (21 modules, 701 KB). +- **Copy active → Copy selected** — the "Copy active" button on the voice library toolbar now copies the **checked/selected** voices when any rows are checked, and falls back to all active voices only when nothing is selected. Label and toast reflect which mode was used. + +--- + +## [1.12.30] — 2026-06-28 + +### Fixed +- **Audiobook casting lost when reopening a book** — the cast autosave was stored under one global key and validated only by a fingerprint of the extracted text. Reopening a large PDF re-extracted text that drifted slightly, the fingerprint mismatched, and the whole cast was discarded as a "different document". Drafts are now keyed by the **book id**, so reopening the same book always restores its cast regardless of text drift (the text fingerprint remains a fallback for ad-hoc documents). +- **Opening a book dropped into an empty casting panel** — a leftover cast panel from a previous session hijacked the reader via the nav restore logic. Opening a book now reliably lands on the PDF (main) view; the stale-cast restore is suppressed for a fresh open. Click "Cast as Audiobook" to restore that book's saved cast. + +### Added +- **Voice matching from the cast cards** — each character in Library → Characters / Cast now has, beside Pick/Auto, an **Online** button (opens Get a Voice Online on the Fish.audio tab, pre-filled with the character name + detected language) and a **Generate** button (opens Design a Voice pre-filled with the character's gender, language, name, and a voice description built from the sheet). +- **Automatic language detection** — a dependency-free `detectLang()` (script ranges + stop-word frequency for de/en/fr/es/it/pt/nl) auto-fills the document language during character extraction when left on Auto, and drives the language pre-fill for online search / voice design. + +--- + +## [1.12.28] — 2026-06-28 + +### Added +- **SillyTavern character-card compatibility** — import and export characters in the SillyTavern / TavernAI "Character Card" format. + - **Import** accepts V1 (flat JSON), V2 (`chara_card_v2`), and V3 (`chara_card_v3`) cards as `.json`, plus `.png` cards with the JSON embedded in a PNG `tEXt` chunk (`chara`/`ccv3`). A gender heuristic fills the field when the card omits it. Available globally and per-production in Library → Characters / Cast. + - **Export** writes a V2 JSON card per character (universally accepted by SillyTavern, Chub, AI Character Editor, …). The assigned TTS voice is stored under `data.extensions.tts_voice` so a round-trip preserves the casting. + +### Fixed +- **Huge empty box / content pushed to the middle** — when the voice API was slow and the 8-second loader fallback fired, it hid the loading skeleton with `opacity:0` but left the element in the DOM (still occupying ~700px) and caused later cleanup to skip removal. The skeleton is now fully removed from the DOM in every path, and `.sk-hidden` also sets `display:none`. + +--- + +## [1.12.26] — 2026-06-28 + +### Fixed +- **Sidebar blue highlights on wrong items** — "Language Models", "Books", "Stage" were hardcoded `is-active` in HTML and stayed blue even when on a different section. Added CSS rule `[data-nav-section]:not(.active) + .nav-tree .nav-tree-item.is-active` to suppress sub-item highlight whenever the parent section-head is not current. + +### Added +- **Characters / Cast page** — Library → Characters / Cast is now a dedicated production-grouped view instead of redirecting to the generic Characters section. Shows all books/scripts that have characters, with each character displayed as a card containing their character sheet info (archetype, personality snippet), tier badge (Main/Supporting), gender icon, and a voice assignment row with Pick and Auto buttons. +- **Voice picker in character cards** — "Pick" opens a compact inline popup with searchable voice list filtered by character gender. "Auto" assigns the highest-rated unassigned matching-gender voice automatically. + +--- + +## [1.12.25] — 2026-06-28 + +### Added +- **Library skeleton loading** — Books and Theater Plays tabs now show animated placeholder cards while data loads instead of a blank panel. +- **Cast Characters button** in audiobook completion footer — triggers character sheet generation from the casting panel. +- **Characters/Cast nav** in Library now navigates directly to the Characters section. +- **Return to cast audiobook** — navigating back to Read Aloud via the sidebar now restores the active casting panel if a cast session was in progress. + +--- + +## [1.12.24] — 2026-06-28 + +### Fixed +- **Infinite skeleton hang** — if any script failed to load (`ERR_NETWORK_CHANGED`, DNS failure, etc.) the async loader threw, skeleton-removal code never ran, and the UI was stuck for the entire browser session. `init.js` and `nav.js` are now wrapped in try-catch; `_revealApp()` always runs. Added an 8-second hard timeout as an absolute fallback. + +### Performance +- **Bundle enabled by default** — loader now always tries `main.min.js` first (one request instead of 21 individual files). Falls back to per-file loading only if the bundle is missing. +- **Eliminated `/api/version` blocking round trip** — version string is read from the already-loaded `style.css` link tag (synchronous, zero latency). Fetching `/api/version` was the first awaited call and blocked the entire load sequence. +- **JS cache restored** — JS files now use only the version string for cache-busting (not `version-timestamp`). The session timestamp defeated the service worker cache on every reload; now the SW serves JS from cache on subsequent visits. + +--- + +## [1.12.23] — 2026-06-28 + +### Fixed +- **Reader-main-view bleeds through casting panel** — `#s-reader #reader-main-view { display: flex }` overrode the UA `[hidden]→display:none` rule. When the audiobook cast panel is shown and JS sets `reader-main-view.hidden`, the element stayed rendered (as a 34px tall flex strip) and bled into the cast view. Added `[hidden] { display: none }` specificity override. +- **Card collapse chevron invisible** — `.card-chev::before { content: '▾' }` didn't render in all browsers. Switched to `mdi mdi-chevron-down` MDI icon class, consistent with the rest of the UI. +- **Sidebar sub-item highlights showing for non-current sections** — added CSS sibling rule so `is-active` sub-items (Stage, Books, Language Models) only show accent colour when their parent section IS the current page. +- **Background process indicator** — `window.setNavBusy(sectionId, true|false)` now shows an animated dot on the nav item when Read Aloud is playing. + +--- + +## [1.12.22] — 2026-06-28 + +### Fixed +- **`toggleNavTree is not defined`** — sidebar tree toggles can fire before `nav.js` executes on slow/interrupted loads. An inline stub now handles the click immediately (reads/writes `ttsvc_trees` in localStorage, toggles CSS classes directly); `nav.js` overwrites it with the full implementation once loaded. +- **"Saved PDF data missing" dead end** — when a book's source PDF failed to upload at save time (e.g. network interruption), clicking the library card now opens a file picker so the user can re-select the original file. The PDF is re-uploaded to the existing book slot and the book opens immediately. + +--- + +## [1.12.21] — 2026-06-28 + +### Changed +- **Casting feed — progressive expand** — clicking a "N lines hidden" divider now reveals only 20 lines at a time and leaves a new divider for the remainder, so the view stays anchored and the user doesn't lose their place. + +--- + ## [1.12.20] — 2026-06-28 ### Performance diff --git a/VERSION b/VERSION index 00f1401..cd091a1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.20 +1.12.52 diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..00a3399 --- /dev/null +++ b/core/database.py @@ -0,0 +1,213 @@ +"""SQLite persistence layer for characters and rehearsals. + +Single-file database at CONFIG_DIR/tts_creator.db. +Designed for easy migration to PostgreSQL / Supabase later: + - JSON columns map 1-to-1 to JSONB + - TEXT PKs (characters) and INTEGER PKs (rehearsals) both survive + - No SQLite-specific pragmas that would break compat +""" +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + +from core.constants import CONFIG_DIR + +_DB_PATH = CONFIG_DIR / "tts_creator.db" + +_DDL = """ +CREATE TABLE IF NOT EXISTS characters ( + id TEXT PRIMARY KEY, + book TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + voice TEXT, + image TEXT, + sheet TEXT NOT NULL DEFAULT '{}', + analysis TEXT, + created TEXT, + updated TEXT +); +CREATE INDEX IF NOT EXISTS idx_characters_book ON characters(book); + +CREATE TABLE IF NOT EXISTS rehearsals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + script TEXT NOT NULL DEFAULT '', + cast TEXT NOT NULL DEFAULT '{}', + emotions TEXT NOT NULL DEFAULT '{}', + notes TEXT NOT NULL DEFAULT '{}', + ignored TEXT NOT NULL DEFAULT '{}', + hidden_lines TEXT NOT NULL DEFAULT '{}', + backend TEXT NOT NULL DEFAULT '', + narrator_voice TEXT NOT NULL DEFAULT '', + line_index INTEGER NOT NULL DEFAULT 0, + created TEXT, + updated TEXT +); +CREATE INDEX IF NOT EXISTS idx_rehearsals_title ON rehearsals(title); +""" + + +def _open() -> sqlite3.Connection: + _DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(_DB_PATH), check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def _init() -> None: + with _open() as conn: + conn.executescript(_DDL) + + +_init() + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _j(v: Any) -> str: + return json.dumps(v, ensure_ascii=False, default=str) + + +def _row_to_char(row: sqlite3.Row) -> dict: + d = dict(row) + d["sheet"] = json.loads(d.get("sheet") or "{}") + d["analysis"] = json.loads(d.get("analysis") or "null") + return d + + +def _row_to_reh(row: sqlite3.Row) -> dict: + d = dict(row) + d["cast"] = json.loads(d.get("cast") or "{}") + d["emotions"] = json.loads(d.get("emotions") or "{}") + d["notes"] = json.loads(d.get("notes") or "{}") + d["ignored"] = json.loads(d.get("ignored") or "{}") + d["hidden"] = json.loads(d.get("hidden_lines") or "{}") + d["narratorVoice"] = d.pop("narrator_voice", "") + d["lineIndex"] = d.pop("line_index", 0) + d.pop("hidden_lines", None) + return d + + +# ── Characters ──────────────────────────────────────────────────────────────── + +def char_get_all() -> list[dict]: + with _open() as conn: + rows = conn.execute("SELECT * FROM characters ORDER BY book, name").fetchall() + return [_row_to_char(r) for r in rows] + + +def char_get(char_id: str) -> dict | None: + with _open() as conn: + row = conn.execute("SELECT * FROM characters WHERE id=?", (char_id,)).fetchone() + return _row_to_char(row) if row else None + + +def char_put(rec: dict) -> dict: + with _open() as conn: + conn.execute(""" + INSERT INTO characters (id, book, name, tags, voice, image, sheet, analysis, created, updated) + VALUES (:id,:book,:name,:tags,:voice,:image,:sheet,:analysis,:created,:updated) + ON CONFLICT(id) DO UPDATE SET + book=excluded.book, name=excluded.name, tags=excluded.tags, + voice=excluded.voice, image=excluded.image, sheet=excluded.sheet, + analysis=excluded.analysis, updated=excluded.updated + """, { + "id": rec.get("id", ""), + "book": rec.get("book", ""), + "name": rec.get("name", ""), + "tags": rec.get("tags", ""), + "voice": rec.get("voice"), + "image": rec.get("image"), + "sheet": _j(rec.get("sheet", {})), + "analysis": _j(rec.get("analysis")) if rec.get("analysis") is not None else None, + "created": str(rec.get("created", "")), + "updated": str(rec.get("updated", "")), + }) + conn.commit() + return rec + + +def char_delete(char_id: str) -> bool: + with _open() as conn: + conn.execute("DELETE FROM characters WHERE id=?", (char_id,)) + conn.commit() + return True + + +# ── Rehearsals ──────────────────────────────────────────────────────────────── + +def reh_get_all() -> list[dict]: + with _open() as conn: + rows = conn.execute( + "SELECT * FROM rehearsals ORDER BY updated DESC" + ).fetchall() + return [_row_to_reh(r) for r in rows] + + +def reh_get(reh_id: int) -> dict | None: + with _open() as conn: + row = conn.execute("SELECT * FROM rehearsals WHERE id=?", (reh_id,)).fetchone() + return _row_to_reh(row) if row else None + + +def reh_put(rec: dict) -> dict: + """Insert (no id) or full replace (id present). Returns record with id.""" + reh_id = rec.get("id") + row = { + "title": rec.get("title", ""), + "script": rec.get("script", ""), + "cast": _j(rec.get("cast", {})), + "emotions": _j(rec.get("emotions", {})), + "notes": _j(rec.get("notes", {})), + "ignored": _j(rec.get("ignored", {})), + "hidden_lines": _j(rec.get("hidden", {})), + "backend": rec.get("backend", ""), + "narrator_voice": rec.get("narratorVoice", ""), + "line_index": int(rec.get("lineIndex", 0)), + "created": str(rec.get("created", "")), + "updated": str(rec.get("updated", "")), + } + with _open() as conn: + if reh_id: + row["id"] = int(reh_id) + conn.execute(""" + INSERT INTO rehearsals + (id,title,script,cast,emotions,notes,ignored,hidden_lines, + backend,narrator_voice,line_index,created,updated) + VALUES + (:id,:title,:script,:cast,:emotions,:notes,:ignored,:hidden_lines, + :backend,:narrator_voice,:line_index,:created,:updated) + ON CONFLICT(id) DO UPDATE SET + title=excluded.title, script=excluded.script, cast=excluded.cast, + emotions=excluded.emotions, notes=excluded.notes, ignored=excluded.ignored, + hidden_lines=excluded.hidden_lines, backend=excluded.backend, + narrator_voice=excluded.narrator_voice, line_index=excluded.line_index, + updated=excluded.updated + """, row) + conn.commit() + else: + cur = conn.execute(""" + INSERT INTO rehearsals + (title,script,cast,emotions,notes,ignored,hidden_lines, + backend,narrator_voice,line_index,created,updated) + VALUES + (:title,:script,:cast,:emotions,:notes,:ignored,:hidden_lines, + :backend,:narrator_voice,:line_index,:created,:updated) + """, row) + conn.commit() + reh_id = cur.lastrowid + rec = dict(rec) + rec["id"] = reh_id + return rec + + +def reh_delete(reh_id: int) -> bool: + with _open() as conn: + conn.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,)) + conn.commit() + return True diff --git a/routes/characters.py b/routes/characters.py new file mode 100644 index 0000000..731de47 --- /dev/null +++ b/routes/characters.py @@ -0,0 +1,61 @@ +"""Character library REST API — backed by SQLite via core.database. + +Mirrors the IndexedDB API in characters-library.js so the JS swap is mechanical: + GET /api/characters → { characters: [...] } + GET /api/characters/{id} → record | 404 + PUT /api/characters/{id} → upsert, returns record + DELETE /api/characters/{id} → { ok: true } + POST /api/characters/migrate → bulk import from JS (one-time migration) +""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request + +from core.database import char_get_all, char_get, char_put, char_delete + +router = APIRouter() + + +@router.get("/api/characters") +async def characters_list(): + return {"characters": char_get_all()} + + +@router.get("/api/characters/{char_id:path}") +async def characters_get(char_id: str): + rec = char_get(char_id) + if rec is None: + raise HTTPException(404, "Character not found") + return rec + + +@router.put("/api/characters/{char_id:path}") +async def characters_put(char_id: str, request: Request): + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON") + body["id"] = char_id + return char_put(body) + + +@router.delete("/api/characters/{char_id:path}") +async def characters_delete(char_id: str): + char_delete(char_id) + return {"ok": True} + + +@router.post("/api/characters/migrate") +async def characters_migrate(request: Request): + """Accept a batch of character records from the browser's IndexedDB dump.""" + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON") + records = body if isinstance(body, list) else body.get("characters", []) + n = 0 + for rec in records: + if rec.get("id"): + char_put(rec) + n += 1 + return {"imported": n} diff --git a/routes/conversation.py b/routes/conversation.py index 59d6ad8..735b706 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -464,8 +464,18 @@ async def character_sheets(request: Request): if not text: raise HTTPException(400, "No text provided") - lang_hint = f" The text language is {language}; write the sheet in that language." if language else "" + lang_instruction = ( + f"⚠️ SPRACHE / LANGUAGE — OBLIGATORISCH / MANDATORY:\n" + f"The source text is in {language}.\n" + f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n" + f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, " + f"capabilities, backstory, relationships, motivation, fears, mannerisms, voice_pattern, secret, " + f"conflict_style, win_condition, archetype, aliases.\n" + f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n" + f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n" + ) if language else "" system = ( + f"{lang_instruction}" "You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich " "character sheets passage by passage as a book is read. Extract playable, action-oriented sheets " "an actor can use to immediately know how to PLAY the character.\n" @@ -474,7 +484,6 @@ async def character_sheets(request: Request): "blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty " "if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue " "and actions when reasonable, and mark any deduced value with a trailing ' *'.\n" - f"{lang_hint}\n" "For each character output these fields:\n" "- name, aliases\n" "- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n" @@ -510,9 +519,14 @@ async def character_sheets(request: Request): '"inventory":[],"secret":"","conflict_style":"","win_condition":"",' '"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think' ) + lang_reminder = ( + f"⚠️ WICHTIG: Alle beschreibenden Feldwerte MÜSSEN auf {language} geschrieben werden. " + f"Kein einziges beschreibendes Feld darf auf Englisch sein. Nur JSON-Schlüssel bleiben Englisch.\n\n" + ) if language else "" user = ( ("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "") + ("Sheets so far (fill gaps / refine; keep solid facts):\n" + existing + "\n\n" if existing else "") + + lang_reminder + "Passage:\n" + text ) payload: dict = { diff --git a/routes/docker.py b/routes/docker.py index 2dbb2cf..8de1f1e 100644 --- a/routes/docker.py +++ b/routes/docker.py @@ -1,6 +1,7 @@ """Local Docker container management and URL probe endpoints.""" from __future__ import annotations +import asyncio import os from pathlib import Path from urllib.parse import quote @@ -65,15 +66,18 @@ def _container_status(name: str) -> dict: @router.get("/api/local-containers") async def get_local_containers(): sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() - results = [] - for defn in _LOCAL_CONTAINER_DEFS: + + async def _check(defn: dict) -> dict: entry = {k: v for k, v in defn.items()} if sock_ok: - entry.update(_container_status(defn["name"])) + status = await asyncio.to_thread(_container_status, defn["name"]) + entry.update(status) else: entry["status"] = "no_socket" - results.append(entry) - return {"containers": results, "socket_available": sock_ok} + return entry + + results = await asyncio.gather(*[_check(d) for d in _LOCAL_CONTAINER_DEFS]) + return {"containers": list(results), "socket_available": sock_ok} @router.post("/api/local-containers/{name}/start") diff --git a/routes/reader.py b/routes/reader.py index 225a660..aaf58a5 100644 --- a/routes/reader.py +++ b/routes/reader.py @@ -189,3 +189,93 @@ async def reader_get_cover(doc_id: str): if not f.is_file(): raise HTTPException(404, "Cover not found") return FileResponse(str(f), media_type="image/jpeg", headers={"Cache-Control": "max-age=31536000"}) + + +# ── Scripts (cast save/load) ─────────────────────────────────────────────────── +# Saves the LLM-cast result (segments + roster) as a Markdown file under +# //Scripts/cast.md +# so it survives browser-cache clears and works across devices. + +def _scripts_dir(doc_id: str) -> Path: + d = _doc_dir(doc_id) / "Scripts" + d.mkdir(parents=True, exist_ok=True) + return d + +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), + "```", + "", + ] + return "\n".join(lines) + +def _md_to_cast(text: str) -> dict: + """Extract the JSON payload from a fenced Markdown cast file.""" + import re + m = re.search(r"```json\s*\n([\s\S]+?)\n```", text) + if not m: + raise ValueError("No JSON block found in script file") + return json.loads(m.group(1)) + + +@router.get("/api/reader/docs/{doc_id}/scripts") +async def reader_list_scripts(doc_id: str): + sd = _scripts_dir(doc_id) + scripts = [] + for f in sorted(sd.glob("*.md")): + try: + data = _md_to_cast(f.read_text(encoding="utf-8")) + scripts.append({ + "name": f.stem, + "savedAt": data.get("savedAt"), + "segments": len(data.get("segments", [])), + "roster": data.get("roster", []), + }) + except Exception: + scripts.append({"name": f.stem}) + return scripts + + +@router.get("/api/reader/docs/{doc_id}/scripts/{name}") +async def reader_get_script(doc_id: str, name: str): + if not name.replace("-", "").replace("_", "").isalnum(): + raise HTTPException(400, "Bad script name") + f = _scripts_dir(doc_id) / f"{name}.md" + if not f.is_file(): + raise HTTPException(404, "Script not found") + try: + return _md_to_cast(f.read_text(encoding="utf-8")) + except Exception as e: + raise HTTPException(500, str(e)) + + +@router.put("/api/reader/docs/{doc_id}/scripts/{name}") +async def reader_save_script(doc_id: str, name: str, request: Request): + if not name.replace("-", "").replace("_", "").isalnum(): + raise HTTPException(400, "Bad script name") + try: + data = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON") + f = _scripts_dir(doc_id) / f"{name}.md" + f.write_text(_cast_to_md(data), encoding="utf-8") + return {"saved": str(f.relative_to(_LIB))} + + +@router.delete("/api/reader/docs/{doc_id}/scripts/{name}") +async def reader_delete_script(doc_id: str, name: str): + if not name.replace("-", "").replace("_", "").isalnum(): + raise HTTPException(400, "Bad script name") + f = _scripts_dir(doc_id) / f"{name}.md" + if f.is_file(): + f.unlink() + return {"deleted": name} diff --git a/routes/rehearsals_db.py b/routes/rehearsals_db.py new file mode 100644 index 0000000..f3e1534 --- /dev/null +++ b/routes/rehearsals_db.py @@ -0,0 +1,75 @@ +"""Rehearsal library REST API — backed by SQLite via core.database. + +Mirrors the IndexedDB API in rehearser.js: + GET /api/rehearsals → { rehearsals: [...] } + GET /api/rehearsals/{id} → record | 404 + PUT /api/rehearsals/{id} → full replace (id must exist) + POST /api/rehearsals → insert (no id), returns { id } + DELETE /api/rehearsals/{id} → { ok: true } + POST /api/rehearsals/migrate → bulk import from JS IndexedDB dump +""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request + +from core.database import reh_get_all, reh_get, reh_put, reh_delete + +router = APIRouter() + + +@router.get("/api/rehearsals") +async def rehearsals_list(): + return {"rehearsals": reh_get_all()} + + +@router.get("/api/rehearsals/{reh_id:int}") +async def rehearsals_get(reh_id: int): + rec = reh_get(reh_id) + if rec is None: + raise HTTPException(404, "Rehearsal not found") + return rec + + +@router.post("/api/rehearsals") +async def rehearsals_add(request: Request): + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON") + body.pop("id", None) + saved = reh_put(body) + return {"id": saved["id"]} + + +@router.put("/api/rehearsals/{reh_id:int}") +async def rehearsals_put(reh_id: int, request: Request): + if reh_get(reh_id) is None: + raise HTTPException(404, "Rehearsal not found") + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON") + body["id"] = reh_id + reh_put(body) + return {"ok": True} + + +@router.delete("/api/rehearsals/{reh_id:int}") +async def rehearsals_delete(reh_id: int): + reh_delete(reh_id) + return {"ok": True} + + +@router.post("/api/rehearsals/migrate") +async def rehearsals_migrate(request: Request): + """Accept a batch of rehearsal records from the browser's IndexedDB dump.""" + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON") + records = body if isinstance(body, list) else body.get("rehearsals", []) + ids = [] + for rec in records: + saved = reh_put(rec) + ids.append(saved["id"]) + return {"imported": len(ids), "ids": ids} diff --git a/scripts/minify.mjs b/scripts/minify.mjs index 4b51b3f..fabcfe9 100644 --- a/scripts/minify.mjs +++ b/scripts/minify.mjs @@ -22,8 +22,8 @@ mkdirSync(outDir, { recursive: true }); const MAIN = [ 'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser', 'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview', - 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets', - 'characters-library', 'library', + 'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets', + 'characters-library', 'sillytavern', 'library', ].map(n => join(jsDir, n + '.js')); const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n'); diff --git a/server.py b/server.py index 3cab99d..f33c7a9 100644 --- a/server.py +++ b/server.py @@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.middleware.gzip import GZipMiddleware from core.constants import STATIC_DIR, _BufferHandler -from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader +from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db logger = logging.getLogger("uvicorn.error") @@ -98,6 +98,8 @@ app.include_router(docker.router) app.include_router(tts.router) app.include_router(conversation.router) app.include_router(reader.router) +app.include_router(characters.router) +app.include_router(rehearsals_db.router) # ── Static files ────────────────────────────────────────────────────────────── diff --git a/static/index.html b/static/index.html index 0803544..d117662 100644 --- a/static/index.html +++ b/static/index.html @@ -10,6 +10,7 @@ + @@ -26,7 +27,7 @@ - + @@ -63,7 +64,7 @@ opacity: 1; transition: opacity .25s ease; } - #app-skeleton.sk-hidden { opacity: 0; pointer-events: none; } + #app-skeleton.sk-hidden { opacity: 0; pointer-events: none; display: none; } .sk-head { display: flex; align-items: center; gap: 12px; } .sk-icon { width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; } .sk-title { display: flex; flex-direction: column; gap: 7px; } @@ -156,7 +157,15 @@