Llama 4 Scout returns the model output directly as a JSON array in the
'response' field rather than as a plain string or [{"generated_text":"..."}].
The old fallback parsed it as [{"generated_text":""}] (Go JSON ignores
unknown fields) and silently returned an empty string — causing chapter-names
jobs to finish with results:null in the payload despite items_done matching
items_total.
Fix: only accept the generated_text fallback when the value is non-empty;
otherwise return the raw JSON bytes as a string so callers (parseChapterTitlesJSON
etc.) can extract what they need.
Admins can now generate TTS audiobooks chapter-by-chapter and publish
them as standard RSS 2.0 + iTunes podcast feeds that Spotify, Apple
Podcasts, and any podcast app can subscribe to.
Backend:
- POST /api/admin/podcast — creates ai_job (kind=podcast), spawns
goroutine that generates TTS for missing chapters and writes to MinIO
- GET /podcast/{slug}.xml?voice=<id> — public RSS feed with correct
pubDate (from chapters_idx.created) and enclosure length (MinIO stat)
- GET /podcast/audio/{slug}/{n}/{voice} — public audio proxy, 302 to
presigned MinIO URL (no auth required for podcast clients)
- GET /api/admin/podcast/{slug} — list podcast jobs for a book
- Add AudioObjectSize to AudioStore interface backed by MinIO StatObject
- Populate ChapterInfo.Date from chapters_idx.created in ListChapters
UI:
- New /admin/podcast page: book + voice selector, chapter range, live
progress bars, copy-feed-URL button, cancel button, how-to instructions
- /api/admin/podcast SvelteKit proxy (injects admin Bearer token)
- Podcast link added to admin sidebar
Cleanup:
- Delete stray playwright screenshot files from repo root
- Add .playwright-mcp/ to .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Catalogue/cover: rewrite raw scraped cover URLs to /api/cover/{domain}/{slug}
in handleCatalogue so all covers route through the backend proxy; fix broken
cdn.novelfire.net fallback in handleGetCover to read stored URL from PocketBase
- Catalogue/profile: add Svelte 5 onerror handlers on cover <img> tags to show
letter-initial placeholder when image fails to load
- Library page: read ?status URL param to initialise activeShelf tab on load so
/books?status=reading correctly pre-selects the Reading tab
- Sessions: filter bot/tool user-agents (curl, python, wget, etc.) and debug-IP
sessions from listUserSessions display; also purge them in pruneStaleUserSessions
- Profile: show email under username, quick stats chips (streak/chapters/completed)
in header, reading count on Library row, dedicated Sign out row, history covers
routed through /api/cover proxy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Content visibility:
- Add `visibility` field to books ("public" | "admin_only"); new migration
backfills all existing scraped books to admin_only
- Meilisearch: add visibility as filterable attribute; catalogue/search
endpoints filter to public-only for non-admin requests
- Admin users identified by bearer token bypass the filter and see all books
- All PocketBase discovery queries (trending, recommended, recently-updated,
audio shelf, discover, subscription feed) now filter to visibility=public
- New scraped books default to admin_only; WriteMetadata preserves existing
visibility on PATCH (never overwrites)
Author submission:
- POST /api/admin/books/submit — creates a public book with submitted_by
- PATCH /api/admin/books/{slug}/publish / unpublish — toggle visibility
- SvelteKit proxies: /api/admin/books/[slug]/publish|unpublish
- /api/books/[slug] endpoint for admin book lookup
Frontend:
- backendFetchAdmin() helper sends admin token on any path
- Catalogue server load uses admin fetch when user is admin
- /submit page: author submission form with genre picker and rights assertion
- "Publish" nav link shown to all logged-in users
- Admin catalogue-tools: visibility management panel (load book by slug, toggle)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove non-root user from pocketbase Docker image; the existing pb_data
volume was created by the previous root-running image so files are owned
by root — running as a non-root appuser caused an immediate permission
error and container exit
- Increase healthcheck retries to 10 and add start_period=30s so migrations
have time to run on first boot before liveness checks begin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add wget to pocketbase Alpine image so the docker-compose healthcheck
(wget http://localhost:8090/api/health) can actually run
- Fix saveIfAbsent calling itself instead of app.Save(c) — was an
infinite recursion that would stack-overflow on a fresh install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each collection creator now skips creation if the collection already exists,
so deploying to an existing prod install no longer requires running
`migrate history-sync` manually. Migration 2 (missing fields) still applies
as normal since those fields genuinely don't exist yet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a custom PocketBase binary (cmd/pocketbase) that embeds PocketBase as a
Go framework with version-controlled migrations, replacing the fragile 419-line
shell script. Migrations apply automatically on every `serve` startup.
Migration 1 (20260414000001): full baseline schema — all 21 collections, both
indexes on chapters_idx, and initial superuser creation from env vars.
Migration 2 (20260414000002): adds three fields found in code but missing from
the old script — books.rating, app_users.notify_new_chapters_push,
book_comments.chapter.
docker-compose: pocketbase service now uses the custom kalekber/libnovel-pocketbase
image; pb-init one-shot container removed (migrations replace it entirely).
Existing installs: run `migrate history-sync` once to mark migration 1 as done,
then restart — migration 2 will apply the three previously missing fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds BACKEND_ADMIN_TOKEN env var (set in Doppler) as a required Bearer
token for every admin route. Also fixes PocketBase filter injection in
notification queries and wires BACKEND_ADMIN_TOKEN through docker-compose
to both backend and ui services. Includes CLAUDE.md for AI assistant guidance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Translation content is immutable once generated — add Cache-Control: public,
max-age=3600, stale-while-revalidate=86400 to handleTranslationRead so the
browser and any CDN reuse the response without hitting MinIO on repeat views.
Forward the header through the SvelteKit /api/translation/[slug]/[n] proxy
which previously stripped it by constructing a bare Content-Type-only Response.
Three bugs:
1. +page.server.ts returned an unawaited Promise — SvelteKit awaits it on
the server anyway so data.jobs arrived as a plain AIJob[] on the client,
not a Promise. The $effect calling .then() on an array silently failed,
leaving jobs=[] and the table empty. Fixed by awaiting in the load fn.
2. +page.svelte $effect updated to assign data.jobs directly (plain array)
instead of calling .then() on it.
3. handlers_textgen.go: final UpdateAIJob (payload+status write) used
r.Context() which is cancelled when the SSE client disconnects. If the
browser navigated away mid-job, results were silently dropped and the
payload stayed as the initial {pattern} stub with no results array.
Fixed by using context.Background() for the final write, matching the
pattern already used in handlers_image.go.
- Add bookstore.NotificationStore interface and wire it directly to *storage.Store
in Dependencies, bypassing the Asynq wrapper that caused Producer.(*storage.Store)
to fail with a 500 on every notification endpoint when Redis is configured
- Replace s.deps.Producer.(*storage.Store) type assertion in all 5 notification
handlers with s.deps.NotificationStore (nil-safe, always works)
- Fix PocketBase filter single-quote bug in ListNotifications, ClearAllNotifications,
and MarkAllNotificationsRead (PocketBase requires double quotes for string values)
The SSE (non-async) chapter-names handler streamed results to the client
but never wrote them into the PocketBase job payload — only the initial
{pattern} stub was stored. The Review button then fetched the job and
found no results, showing 'No results found in this job's payload.'
Fix: accumulate allResults across batches (same as the async handler) and
write the full {pattern, slug, results:[...]} payload when marking done.
- Replace bell dropdown with full-screen NotificationsModal (mirrors SearchModal pattern)
- Notifications visible to all logged-in users (not just admin)
- Admin users excluded from new-chapter fan-out (dedup vs Scrape Complete notification)
- Users with notify_new_chapters=false opted out of new-chapter in-app notifications
- Toggle in profile page to enable/disable in-app new-chapter notifications
- PATCH /api/profile endpoint to save notification preferences
- User-facing /notifications page (admin redirects to /admin/notifications)
- Service worker (src/service-worker.ts) handles push events and
notification clicks, navigating to the book page on tap
- Web app manifest (manifest.webmanifest) linked in app.html
- Profile page: push notification toggle (subscribe/unsubscribe)
using the browser Notification + PushManager API with VAPID
- API route POST/DELETE /api/push-subscription proxies to backend
- Go backend: push_subscriptions PocketBase collection storage
methods (SavePushSubscription, DeletePushSubscription,
ListPushSubscriptionsByBook) in storage/store.go
- handlers_push.go: GET vapid-public-key, POST/DELETE subscription
- webpush package: VAPID-signed sends via webpush-go, SendToBook
fans out to all users who have the book in their library
- Runner fires push to subscribers whenever ChaptersScraped > 0
after a successful book scrape
- Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars
- domain.ScrapeResult gets a Slug field; orchestrator populates it
Demote per-book and per-chapter-list-page Info logs to Debug — these
fire hundreds of times per catalogue run and drown out meaningful signals:
- orchestrator: RunBook starting (per book)
- metadata saved (per book)
- chapter list fetched (per book)
- scraping chapter list page N (per pagination page per book)
The 'book scrape finished' summary log (with scraped/skipped/errors
counters) remains at Info — it is the useful signal per book.
Two bugs fixed in runScrapeTask / runCatalogueTask:
1. FinishScrapeTask was called with the task's own context, which is
already cancelled when the task is stopped. The PATCH to PocketBase
failed silently, leaving all counters at their initial zero values.
Fix: use a fresh context.WithTimeout(Background, 15s) for the write.
2. BooksFound was double-counted: RunBook already sets BooksFound=1 on
success, but the accumulation loop added an extra +1 unconditionally,
reporting 2 books per successful scrape.
Fix: result.BooksFound += bookResult.BooksFound (drop the + 1).
- Add `archived` bool to domain.BookMeta, pbBook, and Meilisearch bookDoc
- ArchiveBook / UnarchiveBook patch the PocketBase record; ListBooks filters
archived=false so hidden books disappear from all public responses
- Meilisearch: add `archived` as a filterable attribute; Search and Catalogue
always prepend `archived = false` to exclude archived books from results
- DeleteBook permanently removes the PocketBase record, all chapters_idx rows,
MinIO chapter objects, cover image, and the Meilisearch document
- New BookAdminStore interface with ArchiveBook, UnarchiveBook, DeleteBook
- Admin HTTP endpoints: PATCH /api/admin/books/{slug}/archive|unarchive,
DELETE /api/admin/books/{slug}
- PocketBase schema: archived field added to live pb.libnovel.cc and to
pb-init-v3.sh (both create block and add_field migration)
Split chapter text into ~1000-char sentence-boundary chunks before sending
to kokoro-fastapi. Each chunk is generated individually and the raw MP3 bytes
are concatenated. This prevents the EOF / timeout failures that occur when
the server receives a very large single request (e.g. a full PDF 'Full Text'
chapter). chunkText() breaks at sentence endings (. ! ? newlines) to preserve
natural speech flow.
- parsePDF: return all text as single 'Full Text' chapter (admin splits manually)
- parseEPUB: fix chapter numbering to use sequential counter not spine index
- Remove dead code: chaptersFromBookmarks, cleanChapterText, extractChaptersFromText, chapterHeadingRE; drop pdfcpu alias and regexp imports
- Backend: POST /api/admin/books/:slug/split-chapters endpoint — splits text on '---' dividers, optional '## Title' headers, writes chapters via WriteChapter
- UI: admin panel now shows for all admin users regardless of source_url; chapter split tool shown when book has single 'Full Text' chapter, pre-fills from MinIO content
Three fixes to PDF chapter extraction quality:
1. Page ordering: parse page number from pdfcpu filename (out_Content_page_N.txt)
instead of using lexicographic sort index — fixes chapters bleeding into each
other (e.g. Prologue text appearing inside Chapter 1).
2. Windows-1252 chars: map bytes 0x91-0x9F to proper Unicode (curly quotes U+2018/
U+2019/U+201C/U+201D, em-dash U+2014, etc.) instead of raw Latin-1 control
bytes that rendered as ◆ in the browser.
3. Chapter header cleanup: skip the first page of each bookmark range (decorative
title art page) and strip any run-on title fragment at the start of the first
body page (e.g. 'for New Journeys!I stood atop...' → 'I stood atop...'). The
remaining sentence truncation is a fundamental limitation of this PDF's
PUA-encoded body font (C2_1/Literata) — those glyphs cannot be decoded without
the publisher's private ToUnicode mapping.
Use PDF outline (bookmarks) for chapter titles and page ranges, then
extract text from per-page content streams via pdfcpu ExtractContent.
This avoids the indefinite hang caused by dslipak/pdf trying to resolve
custom font ToUnicode CMaps on publisher PDFs.
- parsePDF: decrypt → ExtractContent → Bookmarks → chaptersFromBookmarks
- chaptersFromBookmarks: flatten bookmark tree, skip front/back matter
(Cover, Insert, Title Page, Copyright, Appendix), assign page ranges
- extractTextFromContentStream: handle TJ arrays (concat literal strings,
skip hex glyph arrays and kerning numbers) + single Tj strings
- Falls back to paragraph-splitting when no bookmarks present
- Build verified; test PDF produces 9 chapters with proper titles
- parsePDF function restored in import.go (body was orphaned outside function)
- ParseImportFile() called at upload time with 3-min timeout; chapters stored as JSON in MinIO
- runner.go: prefer ChaptersKey path (read pre-parsed JSON) over BookImport.Import()
- ImportChapterStore interface added; store wired in runner/main.go
- HeartbeatTask and ReapStaleTasks now include import_tasks collection
- parseImportTask now returns ChaptersKey in domain.ImportTask
- asynq_runner.go handleImportTask passes ChaptersKey
- pb-init-v3.sh: chapters_key field added to import_tasks schema
- parsePDF now attempts to strip encryption via pdfcpu (empty user password)
before handing bytes to dslipak/pdf — fixes '256-bit encryption key' error
on publisher PDFs that use owner-only encryption (copy/print restrictions)
- Add pdfcpu v0.11.1 as direct dependency (was already indirect)
- docker-compose.yml minio-init: add 'imports' and 'translations' buckets
so a fresh deploy creates all required buckets
- Add ImportFileStore interface to bookstore package
- Add ImportFileStore field to backend.Dependencies
- Wire ImportFileStore: store in cmd/backend/main.go
- handlers_import.go: use s.deps.ImportFileStore.PutImportFile instead of
broken s.deps.Producer.(*storage.Store) type assertion (fails when Asynq active)
- pb-init-v3.sh: add import_tasks and notifications collection definitions
- Import task: persist object_key, author, cover_url, genres, summary,
book_status in PocketBase so the runner can fetch the file and write
book metadata on completion
- Runner poll mode: pass task.ObjectKey instead of empty string
- Runner: write BookMeta + UpsertBook in Meilisearch after chapter ingest
so imported books appear in catalogue and search
- Import UI: add author, cover URL, genres, summary, status fields; add
AI tasks panel (chapter names, description, image gen, tagline) after
import completes; add AI tasks button on each done task in the list
- Admin nav: add Notifications entry to sidebar (all 5 locales)
- Logout: delete user_sessions row on sign-out so sessions don't
accumulate as phantoms after each login/logout cycle
- docker-compose.yml: BODY_SIZE_LIMIT=52428800 (50MB) on UI service
— adapter-node was rejecting PDFs >512KB with 'Content-length exceeds limit'
- storage/import.go: add AnalyzeFile() public function using real PDF/EPUB parsers
- handlers_import.go: analyzeImportFile() now calls storage.AnalyzeFile() instead
of the file-size stub, so chapter count is accurate in the preview
- NewBookImporter now takes *Store instead of raw *minio.Client (same package access)
- Runner Dependencies gains ChapterIngester interface; storeImportedChapters no-op removed
- store.IngestChapters is now actually called after PDF/EPUB extraction
- BookImport and ChapterIngester wired in runner main.go
- CreateImportTask interface gains initiatorUserID param; threaded through store/asynq/handler
- domain.ImportTask gains InitiatorUserID field; parseImportTask populates it
- Runner notifies initiator (falls back to 'admin' when empty)
- UI: import task list polls every 3s while any task is pending/running
- UI: label[for] + input[id] fix for a11y warnings in admin import page
- Import page: add file upload with review step before committing
- Backend: add analyze endpoint to preview chapters before import
- Add notifications collection + API for admin alerts
- Add bell icon in header with notification dropdown (admin only)
- Runner creates notification on import completion
- Notifications link to /admin/import for easy review
- Add ImportTask/ImportResult types to domain.go
- Add TypeImportBook to asynqqueue for task routing
- Add CreateImportTask to producer and storage layers
- Add ClaimNextImportTask/FinishImportTask to Consumer interfaces
- Add import task handling to runner (polling + Asynq handler)
- Add BookImporter interface to bookstore for PDF/EPUB parsing
- Add backend API endpoints: POST/GET /api/admin/import
- Add SvelteKit UI at /admin/import with task list
- Add nav link in admin layout
Note: PDF/EPUB parsing is a placeholder - needs external library integration.
Cloudflare Workers AI changed the API for flux-2-dev, flux-2-klein-4b,
and flux-2-klein-9b to require multipart/form-data (instead of JSON) and
now returns {"image":"<base64>"} instead of raw PNG bytes.
- Add requiresMultipart() helper for the three FLUX.2 models
- callImageAPI builds multipart body for those models, JSON for others
- Parse {"image":"<base64>"} JSON response; fall back to raw bytes for legacy models
- Use "steps" field name (not "num_steps") in multipart forms per CF docs
- Book page: capture and display actual backend error message instead of blank 'Error'
- Admin layout: SVG icons, active highlight, divider between nav sections
- Scrape page: status filter pills with counts, text + status combined search
- Audio page: status filter pills, cancel jobs, retry failed jobs, mobile cards for cache tab
- Translation page: status filter pills (incl. cancelled), cancel + retry jobs, mobile cancel/retry cards, i18n for all labels
- AI Jobs page: fix concurrent cancel (Set instead of single slot), per-job cancel errors inline, full mobile card layout, i18n title/heading
- Text-gen page: tagline editable input + copy, warnings copy, i18n title/heading
- Book page: chapter cover Save button, audio monitor link, currentShelf pre-populated from server
- pocketbase.ts: add getBookShelf(), shelf field on UserLibraryEntry
- New API route: POST /api/admin/translation/bulk (proxy for translation retry)
- i18n: 15 new admin_translation_*, admin_ai_jobs_*, admin_text_gen_* keys across all 5 locales
speechSynthesis is silently muted on iOS Safari and Chrome Android after
the audio session ends (onended), so chapter announcements never played.
Fix:
- Add GET /api/tts-announce backend endpoint: streams a short TTS clip
for arbitrary text without MinIO caching (backend/internal/backend/)
- Add GET /api/announce SvelteKit proxy route (no paywall)
- Add announceNavigatePending/announcePendingSlug/announcePendingChapter
to AudioStore
- Rewrite onended announce branch: sets audioStore.audioUrl to the
announcement clip URL so the persistent <audio> element plays it;
the next onended detects announceNavigatePending and navigates
- 10s safety timeout in case the clip fails to load/end
- Add POST /api/admin/image-gen/async: fire-and-forget image generation
that stores the result (base64) in an ai_job payload and returns 202
immediately — no more 60-120s blocking on FLUX models
- Add POST /api/admin/text-gen/description/async: same pattern for book
description generation
- Register both new routes in server.go
- Rewrite image-gen admin page to use the async path (submit → redirect
to AI Jobs for monitoring)
- Extend ai-jobs page with Review panels for image-gen jobs (show image,
Save as cover / Download / Discard) and description jobs (diff old vs
new, editable textarea, Apply / Discard)
- Add POST /api/admin/text-gen/chapter-names/async backend endpoint: fire-and-forget,
returns job_id immediately (HTTP 202), runs batch generation in background goroutine,
persists proposed titles in ai_job payload when done
- Register new route in server.go alongside existing SSE endpoint (backward compat)
- Add SvelteKit proxy at /api/admin/text-gen/chapter-names/async
- Add SvelteKit proxy for GET /api/admin/ai-jobs/[id] (job detail with payload)
- Add Review button on ai-jobs page for done chapter-names jobs; inline panel shows
editable title table (old to new) with Apply All button that POSTs to chapter-names/apply
- Backend: add GET /api/audio-preview/{slug}/{n} — generates first ~1800-char
chunk via CF AI so playback starts immediately; full chapter cached in MinIO
- Frontend: replace CF AI spinner with preview blob URL + background swap to
full presigned URL when runner finishes, preserving currentTime
- AudioPlayer: isPreview state + 'preview' badge in mini-bar during swap
- pocketbase.ts: fix 403 on stale token — reduce TTL to 50 min + retry once
on 401/403 with forced re-auth (was cached 12 h, PB tokens expire in 1 h)
- Footer build time now rendered in user's local timezone via toLocaleString()
- New `ai_jobs` PocketBase collection tracks all long-running AI tasks
(batch-covers, chapter-names) with status, progress, and cancellation
- `handlers_aijobs.go`: GET/cancel endpoints for ai_jobs; centralised
cancel registry (moved from handlers_catalogue)
- Batch-covers and chapter-names SSE handlers now create/resume ai_job
records, support from_item/to_item ranges, and resume from items_done
on restart via job_id
- New `POST /api/admin/image-gen/auto-prompt`: generates an image prompt
from book description (cover) or chapter title (chapter) via LLM
- image-gen page: "Auto-prompt" button calls auto-prompt API when a slug
is selected; falls back gracefully if TextGen not configured
- text-gen chapter-names: from/to chapter range inputs + job ID display
- catalogue-tools batch-covers: from/to item range + resume job ID input
- pb-init-v3.sh: adds ai_jobs collection (idempotent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CF Workers AI has a ~4MB JSON body limit. Large cover images base64-encoded
can easily exceed this, causing Cloudflare to return a 502 Bad Gateway.
Added resizeRefImage() which scales the reference down so its longest side
≤ 768px (nearest-neighbour, re-encoded as JPEG) before passing it to the
GenerateImageFromReference call. Images already within the limit pass through
unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Llama 4 Scout returns `result.response` as an array of objects
[{"generated_text":"..."}] instead of a plain string. Decode into
json.RawMessage and try both shapes; fall back to generated_text[0].
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split chapter-name LLM requests into 100-chapter batches and stream
results back as SSE so large books (e.g. Shadow Slave: 2916 chapters)
never time out or truncate. Frontend shows live batch progress inline
and accumulates proposals as they arrive.
chapters_idx was missing created/updated columns (never defined in the
PocketBase schema), causing PocketBase to return 400 for any query
sorted by -created. recentlyUpdatedBooks() uses this sort.
- Add created date field to chapters_idx schema in pb-init-v3.sh
(also added via add_field for existing installations)
- Add idx_chapters_idx_created index for sort performance
- Set created timestamp on first insert in upsertChapterIdx so new
chapters are immediately sortable; existing records retain empty created
and will sort to the back (acceptable — only affects home page recency)
- Fix upsertChapterIdx race: use conflict-retry pattern (mirrors WriteMetadata)
so concurrent goroutines don't double-POST the same chapter number
- Add DeduplicateChapters to BookWriter interface and Store implementation;
keeps the latest record per (slug, number) and deletes extras
- Wire POST /api/admin/dedup-chapters/{slug} handler in server.go
The aura-2-en model enforces a hard 2 000-character limit per request.
Chapters routinely exceed this, producing 413 errors.
GenerateAudio now splits the stripped text into ≤1 800-char chunks at
paragraph → sentence → space → hard-cut boundaries, calls the API once
per chunk, and concatenates the MP3 frames. Callers (runner, streaming
handler) are unchanged. StreamAudioMP3/WAV inherit the fix automatically
since they delegate to GenerateAudio.
- Default max_tokens to 4096 for chapter-names so large chapter lists
are not cut off mid-JSON by the model's token limit
- Rewrite system prompt to clarify placeholder semantics ({n} = number,
{scene} = scene hint) and explicitly forbid echoing the number inside
the title field — prevents "Chapter 1 - 1: ..." style duplications
- UI: surface raw_response in the error area when chapters:[] is returned
so the admin can see what the model actually produced
Adds backend handlers and SvelteKit UI for an admin text generation tool.
The tool lets admins propose and apply AI-generated chapter titles and book
descriptions using Cloudflare Workers AI (12 LLM models, model selector shared
across both tabs).