Compare commits

..

14 Commits

Author SHA1 Message Date
Admin
9c79fd5deb feat: AI job tracking, range support, auto-prompt, and resume
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 55s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 3m21s
Release / Docker / runner (push) Successful in 2m41s
Release / Upload source maps (push) Successful in 3m44s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m22s
- 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>
2026-04-05 15:40:52 +05:00
Admin
7aad42834f fix: GlitchTip source map upload flow; add AGENTS.md
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 54s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 3m7s
Release / Docker / runner (push) Successful in 2m58s
Release / Upload source maps (push) Successful in 1m56s
Release / Docker / ui (push) Successful in 2m17s
Release / Gitea Release (push) Successful in 47s
Add 'releases new' and 'releases finalize' steps around sourcemaps
upload in release.yaml — without an explicit 'releases new' call,
GlitchTip creates the release entry but associates 0 files.

Add root AGENTS.md (picked up by Claude, Cursor, Copilot, etc.) with
full project context: stack, repo layout, Gitea CI conventions,
GlitchTip DSN/upload flow, infra, and iOS notes.
2026-04-05 14:52:41 +05:00
Admin
15a31a5c64 fix: chapter menu drawer — constrain width on desktop, fix scroll
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / backend (push) Successful in 3m12s
Release / Docker / runner (push) Successful in 3m41s
Release / Upload source maps (push) Successful in 2m13s
Release / Docker / ui (push) Successful in 2m16s
Release / Gitea Release (push) Successful in 36s
On md+ screens the drawer is now right-aligned and 320px wide (w-80)
instead of full-viewport-width. The sticky header is pulled out of the
scroll container so it never scrolls away, and overflow-y-auto is
applied only to the chapter list itself so both mobile and desktop can
scroll through long chapter lists.
2026-04-05 14:35:03 +05:00
Admin
4d3b91af30 feat: add Grafana Faro RUM, fix dashboards, add Grafana to admin nav
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 1m9s
Release / Docker / backend (push) Successful in 3m14s
Release / Docker / runner (push) Successful in 4m7s
Release / Upload source maps (push) Successful in 2m11s
Release / Docker / ui (push) Successful in 2m23s
Release / Gitea Release (push) Successful in 40s
- Add @grafana/faro-web-sdk to UI; wire initializeFaro in hooks.client.ts
  gated on PUBLIC_FARO_COLLECTOR_URL (no-op in dev)
- Add Grafana Alloy service (faro.receiver) to homelab compose;
  Faro endpoint → alloy:12347 (faro.libnovel.cc via cloudflared)
- Add PUBLIC_FARO_COLLECTOR_URL env var to docker-compose.yml UI service
- Add Web Vitals dashboard (web-vitals.json): LCP/INP/CLS/TTFB/FCP p75
  stats + LCP/TTFB time-series + Faro exception logs from Loki
- Fix runner.json: strip libnovel_ prefix from all metric names
- Fix backend.json: replace 5 dead http_client_* panels with
  spanmetrics-based equivalents (Request Rate by Span Name + Latency
  by Span Name p95)
- Fix OTel collector: add service.telemetry.metrics.address: 0.0.0.0:8888
  so Prometheus can scrape collector self-metrics
- Add Grafana link to admin nav external tools; add admin_nav_grafana
  message key to all 5 locale files; recompile paraglide
2026-04-05 12:58:16 +05:00
Admin
eb8a92f0c1 ci: fix GlitchTip project slug (ui, not libnovel-ui)
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 5m16s
Release / Docker / runner (push) Successful in 4m32s
Release / Upload source maps (push) Successful in 2m8s
Release / Docker / ui (push) Successful in 2m45s
Release / Gitea Release (push) Successful in 59s
2026-04-05 12:27:02 +05:00
Admin
fa2803c164 ci: re-enable GlitchTip source map upload job for UI releases
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 56s
Release / Docker / runner (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
2026-04-05 12:26:23 +05:00
Admin
787942b172 fix: split GLITCHTIP_DSN into per-service vars (backend/runner/ui) 2026-04-05 12:17:03 +05:00
Admin
cb858bf4c9 chor: delete all ios-ux skill 2026-04-05 11:51:23 +05:00
Admin
4c3c160102 fix: downscale reference image before CF AI img2img to avoid 502 payload limit
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>
2026-04-05 11:40:37 +05:00
Admin
37deac1eb3 fix: resolve all svelte-check warnings and errors (0 errors, 0 warnings)
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 40s
Release / Docker / backend (push) Successful in 4m54s
Release / Docker / runner (push) Successful in 56s
Release / Docker / ui (push) Successful in 2m9s
Release / Gitea Release (push) Successful in 48s
2026-04-05 11:03:23 +05:00
Admin
6f0069daca feat: catalogue enrichment — tagline, genres, warnings, quality score, batch covers
Backend (handlers_catalogue.go):
- POST /api/admin/text-gen/tagline — 1-sentence marketing hook
- POST /api/admin/text-gen/genres + /apply — LLM genre suggestions, editable + persist
- POST /api/admin/text-gen/content-warnings — mature theme detection
- POST /api/admin/text-gen/quality-score — 1–5 description quality rating
- POST /api/admin/catalogue/batch-covers (SSE) — generate covers for books missing one
- POST /api/admin/catalogue/batch-covers/cancel — cancel via in-memory job registry
- POST /api/admin/catalogue/refresh-metadata/{slug} (SSE) — description + cover refresh

Frontend:
- text-gen: 4 new tabs (Tagline, Genres, Warnings, Quality) with book autocomplete
- image-gen: localStorage style presets (save/apply/delete named prompt templates)
- catalogue-tools: new admin page with batch cover SSE progress + cancel
- admin nav: "Catalogue Tools" link added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:52:38 +05:00
Admin
0fc30d1328 fix: handle Llama 4 Scout array response shape in CF AI text decoder
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>
2026-04-05 10:28:14 +05:00
Admin
40151f2f33 feat: enhance admin panel on book page — prompts, img2img, SSE chapter names
Some checks failed
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Failing after 35s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 31s
Release / Docker / backend (push) Successful in 2m21s
Release / Docker / runner (push) Successful in 2m23s
Release / Gitea Release (push) Has been skipped
- Cover generation: editable prompt (pre-filled from title+summary),
  img2img toggle to use existing cover as reference, Full editor link
- Chapter cover: editable prompt textarea
- Description: instructions input field, Full editor link
- Chapter names: editable pattern field, SSE streaming with live batch
  progress, inline-editable title proposals, batch warning display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:45:12 +05:00
Admin
ad2d1a2603 feat: stream chapter-name generation via SSE batching
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m16s
Release / Gitea Release (push) Successful in 36s
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.
2026-04-05 00:32:18 +05:00
57 changed files with 4264 additions and 529 deletions

View File

@@ -136,57 +136,71 @@ jobs:
cache-to: type=inline
# ── ui: source map upload ─────────────────────────────────────────────────────
# Commented out: GlitchTip project/auth token needs to be recreated after
# the GlitchTip DB wipe. Re-enable once GLITCHTIP_AUTH_TOKEN is updated.
# upload-sourcemaps:
# name: Upload source maps
# runs-on: ubuntu-latest
# needs: [check-ui]
# defaults:
# run:
# working-directory: ui
# steps:
# - uses: actions/checkout@v4
#
# - uses: actions/setup-node@v4
# with:
# node-version: "22"
# cache: npm
# cache-dependency-path: ui/package-lock.json
#
# - name: Install dependencies
# run: npm ci
#
# - name: Build with source maps
# run: npm run build
#
# - name: Download glitchtip-cli
# run: |
# curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
# -o /usr/local/bin/glitchtip-cli
# chmod +x /usr/local/bin/glitchtip-cli
#
# - name: Inject debug IDs into build artifacts
# run: glitchtip-cli sourcemaps inject ./build
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: libnovel-ui
#
# - name: Upload source maps to GlitchTip
# run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: libnovel-ui
upload-sourcemaps:
name: Upload source maps
runs-on: ubuntu-latest
needs: [check-ui]
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build with source maps
run: npm run build
- name: Download glitchtip-cli
run: |
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
-o /usr/local/bin/glitchtip-cli
chmod +x /usr/local/bin/glitchtip-cli
- name: Inject debug IDs into build artifacts
run: glitchtip-cli sourcemaps inject ./build
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Create GlitchTip release
run: glitchtip-cli releases new ${{ gitea.ref_name }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Upload source maps to GlitchTip
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Finalize GlitchTip release
run: glitchtip-cli releases finalize ${{ gitea.ref_name }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
# ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui:
name: Docker / ui
runs-on: ubuntu-latest
needs: [check-ui]
needs: [check-ui, upload-sourcemaps]
steps:
- uses: actions/checkout@v4
@@ -261,7 +275,7 @@ jobs:
release:
name: Gitea Release
runs-on: ubuntu-latest
needs: [docker-backend, docker-runner, docker-ui, docker-caddy]
needs: [docker-backend, docker-runner, docker-ui, docker-caddy, upload-sourcemaps]
steps:
- uses: actions/checkout@v4
with:

View File

@@ -1,156 +0,0 @@
---
name: ios-ux
description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work.
compatibility: opencode
---
# iOS UI/UX Skill — LibNovel
Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules.
---
## Screenshot Review Process
When the user provides a screenshot of the app:
1. **Analyze first** — identify specific UI/UX issues across these categories:
- Visual hierarchy and spacing
- Typography (size, weight, contrast)
- Color and material usage
- Animation and interactivity gaps
- Accessibility problems
- Deprecated or non-native patterns
2. **Present a numbered list** of suggested improvements with brief rationale for each.
3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?"
4. Apply only what the user confirms.
---
## Design System
### Colors & Materials
- **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs.
- **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`.
- **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.50.6)` overlay. Never use a flat solid black background.
- **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player).
- **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors.
### Typography
- Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark).
- Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families.
- Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier.
- Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata.
### Spacing & Layout
- Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons.
- Prefer 1620 pt horizontal padding on full-width containers; 12 pt for compact inner elements.
- Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI.
- Corner radii: 1214 pt for cards/chips, 10 pt for small badges, 2024 pt for large cover art.
---
## Animation Rules
### Spring Animations (default for all interactive transitions)
- Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions.
- Recommended defaults:
- Interactive elements: `response: 0.3, dampingFraction: 0.7`
- Entrance animations: `response: 0.450.5, dampingFraction: 0.7`
- Quick snappy feedback: `response: 0.2, dampingFraction: 0.6`
- Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays).
### SF Symbol Transitions
- Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.).
- Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators).
- Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing).
- Use `.symbolEffect(.pulse)` for error/warning states that need attention.
### Repeating Animations
- Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains.
- Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`.
---
## Haptic Feedback
Add `UIImpactFeedbackGenerator` to every user-initiated interactive control:
- `.light` — toggle switches, selection chips, secondary actions, slider drag start.
- `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations.
- `.heavy` — destructive actions (only if no confirmation dialog).
Pattern:
```swift
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
// action
} label: { ... }
```
Do **not** add haptics to:
- Programmatic state changes not directly triggered by a tap.
- Buttons inside `List` rows that already use swipe actions.
- Scroll events.
---
## iOS 17+ API Usage
Flag and replace any of the following deprecated patterns:
| Deprecated | Replace with |
|---|---|
| `NavigationView` | `NavigationStack` |
| `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro |
| `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` |
| Manual `@State` animation chains for repeating loops | `phaseAnimator` |
| `.animation(_:)` without `value:` | `.animation(_:value:)` |
| `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` |
Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types.
---
## Accessibility
Every view must:
- Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views.
- Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment.
- Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text.
- Touch targets ≥ 44pt (see Spacing above).
- Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`.
- Do not rely solely on color to convey state — pair color with icon or label.
---
## Performance
- **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds.
- **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects.
- **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this.
- **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache.
- **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead.
---
## Offline & Error States
Every view that makes network calls must:
1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`.
2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline.
3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`.
4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online.
---
## Component Checklist (before submitting any view change)
- [ ] All interactive elements ≥ 44pt touch target
- [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))`
- [ ] State-driven layout transitions use `.spring(response:dampingFraction:)`
- [ ] Tappable controls have haptic feedback
- [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:`
- [ ] High-frequency observers are isolated sub-views
- [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor`
- [ ] VoiceOver labels on icon-only buttons
- [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies

150
AGENTS.md Normal file
View File

@@ -0,0 +1,150 @@
# LibNovel v2 — Agent Context
This file is the root-level knowledge base for LLM coding agents (OpenCode, Claude, Cursor, Copilot, etc.).
Sub-directories have their own `AGENTS.md` with deeper context (e.g. `ios/AGENTS.md`).
---
## Stack
| Layer | Technology |
|---|---|
| UI | SvelteKit 2 + Svelte 5, TypeScript, TailwindCSS |
| Backend / Runner | Go (single repo, two binaries: `backend`, `runner`) |
| iOS app | SwiftUI, iOS 17+, Swift 5.9+ |
| Database | PocketBase (SQLite) + MinIO (object storage) |
| Search | Meilisearch |
| Queue | Asynq over Redis (local) / Valkey (prod) |
| Scraping | Novelfire scraper in `backend/novelfire/` |
---
## Repository Layout
```
.
├── .gitea/workflows/ # CI/CD — Gitea Actions (NOT .github/)
├── .opencode/ # OpenCode agent config (memory, skills)
├── backend/ # Go backend + runner (single module)
├── caddy/ # Caddy reverse proxy Dockerfile
├── homelab/ # Homelab docker-compose + observability stack
├── ios/ # SwiftUI iOS app (see ios/AGENTS.md)
├── scripts/ # Utility scripts
├── ui/ # SvelteKit UI
├── docker-compose.yml # Prod compose (all services)
├── AGENTS.md # This file
└── opencode.json # OpenCode config
```
---
## CI/CD — Gitea Actions
- Workflows live in `.gitea/workflows/`**not** `.github/workflows/`
- Self-hosted Gitea instance; use `gitea.ref_name` / `gitea.sha` (not `github.*`)
- Two workflows:
- `ci.yaml` — runs on every push to `main` (test + type-check)
- `release.yaml` — runs on `v*` tags (build Docker images, upload source maps, create Gitea release)
- Secrets: `DOCKER_USER`, `DOCKER_TOKEN`, `GITEA_TOKEN`, `GLITCHTIP_AUTH_TOKEN`
### Releasing a new version
```bash
git tag v2.5.X -m "Short title\n\nOptional longer body"
git push origin v2.5.X
```
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.
---
## GlitchTip Error Tracking
- Instance: `https://errors.libnovel.cc/`
- Org: `libnovel`
- Projects: `ui` (id/1), `backend` (id/2), `runner` (id/3)
- Tool: `glitchtip-cli` v0.1.0
### Per-service DSNs (stored in Doppler)
| Service | Doppler key | GlitchTip project |
|---|---|---|
| UI (SvelteKit) | `PUBLIC_GLITCHTIP_DSN` | ui (1) |
| Backend (Go) | `GLITCHTIP_DSN_BACKEND` | backend (2) |
| Runner (Go) | `GLITCHTIP_DSN_RUNNER` | runner (3) |
### Source map upload flow (release.yaml)
The correct order is **critical** — uploading before `releases new` results in 0 files shown in GlitchTip UI:
```
glitchtip-cli sourcemaps inject ./build # inject debug IDs
glitchtip-cli releases new <version> # MUST come before upload
glitchtip-cli sourcemaps upload ./build \
--release <version> # associate files with release
glitchtip-cli releases finalize <version> # mark release complete
```
---
## Infrastructure
| Environment | Host | Path | Doppler config |
|---|---|---|---|
| Prod | `165.22.70.138` | `/opt/libnovel/` | `prd` |
| Homelab runner | `192.168.0.109` | `/opt/libnovel-runner/` | `prd_homelab` |
### Docker Compose — always use Doppler
```bash
# Prod
doppler run --project libnovel --config prd -- docker compose <cmd>
# Homelab full-stack (runs from .bak file on server)
doppler run --project libnovel --config prd_homelab -- docker compose -f homelab/docker-compose.yml.bak <cmd>
# Homelab runner only
doppler run --project libnovel --config prd_homelab -- docker compose -f homelab/runner/docker-compose.yml <cmd>
```
- Prod runner has `profiles: [runner]``docker compose up -d` will NOT accidentally start it
- When deploying, always sync `docker-compose.yml` to the server before running `up -d`
---
## Observability
| Tool | Purpose |
|---|---|
| GlitchTip | Error tracking (UI + backend + runner) |
| Grafana Faro | RUM / Web Vitals (collector at `faro.libnovel.cc/collect`) |
| OpenTelemetry | Distributed tracing (OTLP → collector → Tempo) |
| Grafana | Dashboards at `/admin/grafana` |
Grafana dashboards: `homelab/otel/grafana/provisioning/dashboards/`
---
## Go Backend
- Primary files: `orchestrator.go`, `server/handlers_*.go`, `novelfire/scraper.go`, `storage/hybrid.go`, `storage/pocketbase.go`
- Store interface: `store.go` — never touch MinIO/PocketBase clients directly outside `storage/`
- Two binaries built from the same module: `backend` (HTTP API) and `runner` (Asynq worker)
---
## SvelteKit UI
- Source: `ui/src/`
- i18n: Paraglide — translation files in `ui/messages/*.json` (5 locales)
- Auth debug bypass: `GET /api/auth/debug-login?token=<DEBUG_LOGIN_TOKEN>&username=<username>&next=<path>`
---
## iOS App
Full context in `ios/AGENTS.md`. Quick notes:
- SwiftUI, iOS 17+, `@Observable` for new types
- Download key separator: `::` (not `-`)
- Voice fallback: book override → global default → `"af_bella"`
- Offline pattern: `NetworkMonitor` env object + `OfflineBanner` + `ErrorAlertModifier`

View File

@@ -195,6 +195,7 @@ func run() error {
ImageGen: imageGenClient,
TextGen: textGenClient,
BookWriter: store,
AIJobStore: store,
Log: log,
},
)

View File

@@ -0,0 +1,233 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// ── Cancel registry ────────────────────────────────────────────────────────────
// cancelJobsMu guards cancelJobs.
var cancelJobsMu sync.Mutex
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
// job starts and removed when it finishes or is cancelled.
var cancelJobs = map[string]context.CancelFunc{}
func registerCancelJob(id string, cancel context.CancelFunc) {
cancelJobsMu.Lock()
cancelJobs[id] = cancel
cancelJobsMu.Unlock()
}
func deregisterCancelJob(id string) {
cancelJobsMu.Lock()
delete(cancelJobs, id)
cancelJobsMu.Unlock()
}
// ── AI Job list / get / cancel ─────────────────────────────────────────────────
// handleAdminListAIJobs handles GET /api/admin/ai-jobs.
// Returns all ai_job records sorted by started descending.
func (s *Server) handleAdminListAIJobs(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
jobs, err := s.deps.AIJobStore.ListAIJobs(r.Context())
if err != nil {
s.deps.Log.Error("admin: list ai jobs failed", "err", err)
jsonError(w, http.StatusInternalServerError, "list ai jobs: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"jobs": jobs})
}
// handleAdminGetAIJob handles GET /api/admin/ai-jobs/{id}.
func (s *Server) handleAdminGetAIJob(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
id := r.PathValue("id")
job, ok, err := s.deps.AIJobStore.GetAIJob(r.Context(), id)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", id))
return
}
writeJSON(w, 0, job)
}
// handleAdminCancelAIJob handles POST /api/admin/ai-jobs/{id}/cancel.
// Marks the job as cancelled in PB and cancels the in-memory context if present.
func (s *Server) handleAdminCancelAIJob(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
id := r.PathValue("id")
// Cancel in-memory context if the job is still running in this process.
cancelJobsMu.Lock()
if cancel, ok := cancelJobs[id]; ok {
cancel()
}
cancelJobsMu.Unlock()
// Mark as cancelled in PB.
if err := s.deps.AIJobStore.UpdateAIJob(r.Context(), id, map[string]any{
"status": string(domain.TaskStatusCancelled),
"finished": time.Now().Format(time.RFC3339),
}); err != nil {
s.deps.Log.Error("admin: cancel ai job failed", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, "cancel ai job: "+err.Error())
return
}
s.deps.Log.Info("admin: ai job cancelled", "id", id)
writeJSON(w, 0, map[string]any{"cancelled": true})
}
// ── Auto-prompt ────────────────────────────────────────────────────────────────
// autoPromptRequest is the JSON body for POST /api/admin/image-gen/auto-prompt.
type autoPromptRequest struct {
// Slug is the book slug.
Slug string `json:"slug"`
// Type is "cover" or "chapter".
Type string `json:"type"`
// Chapter number (required when type == "chapter").
Chapter int `json:"chapter"`
// Model is the text-gen model to use. Defaults to DefaultTextModel.
Model string `json:"model"`
}
// autoPromptResponse is returned by POST /api/admin/image-gen/auto-prompt.
type autoPromptResponse struct {
Prompt string `json:"prompt"`
Model string `json:"model"`
}
// handleAdminImageGenAutoPrompt handles POST /api/admin/image-gen/auto-prompt.
//
// Uses the text generation model to create a vivid image generation prompt
// based on the book's description (for covers) or chapter title/content (for chapters).
func (s *Server) handleAdminImageGenAutoPrompt(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req autoPromptRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Type != "cover" && req.Type != "chapter" {
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := req.Model
if model == "" {
model = string(cfai.DefaultTextModel)
}
var userPrompt string
if req.Type == "cover" {
userPrompt = fmt.Sprintf(
"Book: \"%s\"\nAuthor: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
meta.Author,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
} else {
// For chapter images, use chapter title if available.
chapterTitle := fmt.Sprintf("Chapter %d", req.Chapter)
if req.Chapter > 0 {
chapters, listErr := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if listErr == nil {
for _, ch := range chapters {
if ch.Number == req.Chapter {
chapterTitle = ch.Title
break
}
}
}
}
userPrompt = fmt.Sprintf(
"Book: \"%s\"\nGenres: %s\nChapter: %s\n\nBook description:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
chapterTitle,
meta.Summary,
)
}
systemPrompt := buildAutoPromptSystem(req.Type)
s.deps.Log.Info("admin: image auto-prompt requested",
"slug", req.Slug, "type", req.Type, "chapter", req.Chapter, "model", model)
result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: cfai.TextModel(model),
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: 256,
})
if genErr != nil {
s.deps.Log.Error("admin: auto-prompt failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
writeJSON(w, 0, autoPromptResponse{
Prompt: strings.TrimSpace(result),
Model: model,
})
}
func buildAutoPromptSystem(imageType string) string {
if imageType == "cover" {
return `You are a professional prompt engineer for AI image generation (Stable Diffusion / FLUX models). ` +
`Given a book's title, genres, and description, write a single vivid image generation prompt ` +
`for a book cover. The prompt should describe the visual composition, art style, lighting, ` +
`and mood without mentioning text or typography. ` +
`Format: comma-separated visual descriptors, 3060 words. ` +
`Output ONLY the prompt — no explanation, no quotes, no labels.`
}
return `You are a professional prompt engineer for AI image generation (Stable Diffusion / FLUX models). ` +
`Given a book's title, genres, and a specific chapter title, write a single vivid scene illustration prompt. ` +
`Describe the scene, characters, setting, lighting, and art style. ` +
`Format: comma-separated visual descriptors, 3060 words. ` +
`Output ONLY the prompt — no explanation, no quotes, no labels.`
}

View File

@@ -0,0 +1,792 @@
package backend
// Catalogue enrichment handlers: tagline, genre tagging, content warnings,
// quality scoring, batch cover regeneration, and per-book metadata refresh.
//
// All generation endpoints are admin-only (enforced by the SvelteKit proxy layer).
// All long-running operations support cancellation via r.Context().Done().
// Batch operations use an in-memory cancel registry (cancelJobs map) so the
// frontend can send a cancel request by job ID.
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// ── Tagline ───────────────────────────────────────────────────────────────
// textGenTaglineRequest is the JSON body for POST /api/admin/text-gen/tagline.
type textGenTaglineRequest struct {
Slug string `json:"slug"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
// textGenTaglineResponse is returned by POST /api/admin/text-gen/tagline.
type textGenTaglineResponse struct {
OldTagline string `json:"old_tagline"`
NewTagline string `json:"new_tagline"`
Model string `json:"model"`
}
// handleAdminTextGenTagline handles POST /api/admin/text-gen/tagline.
// Generates a 1-sentence marketing hook for a book.
func (s *Server) handleAdminTextGenTagline(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req textGenTaglineRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
system := `You are a copywriter for a web novel platform. ` +
`Given a book's title, genres, and description, write a single punchy tagline ` +
`(one sentence, under 20 words) that hooks a reader. ` +
`Output ONLY the tagline — no quotes, no labels, no explanation.`
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
s.deps.Log.Info("admin: text-gen tagline requested", "slug", req.Slug, "model", model)
result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
MaxTokens: 64,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen tagline failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
writeJSON(w, 0, textGenTaglineResponse{
OldTagline: "", // BookMeta has no tagline field yet — always empty
NewTagline: strings.TrimSpace(result),
Model: string(model),
})
}
// ── Genres ────────────────────────────────────────────────────────────────
// textGenGenresRequest is the JSON body for POST /api/admin/text-gen/genres.
type textGenGenresRequest struct {
Slug string `json:"slug"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
// textGenGenresResponse is returned by POST /api/admin/text-gen/genres.
type textGenGenresResponse struct {
CurrentGenres []string `json:"current_genres"`
ProposedGenres []string `json:"proposed_genres"`
Model string `json:"model"`
}
// handleAdminTextGenGenres handles POST /api/admin/text-gen/genres.
// Suggests a refined genre list based on the book's description.
func (s *Server) handleAdminTextGenGenres(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req textGenGenresRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
system := `You are a genre classification expert for a web novel platform. ` +
`Given a book's title and description, return a JSON array of 26 genre tags. ` +
`Use only well-known web novel genres such as: ` +
`Action, Adventure, Comedy, Drama, Fantasy, Historical, Horror, Isekai, Josei, ` +
`Martial Arts, Mature, Mecha, Mystery, Psychological, Romance, School Life, ` +
`Sci-fi, Seinen, Shoujo, Shounen, Slice of Life, Supernatural, System, Tragedy, Wuxia, Xianxia. ` +
`Output ONLY a raw JSON array of strings — no prose, no markdown, no explanation. ` +
`Example: ["Fantasy","Adventure","Action"]`
user := fmt.Sprintf("Title: %s\nCurrent genres: %s\n\nDescription:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
s.deps.Log.Info("admin: text-gen genres requested", "slug", req.Slug, "model", model)
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
MaxTokens: 128,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen genres failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
proposed := parseStringArrayJSON(raw)
writeJSON(w, 0, textGenGenresResponse{
CurrentGenres: meta.Genres,
ProposedGenres: proposed,
Model: string(model),
})
}
// handleAdminTextGenApplyGenres handles POST /api/admin/text-gen/genres/apply.
// Persists the confirmed genre list to PocketBase.
func (s *Server) handleAdminTextGenApplyGenres(w http.ResponseWriter, r *http.Request) {
if s.deps.BookWriter == nil {
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
return
}
var req struct {
Slug string `json:"slug"`
Genres []string `json:"genres"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
meta.Genres = req.Genres
if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
s.deps.Log.Error("admin: apply genres failed", "slug", req.Slug, "err", err)
jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
return
}
s.deps.Log.Info("admin: genres applied", "slug", req.Slug, "genres", req.Genres)
writeJSON(w, 0, map[string]any{"updated": true})
}
// ── Content warnings ──────────────────────────────────────────────────────
// textGenContentWarningsRequest is the JSON body for POST /api/admin/text-gen/content-warnings.
type textGenContentWarningsRequest struct {
Slug string `json:"slug"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
// textGenContentWarningsResponse is returned by POST /api/admin/text-gen/content-warnings.
type textGenContentWarningsResponse struct {
Warnings []string `json:"warnings"`
Model string `json:"model"`
}
// handleAdminTextGenContentWarnings handles POST /api/admin/text-gen/content-warnings.
// Detects mature or sensitive themes in a book's description.
func (s *Server) handleAdminTextGenContentWarnings(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req textGenContentWarningsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
system := `You are a content moderation assistant for a web novel platform. ` +
`Given a book's title, genres, and description, detect any content warnings that should be shown to readers. ` +
`Choose only relevant warnings from: Violence, Strong Language, Sexual Content, Mature Themes, ` +
`Dark Themes, Gore, Torture, Abuse, Drug Use, Suicide/Self-Harm. ` +
`If the book is clean, return an empty array. ` +
`Output ONLY a raw JSON array of strings — no prose, no markdown. ` +
`Example: ["Violence","Dark Themes"]`
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
s.deps.Log.Info("admin: text-gen content-warnings requested", "slug", req.Slug, "model", model)
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
MaxTokens: 128,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen content-warnings failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
warnings := parseStringArrayJSON(raw)
writeJSON(w, 0, textGenContentWarningsResponse{
Warnings: warnings,
Model: string(model),
})
}
// ── Quality score ─────────────────────────────────────────────────────────
// textGenQualityScoreRequest is the JSON body for POST /api/admin/text-gen/quality-score.
type textGenQualityScoreRequest struct {
Slug string `json:"slug"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
// textGenQualityScoreResponse is returned by POST /api/admin/text-gen/quality-score.
type textGenQualityScoreResponse struct {
Score int `json:"score"` // 15
Feedback string `json:"feedback"` // brief reasoning
Model string `json:"model"`
}
// handleAdminTextGenQualityScore handles POST /api/admin/text-gen/quality-score.
// Rates the book description quality on a 15 scale with brief feedback.
func (s *Server) handleAdminTextGenQualityScore(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req textGenQualityScoreRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
system := `You are a book description quality reviewer for a web novel platform. ` +
`Rate the provided description on a scale of 15 where: ` +
`1=poor (vague/too short), 2=below average, 3=average, 4=good, 5=excellent (engaging/detailed). ` +
`Respond with ONLY a JSON object: {"score": <1-5>, "feedback": "<one sentence explanation>"}. ` +
`No markdown, no extra text.`
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
s.deps.Log.Info("admin: text-gen quality-score requested", "slug", req.Slug, "model", model)
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
MaxTokens: 128,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen quality-score failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
var parsed struct {
Score int `json:"score"`
Feedback string `json:"feedback"`
}
// Strip markdown fences if any.
clean := extractJSONObject(raw)
if err := json.Unmarshal([]byte(clean), &parsed); err != nil {
// Fallback: try to extract a digit.
parsed.Score = 0
for _, ch := range raw {
if ch >= '1' && ch <= '5' {
parsed.Score = int(ch - '0')
break
}
}
parsed.Feedback = strings.TrimSpace(raw)
}
writeJSON(w, 0, textGenQualityScoreResponse{
Score: parsed.Score,
Feedback: parsed.Feedback,
Model: string(model),
})
}
// ── Batch cover regeneration ──────────────────────────────────────────────
// batchCoverEvent is one SSE event emitted during batch cover regeneration.
type batchCoverEvent struct {
// JobID is the opaque identifier clients use to cancel this job.
JobID string `json:"job_id,omitempty"`
Done int `json:"done"`
Total int `json:"total"`
Slug string `json:"slug,omitempty"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
Finish bool `json:"finish,omitempty"`
}
// handleAdminBatchCovers handles POST /api/admin/catalogue/batch-covers.
//
// Streams SSE events as it generates covers for every book that has no cover
// stored in MinIO. Each event carries progress info. The final event has Finish=true.
//
// Supports from_item/to_item to process a sub-range of the catalogue (0-based indices).
// Supports job_id to resume a previously interrupted job.
// The job can be cancelled by calling POST /api/admin/ai-jobs/{id}/cancel.
func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil || s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured")
return
}
if s.deps.CoverStore == nil {
jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
return
}
var reqBody struct {
Model string `json:"model"`
NumSteps int `json:"num_steps"`
Width int `json:"width"`
Height int `json:"height"`
FromItem int `json:"from_item"`
ToItem int `json:"to_item"`
JobID string `json:"job_id"`
}
// Body is optional — defaults used if absent.
json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck
allBooks, err := s.deps.BookReader.ListBooks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "list books: "+err.Error())
return
}
// Apply range filter.
books := allBooks
if reqBody.FromItem > 0 || reqBody.ToItem > 0 {
from := reqBody.FromItem
to := reqBody.ToItem
if to == 0 || to >= len(allBooks) {
to = len(allBooks) - 1
}
if from < 0 {
from = 0
}
if from <= to && from < len(allBooks) {
books = allBooks[from : to+1]
}
}
// SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
flusher, canFlush := w.(http.Flusher)
sseWrite := func(evt batchCoverEvent) {
b, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", b)
if canFlush {
flusher.Flush()
}
}
total := len(books)
done := 0
// Create or resume PB ai_job and register cancel context.
var pbJobID string
resumeFrom := 0
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
if s.deps.AIJobStore != nil {
if reqBody.JobID != "" {
if existing, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), reqBody.JobID); ok {
pbJobID = reqBody.JobID
resumeFrom = existing.ItemsDone
done = resumeFrom
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
"status": string(domain.TaskStatusRunning),
"items_total": total,
})
}
}
if pbJobID == "" {
id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "batch-covers",
Status: domain.TaskStatusRunning,
FromItem: reqBody.FromItem,
ToItem: reqBody.ToItem,
ItemsTotal: total,
Started: time.Now(),
})
if createErr == nil {
pbJobID = id
}
}
if pbJobID != "" {
registerCancelJob(pbJobID, cancel)
defer deregisterCancelJob(pbJobID)
}
}
// Use pbJobID as the SSE job_id when available, else a random hex fallback.
sseJobID := pbJobID
if sseJobID == "" {
sseJobID = randomHex(8)
ctx2, cancel2 := context.WithCancel(r.Context())
registerCancelJob(sseJobID, cancel2)
defer deregisterCancelJob(sseJobID)
defer cancel2()
cancel() // replace ctx with ctx2
ctx = ctx2
}
// Send initial event with jobID so frontend can store it for cancellation.
sseWrite(batchCoverEvent{JobID: sseJobID, Done: done, Total: total})
for i, book := range books {
if ctx.Err() != nil {
break
}
// Skip already-processed items when resuming.
if i < resumeFrom {
continue
}
// Check if cover already exists.
hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug)
if hasCover {
done++
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true})
if pbJobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
}
continue
}
// Build a prompt from the book metadata.
prompt := buildCoverPrompt(book)
// Generate the image via CF AI.
imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{
Prompt: prompt,
NumSteps: reqBody.NumSteps,
Width: reqBody.Width,
Height: reqBody.Height,
})
if genErr != nil {
done++
s.deps.Log.Error("batch-covers: image gen failed", "slug", book.Slug, "err", genErr)
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: genErr.Error()})
continue
}
// Save to CoverStore.
if saveErr := s.deps.CoverStore.PutCover(ctx, book.Slug, imgBytes, "image/png"); saveErr != nil {
done++
s.deps.Log.Error("batch-covers: save failed", "slug", book.Slug, "err", saveErr)
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: saveErr.Error()})
continue
}
done++
s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug)
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug})
if pbJobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
}
}
if pbJobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if ctx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
"status": string(status),
"items_done": done,
"finished": time.Now().Format(time.RFC3339),
})
}
sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true})
}
// handleAdminBatchCoversCancel handles POST /api/admin/catalogue/batch-covers/cancel.
// Cancels an in-progress batch cover job by its job ID.
func (s *Server) handleAdminBatchCoversCancel(w http.ResponseWriter, r *http.Request) {
var req struct {
JobID string `json:"job_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.JobID == "" {
jsonError(w, http.StatusBadRequest, "job_id is required")
return
}
cancelJobsMu.Lock()
cancel, ok := cancelJobs[req.JobID]
cancelJobsMu.Unlock()
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", req.JobID))
return
}
cancel()
s.deps.Log.Info("batch-covers: job cancelled", "job_id", req.JobID)
writeJSON(w, 0, map[string]any{"cancelled": true})
}
// ── Refresh metadata (per-book) ────────────────────────────────────────────
// refreshMetadataEvent is one SSE event during per-book metadata refresh.
type refreshMetadataEvent struct {
Step string `json:"step"` // "description" | "tagline" | "cover"
Done bool `json:"done"`
Error string `json:"error,omitempty"`
}
// handleAdminRefreshMetadata handles POST /api/admin/catalogue/refresh-metadata/{slug}.
//
// Runs description → tagline → cover generation in sequence for a single book
// and streams SSE progress. Interruptable via client disconnect (r.Context()).
func (s *Server) handleAdminRefreshMetadata(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", slug))
return
}
// SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
flusher, canFlush := w.(http.Flusher)
sseWrite := func(evt refreshMetadataEvent) {
b, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", b)
if canFlush {
flusher.Flush()
}
}
ctx := r.Context()
// Step 1 — description.
if s.deps.TextGen != nil {
if ctx.Err() == nil {
newDesc, genErr := s.deps.TextGen.Generate(ctx, cfai.TextRequest{
Model: cfai.DefaultTextModel,
Messages: []cfai.TextMessage{
{Role: "system", Content: `You are a book description writer for a web novel platform. Write an improved description. Respond with ONLY the new description text — no title, no labels, no markdown.`},
{Role: "user", Content: fmt.Sprintf("Title: %s\nGenres: %s\n\nCurrent description:\n%s\n\nInstructions: Write a compelling 24 sentence description. Keep it spoiler-free and engaging.", meta.Title, strings.Join(meta.Genres, ", "), meta.Summary)},
},
MaxTokens: 512,
})
if genErr == nil && strings.TrimSpace(newDesc) != "" && s.deps.BookWriter != nil {
meta.Summary = strings.TrimSpace(newDesc)
if writeErr := s.deps.BookWriter.WriteMetadata(ctx, meta); writeErr != nil {
sseWrite(refreshMetadataEvent{Step: "description", Error: writeErr.Error()})
} else {
sseWrite(refreshMetadataEvent{Step: "description"})
}
} else if genErr != nil {
sseWrite(refreshMetadataEvent{Step: "description", Error: genErr.Error()})
}
}
}
// Step 2 — cover.
if s.deps.ImageGen != nil && s.deps.CoverStore != nil {
if ctx.Err() == nil {
prompt := buildCoverPrompt(meta)
imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{Prompt: prompt})
if genErr == nil {
if saveErr := s.deps.CoverStore.PutCover(ctx, slug, imgBytes, "image/png"); saveErr != nil {
sseWrite(refreshMetadataEvent{Step: "cover", Error: saveErr.Error()})
} else {
sseWrite(refreshMetadataEvent{Step: "cover"})
}
} else {
sseWrite(refreshMetadataEvent{Step: "cover", Error: genErr.Error()})
}
}
}
sseWrite(refreshMetadataEvent{Step: "done", Done: true})
}
// ── Helpers ───────────────────────────────────────────────────────────────
// parseStringArrayJSON extracts a JSON string array from model output,
// tolerating markdown fences and surrounding prose.
func parseStringArrayJSON(raw string) []string {
s := raw
if idx := strings.Index(s, "```json"); idx >= 0 {
s = s[idx+7:]
} else if idx := strings.Index(s, "```"); idx >= 0 {
s = s[idx+3:]
}
if idx := strings.LastIndex(s, "```"); idx >= 0 {
s = s[:idx]
}
start := strings.Index(s, "[")
end := strings.LastIndex(s, "]")
if start < 0 || end <= start {
return nil
}
s = s[start : end+1]
var out []string
json.Unmarshal([]byte(s), &out) //nolint:errcheck
return out
}
// extractJSONObject finds the first {...} object in a string.
func extractJSONObject(raw string) string {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return raw
}
return raw[start : end+1]
}
// buildCoverPrompt constructs a prompt string for cover generation from a book.
func buildCoverPrompt(meta domain.BookMeta) string {
parts := []string{"book cover art"}
if meta.Title != "" {
parts = append(parts, "titled \""+meta.Title+"\"")
}
if len(meta.Genres) > 0 {
parts = append(parts, strings.Join(meta.Genres, ", ")+" genre")
}
if meta.Summary != "" {
summary := meta.Summary
if len(summary) > 200 {
summary = summary[:200]
}
parts = append(parts, summary)
}
return strings.Join(parts, ", ")
}
// randomHex returns a random hex string of n bytes.
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -1,15 +1,21 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// chapterNamesBatchSize is the number of chapters sent per LLM request.
// Keeps output well within the 4096-token response limit (~30 tokens/title).
const chapterNamesBatchSize = 100
// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
// Returns the list of supported Cloudflare AI text generation models.
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
@@ -34,16 +40,13 @@ type textGenChapterNamesRequest struct {
Model string `json:"model"`
// MaxTokens limits response length (0 = model default).
MaxTokens int `json:"max_tokens"`
}
// textGenChapterNamesResponse is the JSON body returned by POST /api/admin/text-gen/chapter-names.
type textGenChapterNamesResponse struct {
// Chapters is the list of proposed chapter titles, indexed by chapter number.
Chapters []proposedChapterTitle `json:"chapters"`
// Model is the model that was used.
Model string `json:"model"`
// RawResponse is the raw model output for debugging / manual editing.
RawResponse string `json:"raw_response"`
// FromChapter is the first chapter to process (1-based). 0 = start from chapter 1.
FromChapter int `json:"from_chapter"`
// ToChapter is the last chapter to process (inclusive). 0 = process all.
ToChapter int `json:"to_chapter"`
// JobID is an optional existing ai_job ID for resuming a previous run.
// If set, the handler resumes from items_done instead of starting from scratch.
JobID string `json:"job_id"`
}
// proposedChapterTitle is a single chapter with its AI-proposed title.
@@ -55,12 +58,37 @@ type proposedChapterTitle struct {
NewTitle string `json:"new_title"`
}
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
type chapterNamesBatchEvent struct {
// JobID is the PB ai_job ID for this run (emitted on the first event only).
JobID string `json:"job_id,omitempty"`
// Batch is the 1-based batch index.
Batch int `json:"batch"`
// TotalBatches is the total number of batches.
TotalBatches int `json:"total_batches"`
// ChaptersDone is the cumulative count of chapters processed so far.
ChaptersDone int `json:"chapters_done"`
// TotalChapters is the total chapter count for this book.
TotalChapters int `json:"total_chapters"`
// Model is the CF AI model used.
Model string `json:"model"`
// Chapters contains the proposed titles for this batch.
Chapters []proposedChapterTitle `json:"chapters"`
// Error is non-empty if this batch failed.
Error string `json:"error,omitempty"`
// Done is true on the final sentinel event (no Chapters).
Done bool `json:"done,omitempty"`
}
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
//
// Reads all chapter titles for the given slug, sends them to the LLM with the
// requested naming pattern, and returns proposed replacements. Does NOT persist
// anything — the frontend shows a diff and the user must confirm via
// POST /api/admin/text-gen/chapter-names/apply.
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
// to the LLM sequentially, and streams results back as Server-Sent Events so
// the frontend can show live progress. Each SSE data line is a JSON-encoded
// chapterNamesBatchEvent. The final event has Done=true.
//
// Does NOT persist anything — the frontend shows a diff and the user must
// confirm via POST /api/admin/text-gen/chapter-names/apply.
func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
@@ -82,21 +110,59 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
// Load existing chapter list.
chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
allChapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
return
}
if len(chapters) == 0 {
if len(allChapters) == 0 {
jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
return
}
// Build the prompt.
var chapterListSB strings.Builder
for _, ch := range chapters {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
// Apply chapter range filter.
chapters := allChapters
if req.FromChapter > 0 || req.ToChapter > 0 {
filtered := chapters[:0]
for _, ch := range allChapters {
if req.FromChapter > 0 && ch.Number < req.FromChapter {
continue
}
if req.ToChapter > 0 && ch.Number > req.ToChapter {
break
}
filtered = append(filtered, ch)
}
chapters = filtered
}
if len(chapters) == 0 {
jsonError(w, http.StatusBadRequest, "no chapters in the specified range")
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
// 4096 tokens comfortably fits 100 chapter titles (~30 tokens each).
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
// Index existing titles for old/new diff.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
// Partition chapters into batches.
batches := chunkChapters(chapters, chapterNamesBatchSize)
totalBatches := len(batches)
s.deps.Log.Info("admin: text-gen chapter-names requested",
"slug", req.Slug, "chapters", len(chapters),
"batches", totalBatches, "model", model, "max_tokens", maxTokens)
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
`The user provides a list of chapter numbers with their current titles, ` +
@@ -111,64 +177,168 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
`5. Each element: {"number": <int>, "title": <string>}. ` +
`6. Output every chapter in the input list, in order. Do not skip any.`
userPrompt := fmt.Sprintf(
"Naming pattern: %s\n\nChapters:\n%s",
req.Pattern,
chapterListSB.String(),
)
// Switch to SSE before writing anything.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
flusher, canFlush := w.(http.Flusher)
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
sseWrite := func(evt chapterNamesBatchEvent) {
b, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", b)
if canFlush {
flusher.Flush()
}
}
// Default to 4096 tokens so large chapter lists are not truncated.
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
// Create or resume an ai_job record for tracking.
var jobID string
resumeFrom := 0
jobCtx := r.Context()
var jobCancel context.CancelFunc
if s.deps.AIJobStore != nil {
if req.JobID != "" {
if existingJob, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), req.JobID); ok {
jobID = req.JobID
resumeFrom = existingJob.ItemsDone
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(domain.TaskStatusRunning),
"items_total": len(chapters),
})
}
}
if jobID == "" {
jobPayload := fmt.Sprintf(`{"pattern":%q}`, req.Pattern)
id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "chapter-names",
Slug: req.Slug,
Status: domain.TaskStatusRunning,
FromItem: req.FromChapter,
ToItem: req.ToChapter,
ItemsTotal: len(chapters),
Model: string(model),
Payload: jobPayload,
Started: time.Now(),
})
if createErr == nil {
jobID = id
}
}
if jobID != "" {
jobCtx, jobCancel = context.WithCancel(r.Context())
registerCancelJob(jobID, jobCancel)
defer deregisterCancelJob(jobID)
defer jobCancel()
}
}
s.deps.Log.Info("admin: text-gen chapter-names requested",
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
chaptersDone := resumeFrom
firstEvent := true
for i, batch := range batches {
if jobCtx.Err() != nil {
return // client disconnected or cancelled
}
// Skip batches already processed in a previous run.
batchEnd := (i + 1) * chapterNamesBatchSize
if batchEnd <= resumeFrom {
continue
}
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: maxTokens,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen chapter-names failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
var chapterListSB strings.Builder
for _, ch := range batch {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
}
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
raw, genErr := s.deps.TextGen.Generate(jobCtx, cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: maxTokens,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
"batch", i+1, "err", genErr)
evt := chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Error: genErr.Error(),
}
if firstEvent {
evt.JobID = jobID
firstEvent = false
}
sseWrite(evt)
continue
}
proposed := parseChapterTitlesJSON(raw)
result := make([]proposedChapterTitle, 0, len(proposed))
for _, p := range proposed {
result = append(result, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
})
}
chaptersDone += len(batch)
if jobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"items_done": chaptersDone,
})
}
evt := chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Chapters: result,
}
if firstEvent {
evt.JobID = jobID
firstEvent = false
}
sseWrite(evt)
}
// Parse the JSON array from the model response.
proposed := parseChapterTitlesJSON(raw)
// Build the response: merge proposed titles with old titles.
// Index existing chapters by number for O(1) lookup.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
result := make([]proposedChapterTitle, 0, len(proposed))
for _, p := range proposed {
result = append(result, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
// Mark job as done in PB.
if jobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if jobCtx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),
})
}
writeJSON(w, 0, textGenChapterNamesResponse{
Chapters: result,
Model: string(model),
RawResponse: raw,
})
// Final sentinel event.
sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
}
// chunkChapters splits a chapter slice into batches of at most size n.
func chunkChapters(chapters []domain.ChapterInfo, n int) [][]domain.ChapterInfo {
var batches [][]domain.ChapterInfo
for len(chapters) > 0 {
end := n
if end > len(chapters) {
end = len(chapters)
}
batches = append(batches, chapters[:end])
chapters = chapters[end:]
}
return batches
}
// parseChapterTitlesJSON extracts the JSON array from a model response.

View File

@@ -82,6 +82,9 @@ type Dependencies struct {
// BookWriter writes book metadata and chapter refs to PocketBase.
// Used by admin text-gen apply endpoints.
BookWriter bookstore.BookWriter
// AIJobStore tracks long-running AI generation jobs in PocketBase.
// If nil, job persistence is disabled (jobs still run but are not recorded).
AIJobStore bookstore.AIJobStore
// Log is the structured logger.
Log *slog.Logger
}
@@ -204,6 +207,24 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
// Admin catalogue enrichment endpoints
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
// Admin AI job tracking endpoints
mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
// Auto-prompt generation from book/chapter content
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)

View File

@@ -158,6 +158,19 @@ type CoverStore interface {
CoverExists(ctx context.Context, slug string) bool
}
// AIJobStore manages AI generation jobs tracked in PocketBase.
type AIJobStore interface {
// CreateAIJob inserts a new ai_job record with status=running and returns its ID.
CreateAIJob(ctx context.Context, job domain.AIJob) (string, error)
// GetAIJob retrieves a single ai_job by ID.
// Returns (zero, false, nil) when not found.
GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error)
// UpdateAIJob patches an existing ai_job record with the given fields.
UpdateAIJob(ctx context.Context, id string, fields map[string]any) error
// ListAIJobs returns all ai_job records sorted by started descending.
ListAIJobs(ctx context.Context) ([]domain.AIJob, error)
}
// TranslationStore covers machine-translated chapter storage in MinIO.
// The runner writes translations; the backend reads them.
type TranslationStore interface {

View File

@@ -29,8 +29,11 @@ import (
"encoding/json"
"fmt"
"image"
"image/draw"
"image/jpeg"
_ "image/jpeg" // register JPEG decoder
_ "image/png" // register PNG decoder
"image/png"
_ "image/png" // register PNG decoder
"io"
"net/http"
"time"
@@ -189,6 +192,11 @@ func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest
return c.callImageAPI(ctx, req.Model, body)
}
// refImageMaxDim is the maximum dimension (width or height) for reference images
// sent to Cloudflare Workers AI. CF's JSON body limit is ~4 MB; a 768px JPEG
// stays well under that while preserving enough detail for img2img guidance.
const refImageMaxDim = 768
// GenerateImageFromReference generates an image from a text prompt + reference image.
func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error) {
if len(refImage) == 0 {
@@ -196,6 +204,10 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req
}
req = applyImageDefaults(req)
// Shrink the reference image if it exceeds the safe payload size.
// This avoids CF's 4 MB JSON body limit and reduces latency.
refImage = resizeRefImage(refImage, refImageMaxDim)
var body map[string]any
if req.Model == ImageModelSD15Img2Img {
pixels, err := decodeImageToRGBA(refImage)
@@ -286,6 +298,60 @@ func applyImageDefaults(req ImageRequest) ImageRequest {
return req
}
// resizeRefImage down-scales an image so that its longest side is at most maxDim
// pixels, then re-encodes it as JPEG (quality 85). If the image is already small
// enough, or if decoding fails, the original bytes are returned unchanged.
// This keeps the JSON payload well under Cloudflare Workers AI's 4 MB body limit.
func resizeRefImage(data []byte, maxDim int) []byte {
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data
}
b := src.Bounds()
w, h := b.Dx(), b.Dy()
longest := w
if h > longest {
longest = h
}
if longest <= maxDim {
return data // already fits
}
// Compute target dimensions preserving aspect ratio.
scale := float64(maxDim) / float64(longest)
newW := int(float64(w)*scale + 0.5)
newH := int(float64(h)*scale + 0.5)
if newW < 1 {
newW = 1
}
if newH < 1 {
newH = 1
}
// Nearest-neighbour downsample (no extra deps, sufficient for reference guidance).
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
for y := 0; y < newH; y++ {
for x := 0; x < newW; x++ {
srcX := b.Min.X + int(float64(x)/scale)
srcY := b.Min.Y + int(float64(y)/scale)
draw.Draw(dst, image.Rect(x, y, x+1, y+1), src, image.Pt(srcX, srcY), draw.Src)
}
}
var buf bytes.Buffer
if format == "jpeg" {
if encErr := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); encErr != nil {
return data
}
} else {
if encErr := png.Encode(&buf, dst); encErr != nil {
return data
}
}
return buf.Bytes()
}
// decodeImageToRGBA decodes PNG/JPEG bytes to a flat []uint8 RGBA pixel array
// required by the stable-diffusion-v1-5-img2img model.
func decodeImageToRGBA(data []byte) ([]uint8, error) {

View File

@@ -217,9 +217,11 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
}
// CF AI wraps responses: { "result": { "response": "..." }, "success": true }
// Some models (e.g. Llama 4 Scout) return response as an array:
// { "result": { "response": [{"generated_text":"..."}] } }
var wrapper struct {
Result struct {
Response string `json:"response"`
Response json.RawMessage `json:"response"`
} `json:"result"`
Success bool `json:"success"`
Errors []string `json:"errors"`
@@ -230,7 +232,19 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
if !wrapper.Success {
return "", fmt.Errorf("cfai/text: model %s error: %v", req.Model, wrapper.Errors)
}
return wrapper.Result.Response, nil
// Try plain string first.
var text string
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
return text, nil
}
// Fall back: array of objects with a "generated_text" field.
var arr []struct {
GeneratedText string `json:"generated_text"`
}
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 {
return arr[0].GeneratedText, nil
}
return "", fmt.Errorf("cfai/text: model %s: unrecognised response shape: %s", req.Model, wrapper.Result.Response)
}
// Models returns all supported text generation model metadata.

View File

@@ -169,3 +169,30 @@ type TranslationResult struct {
ObjectKey string `json:"object_key,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// AIJob represents an AI generation task tracked in PocketBase (ai_jobs collection).
type AIJob struct {
ID string `json:"id"`
// Kind is one of: "chapter-names", "batch-covers", "chapter-covers", "refresh-metadata".
Kind string `json:"kind"`
// Slug is the book slug for per-book jobs; empty for catalogue-wide jobs.
Slug string `json:"slug"`
Status TaskStatus `json:"status"`
// FromItem is the first item to process (chapter number, or 0-based book index).
// 0 = start from the beginning.
FromItem int `json:"from_item"`
// ToItem is the last item to process (inclusive). 0 = process all.
ToItem int `json:"to_item"`
// ItemsDone is the cumulative count of successfully processed items.
ItemsDone int `json:"items_done"`
// ItemsTotal is the total number of items in this job.
ItemsTotal int `json:"items_total"`
Model string `json:"model"`
// Payload is a JSON-encoded string with job-specific parameters
// (e.g. naming pattern for chapter-names, num_steps for batch-covers).
Payload string `json:"payload"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started,omitempty"`
Finished time.Time `json:"finished,omitempty"`
HeartbeatAt time.Time `json:"heartbeat_at,omitempty"`
}

View File

@@ -53,6 +53,7 @@ var _ bookstore.PresignStore = (*Store)(nil)
var _ bookstore.ProgressStore = (*Store)(nil)
var _ bookstore.CoverStore = (*Store)(nil)
var _ bookstore.TranslationStore = (*Store)(nil)
var _ bookstore.AIJobStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
@@ -1063,3 +1064,107 @@ func (s *Store) GetTranslation(ctx context.Context, key string) (string, error)
}
return string(data), nil
}
// ── AIJobStore ────────────────────────────────────────────────────────────────
func (s *Store) CreateAIJob(ctx context.Context, job domain.AIJob) (string, error) {
payload := map[string]any{
"kind": job.Kind,
"slug": job.Slug,
"status": string(job.Status),
"from_item": job.FromItem,
"to_item": job.ToItem,
"items_done": job.ItemsDone,
"items_total": job.ItemsTotal,
"model": job.Model,
"payload": job.Payload,
"started": job.Started.Format(time.RFC3339),
}
var out struct {
ID string `json:"id"`
}
if err := s.pb.post(ctx, "/api/collections/ai_jobs/records", payload, &out); err != nil {
return "", fmt.Errorf("CreateAIJob: %w", err)
}
return out.ID, nil
}
func (s *Store) GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error) {
var raw json.RawMessage
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), &raw); err != nil {
if strings.Contains(err.Error(), "404") {
return domain.AIJob{}, false, nil
}
return domain.AIJob{}, false, fmt.Errorf("GetAIJob: %w", err)
}
job, err := parseAIJob(raw)
if err != nil {
return domain.AIJob{}, false, err
}
return job, true, nil
}
func (s *Store) UpdateAIJob(ctx context.Context, id string, fields map[string]any) error {
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), fields)
}
func (s *Store) ListAIJobs(ctx context.Context) ([]domain.AIJob, error) {
items, err := s.pb.listAll(ctx, "ai_jobs", "", "-started")
if err != nil {
return nil, fmt.Errorf("ListAIJobs: %w", err)
}
out := make([]domain.AIJob, 0, len(items))
for _, raw := range items {
j, err := parseAIJob(raw)
if err != nil {
continue
}
out = append(out, j)
}
return out, nil
}
func parseAIJob(raw json.RawMessage) (domain.AIJob, error) {
var r struct {
ID string `json:"id"`
Kind string `json:"kind"`
Slug string `json:"slug"`
Status string `json:"status"`
FromItem int `json:"from_item"`
ToItem int `json:"to_item"`
ItemsDone int `json:"items_done"`
ItemsTotal int `json:"items_total"`
Model string `json:"model"`
Payload string `json:"payload"`
ErrorMessage string `json:"error_message"`
Started string `json:"started"`
Finished string `json:"finished"`
HeartbeatAt string `json:"heartbeat_at"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return domain.AIJob{}, fmt.Errorf("parseAIJob: %w", err)
}
parseT := func(s string) time.Time {
if s == "" {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339, s)
return t
}
return domain.AIJob{
ID: r.ID,
Kind: r.Kind,
Slug: r.Slug,
Status: domain.TaskStatus(r.Status),
FromItem: r.FromItem,
ToItem: r.ToItem,
ItemsDone: r.ItemsDone,
ItemsTotal: r.ItemsTotal,
Model: r.Model,
Payload: r.Payload,
ErrorMessage: r.ErrorMessage,
Started: parseT(r.Started),
Finished: parseT(r.Finished),
HeartbeatAt: parseT(r.HeartbeatAt),
}, nil
}

View File

@@ -186,7 +186,7 @@ services:
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_BACKEND}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "backend"
# Asynq task queue — backend enqueues jobs to local Redis sidecar.
@@ -249,7 +249,7 @@ services:
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "runner"
healthcheck:
@@ -302,6 +302,8 @@ services:
PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}"
# GlitchTip client + server-side error tracking
PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}"
# Grafana Faro RUM (browser Web Vitals, traces, errors)
PUBLIC_FARO_COLLECTOR_URL: "${PUBLIC_FARO_COLLECTOR_URL}"
# OpenTelemetry tracing
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "ui"

View File

@@ -18,6 +18,7 @@
# uptime.libnovel.cc → uptime-kuma:3001
# push.libnovel.cc → gotify:80
# grafana.libnovel.cc → grafana:3000
# faro.libnovel.cc → alloy:12347
services:
@@ -81,7 +82,7 @@ services:
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
LOG_LEVEL: "${LOG_LEVEL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
# OTel — send runner traces/metrics to the local collector (HTTP)
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
@@ -346,6 +347,23 @@ services:
timeout: 5s
retries: 5
# ── Grafana Alloy (Faro RUM receiver) ───────────────────────────────────────
# Receives browser telemetry from @grafana/faro-web-sdk (Web Vitals, traces,
# errors). Exposes POST /collect at faro.libnovel.cc via cloudflared.
# Forwards traces to otel-collector (→ Tempo) and logs to Loki directly.
alloy:
image: grafana/alloy:latest
restart: unless-stopped
command: ["run", "--server.http.listen-addr=0.0.0.0:12348", "/etc/alloy/alloy.river"]
volumes:
- ./otel/alloy.river:/etc/alloy/alloy.river:ro
expose:
- "12347" # Faro HTTP receiver (POST /collect)
- "12348" # Alloy UI / health endpoint
depends_on:
- otel-collector
- loki
# ── OTel Collector ──────────────────────────────────────────────────────────
# Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki.
otel-collector:

43
homelab/otel/alloy.river Normal file
View File

@@ -0,0 +1,43 @@
// Grafana Alloy — Faro RUM receiver
//
// Receives browser telemetry (Web Vitals, traces, logs, exceptions) from the
// LibNovel SvelteKit frontend via the @grafana/faro-web-sdk.
//
// Pipeline:
// faro.receiver → receives HTTP POST /collect from browsers
// otelcol.exporter.otlphttp → forwards traces to OTel Collector → Tempo
// loki.write → forwards logs/exceptions to Loki
//
// The Faro endpoint is exposed publicly at faro.libnovel.cc via cloudflared.
// CORS is configured to allow requests from libnovel.cc.
faro.receiver "faro" {
server {
listen_address = "0.0.0.0"
listen_port = 12347
cors_allowed_origins = ["https://libnovel.cc", "https://www.libnovel.cc"]
}
output {
logs = [loki.write.faro.receiver]
traces = [otelcol.exporter.otlphttp.faro.input]
}
}
// Forward Faro traces to the OTel Collector (which routes to Tempo)
otelcol.exporter.otlphttp "faro" {
client {
endpoint = "http://otel-collector:4318"
tls {
insecure = true
}
}
}
// Forward Faro logs/exceptions directly to Loki
loki.write "faro" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}

View File

@@ -53,6 +53,9 @@ extensions:
service:
extensions: [health_check, pprof]
telemetry:
metrics:
address: 0.0.0.0:8888
pipelines:
traces:
receivers: [otlp]

View File

@@ -1,7 +1,7 @@
{
"uid": "libnovel-backend",
"title": "Backend API",
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics and UI OTel instrumentation.",
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics.",
"tags": ["libnovel", "backend", "api"],
"timezone": "browser",
"refresh": "30s",
@@ -173,7 +173,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m])) * 60",
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "5xx/min",
"instant": true
}
@@ -182,7 +182,7 @@
{
"id": 10,
"type": "timeseries",
"title": "Request Rate by Status",
"title": "Request Rate (total vs errors)",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"options": {
"tooltip": { "mode": "multi" },
@@ -191,27 +191,21 @@
"fieldConfig": {
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byFrameRefID", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
{ "matcher": { "id": "byName", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"targets": [
{
"refId": "success",
"refId": "total",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"2..\"}[5m]))",
"legendFormat": "2xx"
},
{
"refId": "notfound",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"4..\"}[5m]))",
"legendFormat": "4xx"
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m]))",
"legendFormat": "total"
},
{
"refId": "errors",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m]))",
"legendFormat": "5xx"
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m]))",
"legendFormat": "errors"
}
]
},
@@ -248,50 +242,30 @@
{
"id": 12,
"type": "timeseries",
"title": "Requests / min by HTTP method (UI → Backend)",
"title": "Request Rate by Span Name (top operations)",
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
"description": "Throughput broken down by HTTP route / span name from Tempo span metrics.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\"}[5m])) by (http_request_method) * 60",
"legendFormat": "{{http_request_method}}"
"expr": "topk(10, sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m])) by (span_name))",
"legendFormat": "{{span_name}}"
}
]
},
{
"id": 13,
"type": "timeseries",
"title": "Requests / min — UI → PocketBase",
"title": "Latency by Span Name (p95)",
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
"description": "Traffic from SvelteKit server to PocketBase (auth, collections, etc.).",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"pocketbase\"}[5m])) by (http_request_method, http_response_status_code) * 60",
"legendFormat": "{{http_request_method}} {{http_response_status_code}}"
}
]
},
{
"id": 14,
"type": "timeseries",
"title": "UI → Backend Latency (p50 / p95)",
"gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 },
"description": "HTTP client latency as seen from the SvelteKit SSR layer calling backend.",
"description": "p95 latency per operation — helps identify slow endpoints.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
@@ -302,13 +276,8 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
"legendFormat": "p95"
"expr": "topk(10, histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le, span_name)))",
"legendFormat": "{{span_name}}"
}
]
},
@@ -316,7 +285,7 @@
"id": 20,
"type": "logs",
"title": "Backend Errors",
"gridPos": { "x": 0, "y": 28, "w": 24, "h": 10 },
"gridPos": { "x": 0, "y": 20, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,

View File

@@ -35,7 +35,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_running",
"expr": "runner_tasks_running",
"legendFormat": "running",
"instant": true
}
@@ -61,7 +61,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_completed_total",
"expr": "runner_tasks_completed_total",
"legendFormat": "completed",
"instant": true
}
@@ -93,7 +93,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_failed_total",
"expr": "runner_tasks_failed_total",
"legendFormat": "failed",
"instant": true
}
@@ -126,7 +126,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_uptime_seconds",
"expr": "runner_uptime_seconds",
"legendFormat": "uptime",
"instant": true
}
@@ -159,7 +159,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_failed_total / clamp_min(libnovel_runner_tasks_completed_total + libnovel_runner_tasks_failed_total, 1)",
"expr": "runner_tasks_failed_total / clamp_min(runner_tasks_completed_total + runner_tasks_failed_total, 1)",
"legendFormat": "failure rate",
"instant": true
}
@@ -215,17 +215,17 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(libnovel_runner_tasks_completed_total[5m]) * 60",
"expr": "rate(runner_tasks_completed_total[5m]) * 60",
"legendFormat": "completed"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(libnovel_runner_tasks_failed_total[5m]) * 60",
"expr": "rate(runner_tasks_failed_total[5m]) * 60",
"legendFormat": "failed"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_running",
"expr": "runner_tasks_running",
"legendFormat": "running"
}
]

View File

@@ -0,0 +1,284 @@
{
"uid": "libnovel-web-vitals",
"title": "Web Vitals (RUM)",
"description": "Real User Monitoring — Core Web Vitals (LCP, CLS, INP, TTFB, FCP) from @grafana/faro-web-sdk. Data flows: browser → Alloy faro.receiver → Tempo (traces) + Loki (logs).",
"tags": ["libnovel", "frontend", "rum", "web-vitals"],
"timezone": "browser",
"refresh": "1m",
"time": { "from": "now-24h", "to": "now" },
"schemaVersion": 39,
"panels": [
{
"id": 1,
"type": "stat",
"title": "LCP — p75 (Largest Contentful Paint)",
"description": "Good < 2.5 s, needs improvement < 4 s, poor ≥ 4 s.",
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 2500 },
{ "color": "red", "value": 4000 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[1h])) by (le)) * 1000",
"legendFormat": "LCP p75",
"instant": true
}
]
},
{
"id": 2,
"type": "stat",
"title": "INP — p75 (Interaction to Next Paint)",
"description": "Good < 200 ms, needs improvement < 500 ms, poor ≥ 500 ms.",
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 200 },
{ "color": "red", "value": 500 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*inp|INP\"}[1h])) by (le)) * 1000",
"legendFormat": "INP p75",
"instant": true
}
]
},
{
"id": 3,
"type": "stat",
"title": "CLS — p75 (Cumulative Layout Shift)",
"description": "Good < 0.1, needs improvement < 0.25, poor ≥ 0.25.",
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 3,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 0.25 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*cls|CLS\"}[1h])) by (le))",
"legendFormat": "CLS p75",
"instant": true
}
]
},
{
"id": 4,
"type": "stat",
"title": "TTFB — p75 (Time to First Byte)",
"description": "Good < 800 ms, needs improvement < 1800 ms, poor ≥ 1800 ms.",
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 800 },
{ "color": "red", "value": 1800 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[1h])) by (le)) * 1000",
"legendFormat": "TTFB p75",
"instant": true
}
]
},
{
"id": 5,
"type": "stat",
"title": "FCP — p75 (First Contentful Paint)",
"description": "Good < 1.8 s, needs improvement < 3 s, poor ≥ 3 s.",
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1800 },
{ "color": "red", "value": 3000 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*fcp|FCP\"}[1h])) by (le)) * 1000",
"legendFormat": "FCP p75",
"instant": true
}
]
},
{
"id": 6,
"type": "stat",
"title": "Active Sessions (30 min)",
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"libnovel-ui\"}[30m]))",
"legendFormat": "sessions",
"instant": true
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "LCP over time (p50 / p75 / p95)",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
"fieldConfig": {
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byName", "options": "Good (2.5s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] },
{ "matcher": { "id": "byName", "options": "Poor (4s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] }
]
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"legendFormat": "p75"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"legendFormat": "p95"
},
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "2500", "legendFormat": "Good (2.5s)" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "4000", "legendFormat": "Poor (4s)" }
]
},
{
"id": 11,
"type": "timeseries",
"title": "TTFB over time (p50 / p75 / p95)",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
"fieldConfig": {
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"legendFormat": "p75"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"legendFormat": "p95"
}
]
},
{
"id": 20,
"type": "logs",
"title": "Frontend Errors & Exceptions",
"description": "JS exceptions and console errors captured by Faro and shipped to Loki.",
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": true,
"wrapLogMessage": true,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"libnovel-ui\"} | json | kind =~ `(exception|error)`",
"legendFormat": ""
}
]
},
{
"id": 21,
"type": "logs",
"title": "Frontend Logs (all Faro events)",
"gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": true,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"libnovel-ui\"}",
"legendFormat": ""
}
]
}
]
}

View File

@@ -102,7 +102,7 @@ services:
# ── Observability ───────────────────────────────────────────────────────
LOG_LEVEL: "${LOG_LEVEL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
healthcheck:
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]

View File

@@ -294,6 +294,23 @@ create "translation_jobs" '{
{"name":"heartbeat_at", "type":"date"}
]}'
create "ai_jobs" '{
"name":"ai_jobs","type":"base","fields":[
{"name":"kind", "type":"text", "required":true},
{"name":"slug", "type":"text"},
{"name":"status", "type":"text", "required":true},
{"name":"from_item", "type":"number"},
{"name":"to_item", "type":"number"},
{"name":"items_done", "type":"number"},
{"name":"items_total", "type":"number"},
{"name":"model", "type":"text"},
{"name":"payload", "type":"text"},
{"name":"error_message", "type":"text"},
{"name":"started", "type":"date"},
{"name":"finished", "type":"date"},
{"name":"heartbeat_at", "type":"date"}
]}'
create "discovery_votes" '{
"name":"discovery_votes","type":"base","fields":[
{"name":"session_id","type":"text","required":true},

View File

@@ -402,6 +402,7 @@
"admin_nav_changelog": "Changelog",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",
@@ -409,6 +410,7 @@
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Push",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_scrape_status_idle": "Idle",
"admin_scrape_status_running": "Running",

View File

@@ -402,7 +402,7 @@
"admin_nav_changelog": "Modifications",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_feedback": "Retours",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
"admin_nav_logs": "Journaux",
@@ -471,5 +471,6 @@
"feed_chapters_label": "{n} chapitres",
"feed_browse_cta": "Parcourir le catalogue",
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea"
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
}

View File

@@ -402,7 +402,7 @@
"admin_nav_changelog": "Perubahan",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_feedback": "Masukan",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
"admin_nav_logs": "Log",
@@ -471,5 +471,6 @@
"feed_chapters_label": "{n} bab",
"feed_browse_cta": "Jelajahi katalog",
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea"
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
}

View File

@@ -402,7 +402,7 @@
"admin_nav_changelog": "Alterações",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_feedback": "Feedback",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
"admin_nav_logs": "Logs",
@@ -471,5 +471,6 @@
"feed_chapters_label": "{n} capítulos",
"feed_browse_cta": "Ver catálogo",
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea"
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
}

View File

@@ -402,7 +402,7 @@
"admin_nav_changelog": "Изменения",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_feedback": "Отзывы",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
"admin_nav_logs": "Логи",
@@ -471,5 +471,6 @@
"feed_chapters_label": "{n} глав",
"feed_browse_cta": "Каталог",
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea"
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
}

142
ui/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"@grafana/faro-web-sdk": "^2.3.1",
"@inlang/paraglide-js": "^2.15.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
@@ -1689,6 +1690,115 @@
"module-details-from-path": "^1.0.4"
}
},
"node_modules/@grafana/faro-core": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-2.3.1.tgz",
"integrity": "sha512-htDKO0YFKr0tfntrPoM151vOPSZzmP6oE0+0MDvbI1WDaBW4erXmYi3feGJLWDXt5/vZBg9iQRmZoRzTLTTcOA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/otlp-transformer": "^0.213.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/otlp-transformer": {
"version": "0.213.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz",
"integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.213.0",
"@opentelemetry/core": "2.6.0",
"@opentelemetry/resources": "2.6.0",
"@opentelemetry/sdk-logs": "0.213.0",
"@opentelemetry/sdk-metrics": "2.6.0",
"@opentelemetry/sdk-trace-base": "2.6.0",
"protobufjs": "^7.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/resources": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz",
"integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.6.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-logs": {
"version": "0.213.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz",
"integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.213.0",
"@opentelemetry/core": "2.6.0",
"@opentelemetry/resources": "2.6.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-metrics": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz",
"integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.6.0",
"@opentelemetry/resources": "2.6.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz",
"integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.6.0",
"@opentelemetry/resources": "2.6.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@grafana/faro-web-sdk": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@grafana/faro-web-sdk/-/faro-web-sdk-2.3.1.tgz",
"integrity": "sha512-WMfErl2YSP+CcfcobMpCdK6apX86hc8bymMXsvYLQpBBkQ0KJjIilEQS/YXd+g/cg6F1kwbeweisBKluNNy5sA==",
"license": "Apache-2.0",
"dependencies": {
"@grafana/faro-core": "^2.3.1",
"ua-parser-js": "1.0.41",
"web-vitals": "^5.1.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
@@ -7377,6 +7487,32 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "MIT",
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@@ -7540,6 +7676,12 @@
}
}
},
"node_modules/web-vitals": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -31,6 +31,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"@grafana/faro-web-sdk": "^2.3.1",
"@inlang/paraglide-js": "^2.15.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",

View File

@@ -1,5 +1,6 @@
import * as Sentry from '@sentry/sveltekit';
import { env } from '$env/dynamic/public';
import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
// Sentry / GlitchTip client-side error tracking.
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
@@ -13,4 +14,21 @@ if (env.PUBLIC_GLITCHTIP_DSN) {
});
}
// Grafana Faro RUM — browser performance monitoring (Web Vitals, traces, errors).
// No-op when PUBLIC_FARO_COLLECTOR_URL is unset (e.g. local dev).
if (env.PUBLIC_FARO_COLLECTOR_URL) {
initializeFaro({
url: env.PUBLIC_FARO_COLLECTOR_URL,
app: {
name: 'libnovel-ui',
version: env.PUBLIC_BUILD_VERSION || 'dev',
environment: 'production'
},
instrumentations: [
// Core Web Vitals (LCP, CLS, INP, TTFB, FCP) + JS errors + console
...getWebInstrumentations({ captureConsole: false })
]
});
}
export const handleError = Sentry.handleErrorWithSentry();

View File

@@ -875,6 +875,7 @@
<!-- Seekable progress bar -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer group"
onclick={seekFromCompactBar}
>

View File

@@ -18,6 +18,13 @@
}
}
function handleBackdropKeyDown(e: KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) {
open = false;
onclose?.();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
@@ -34,7 +41,9 @@
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleBackdropKeyDown}
>
<div class={cn('bg-(--color-surface) rounded-2xl border border-(--color-border) shadow-2xl w-full max-w-sm', className)}>
{@render children?.()}

View File

@@ -373,6 +373,7 @@ export * from './admin_nav_translation.js'
export * from './admin_nav_changelog.js'
export * from './admin_nav_image_gen.js'
export * from './admin_nav_text_gen.js'
export * from './admin_nav_catalogue_tools.js'
export * from './admin_nav_feedback.js'
export * from './admin_nav_errors.js'
export * from './admin_nav_analytics.js'
@@ -380,6 +381,7 @@ export * from './admin_nav_logs.js'
export * from './admin_nav_uptime.js'
export * from './admin_nav_push.js'
export * from './admin_nav_gitea.js'
export * from './admin_nav_grafana.js'
export * from './admin_scrape_status_idle.js'
export * from './admin_scrape_full_catalogue.js'
export * from './admin_scrape_single_book.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_Catalogue_ToolsInputs */
const en_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
const ru_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
const id_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
const pt_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
const fr_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
/**
* | output |
* | --- |
* | "Catalogue Tools" |
*
* @param {Admin_Nav_Catalogue_ToolsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_catalogue_tools = /** @type {((inputs?: Admin_Nav_Catalogue_ToolsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Catalogue_ToolsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_catalogue_tools(inputs)
if (locale === "ru") return ru_admin_nav_catalogue_tools(inputs)
if (locale === "id") return id_admin_nav_catalogue_tools(inputs)
if (locale === "pt") return pt_admin_nav_catalogue_tools(inputs)
return fr_admin_nav_catalogue_tools(inputs)
});

View File

@@ -9,21 +9,17 @@ const en_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => L
return /** @type {LocalizedString} */ (`Feedback`)
};
const ru_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Отзывы`)
};
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
const ru_admin_nav_feedback = en_admin_nav_feedback;
const id_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masukan`)
};
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
const id_admin_nav_feedback = en_admin_nav_feedback;
const pt_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feedback`)
};
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
const pt_admin_nav_feedback = en_admin_nav_feedback;
const fr_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Retours`)
};
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
const fr_admin_nav_feedback = en_admin_nav_feedback;
/**
* | output |

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_GrafanaInputs */
const en_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
const ru_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
const id_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
const pt_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
const fr_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
/**
* | output |
* | --- |
* | "Grafana" |
*
* @param {Admin_Nav_GrafanaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_grafana = /** @type {((inputs?: Admin_Nav_GrafanaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_GrafanaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_grafana(inputs)
if (locale === "ru") return ru_admin_nav_grafana(inputs)
if (locale === "id") return id_admin_nav_grafana(inputs)
if (locale === "pt") return pt_admin_nav_grafana(inputs)
return fr_admin_nav_grafana(inputs)
});

View File

@@ -54,8 +54,11 @@
let audioEl = $state<HTMLAudioElement | null>(null);
// ── Theme ──────────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
let currentTheme = $state(data.settings?.theme ?? 'amber');
// svelte-ignore state_referenced_locally
let currentFontFamily = $state(data.settings?.fontFamily ?? 'system');
// svelte-ignore state_referenced_locally
let currentFontSize = $state(data.settings?.fontSize ?? 1.0);
// Expose theme + font state to child pages (e.g. profile picker)
@@ -775,9 +778,10 @@
<!-- Chapter list drawer (slides up above the mini-bar) -->
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
<div class="border-b border-(--color-border) bg-(--color-surface) max-h-[32rem] overflow-y-auto">
<div class="max-w-6xl mx-auto px-4">
<div class="flex items-center justify-between py-2 border-b border-(--color-border) sticky top-0 bg-(--color-surface)">
<div class="border-b border-(--color-border) bg-(--color-surface) flex justify-center md:justify-end md:pr-4">
<div class="w-full md:w-80 flex flex-col max-h-72">
<!-- Sticky header -->
<div class="flex items-center justify-between px-4 py-2 border-b border-(--color-border) shrink-0">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
<Button
variant="ghost"
@@ -791,26 +795,29 @@
</svg>
</Button>
</div>
{#each audioStore.chapters as ch (ch.number)}
<a
use:setIfActive={ch.number === audioStore.chapter}
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
? 'text-(--color-brand) font-semibold'
: 'text-(--color-muted)'}"
>
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
{ch.number}
</span>
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</a>
{/each}
<!-- Scrollable list -->
<div class="overflow-y-auto px-4">
{#each audioStore.chapters as ch (ch.number)}
<a
use:setIfActive={ch.number === audioStore.chapter}
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
? 'text-(--color-brand) font-semibold'
: 'text-(--color-muted)'}"
>
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
{ch.number}
</span>
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</a>
{/each}
</div>
</div>
</div>
{/if}

View File

@@ -8,7 +8,8 @@
{ href: '/admin/translation', label: () => m.admin_nav_translation() },
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() }
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() },
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }
];
const externalLinks = [
@@ -18,6 +19,7 @@
{ href: 'https://logs.libnovel.cc', label: () => m.admin_nav_logs() },
{ href: 'https://uptime.libnovel.cc', label: () => m.admin_nav_uptime() },
{ href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() },
{ href: 'https://grafana.libnovel.cc', label: () => m.admin_nav_grafana() },
{ href: 'https://gitea.kalekber.cc/kamil/libnovel', label: () => m.admin_nav_gitea() }
];

View File

@@ -0,0 +1,24 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export interface ImageModelInfo {
id: string;
label: string;
provider: string;
}
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
let imgModels: ImageModelInfo[] = [];
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (res.ok) {
const data = await res.json();
imgModels = (data.models ?? []) as ImageModelInfo[];
}
} catch (e) {
log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) });
}
return { imgModels };
};

View File

@@ -0,0 +1,279 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// svelte-ignore state_referenced_locally
const imgModels = data.imgModels ?? [];
// ── Config persistence ────────────────────────────────────────────────────────
const CONFIG_KEY = 'admin_catalogue_tools_v1';
interface SavedConfig {
imgModel: string;
numSteps: number;
width: number;
height: number;
}
function loadConfig(): Partial<SavedConfig> {
if (!browser) return {};
try {
const raw = localStorage.getItem(CONFIG_KEY);
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
} catch { return {}; }
}
function saveConfig() {
if (!browser) return;
localStorage.setItem(CONFIG_KEY, JSON.stringify({ imgModel, numSteps, width, height }));
}
const saved = loadConfig();
let imgModel = $state(saved.imgModel ?? (imgModels[0]?.id ?? ''));
let numSteps = $state(saved.numSteps ?? 20);
let width = $state(saved.width ?? 0);
let height = $state(saved.height ?? 0);
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
// ── Batch covers ──────────────────────────────────────────────────────────────
let fromItem = $state(0);
let toItem = $state(0);
let resumeJobID = $state('');
let running = $state(false);
let jobID = $state('');
let done = $state(0);
let total = $state(0);
let events = $state<{ slug: string; skipped?: boolean; error?: string }[]>([]);
let finished = $state(false);
let error = $state('');
let cancelling = $state(false);
let progress = $derived(total > 0 ? Math.round((done / total) * 100) : 0);
async function startBatch() {
running = true; finished = false; error = ''; done = 0; total = 0; events = []; jobID = ''; cancelling = false;
try {
const res = await fetch('/api/admin/catalogue/batch-covers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: imgModel || undefined,
num_steps: numSteps || undefined,
width: width || undefined,
height: height || undefined,
from_item: fromItem || undefined,
to_item: toItem || undefined,
job_id: resumeJobID.trim() || undefined,
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
error = body.error ?? `Error ${res.status}`;
return;
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
outer: while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
let evt: {
job_id?: string;
done?: number;
total?: number;
slug?: string;
skipped?: boolean;
error?: string;
finish?: boolean;
};
try { evt = JSON.parse(payload); } catch { continue; }
if (evt.job_id) jobID = evt.job_id;
if (evt.total != null) total = evt.total;
if (evt.done != null) done = evt.done;
if (evt.finish) { finished = true; running = false; break outer; }
if (evt.slug) {
events = [{ slug: evt.slug, skipped: evt.skipped, error: evt.error }, ...events].slice(0, 200);
}
}
}
} catch (e) {
error = String(e);
} finally {
running = false;
}
}
async function cancelBatch() {
if (!jobID) return;
cancelling = true;
try {
await fetch('/api/admin/catalogue/batch-covers/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: jobID })
});
} catch { /* ignore */ } finally {
cancelling = false;
}
}
</script>
<svelte:head>
<title>Catalogue Tools — Admin</title>
</svelte:head>
<div class="space-y-8 max-w-4xl">
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Catalogue Tools</h1>
<p class="text-(--color-muted) text-sm mt-1">Bulk AI operations for your book catalogue.</p>
</div>
<!-- Batch cover generation -->
<div class="space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">Batch Cover Generation</h2>
<p class="text-sm text-(--color-muted)">
Generates AI covers for every book that has no cover stored in MinIO.
Books with existing covers are skipped. The job can be cancelled at any time.
</p>
<!-- Config -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 bg-(--color-surface) border border-(--color-border) rounded-xl p-4">
{#if imgModels.length > 0}
<div class="col-span-2 sm:col-span-4 space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="img-model">Image model</label>
<select id="img-model" bind:value={imgModel}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)">
{#each imgModels as m}
<option value={m.id}>{m.label} {m.provider}</option>
{/each}
</select>
</div>
{/if}
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="steps">Steps</label>
<input id="steps" type="number" bind:value={numSteps} min="1" max="50"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="width">Width <span class="font-normal">(0=default)</span></label>
<input id="width" type="number" bind:value={width} min="0" step="64"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="height">Height <span class="font-normal">(0=default)</span></label>
<input id="height" type="number" bind:value={height} min="0" step="64"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="from-item">From <span class="font-normal">(0=start)</span></label>
<input id="from-item" type="number" bind:value={fromItem} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="to-item">To <span class="font-normal">(0=end)</span></label>
<input id="to-item" type="number" bind:value={toItem} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="col-span-2 sm:col-span-4 space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="resume-job">Resume job ID <span class="font-normal">(leave blank to start fresh)</span></label>
<input id="resume-job" type="text" bind:value={resumeJobID} placeholder="optional PB job ID"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
</div>
<!-- Controls -->
<div class="flex gap-3">
<button
onclick={startBatch}
disabled={running}
class="px-6 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if running}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Running…
{:else}
Start batch
{/if}
</button>
{#if running && jobID}
<button
onclick={cancelBatch}
disabled={cancelling}
class="px-5 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) text-sm
hover:text-(--color-text) transition-colors disabled:opacity-50"
>
{cancelling ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
</div>
{#if error}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{error}</p>
{/if}
<!-- Progress -->
{#if total > 0 || running}
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-(--color-muted)">
{done} / {total} books processed
{#if finished} — done{/if}
</span>
<span class="text-(--color-muted)">{progress}%</span>
</div>
<div class="w-full h-2 bg-(--color-surface-2) rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300 {finished ? 'bg-green-500' : 'bg-(--color-brand)'}"
style="width: {progress}%"
></div>
</div>
</div>
{/if}
<!-- Event log -->
{#if events.length > 0}
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
<div class="px-4 py-2 border-b border-(--color-border)">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Activity log (newest first)</p>
</div>
<div class="max-h-80 overflow-y-auto divide-y divide-(--color-border)">
{#each events as evt}
<div class="px-4 py-2 flex items-center gap-3 text-sm">
{#if evt.error}
<span class="w-2 h-2 rounded-full bg-(--color-danger) shrink-0"></span>
<span class="font-mono text-(--color-muted)">{evt.slug}</span>
<span class="text-(--color-danger) text-xs truncate">{evt.error}</span>
{:else if evt.skipped}
<span class="w-2 h-2 rounded-full bg-(--color-surface-2) shrink-0"></span>
<span class="font-mono text-(--color-muted)">{evt.slug}</span>
<span class="text-xs text-(--color-muted)">skipped (has cover)</span>
{:else}
<span class="w-2 h-2 rounded-full bg-green-500 shrink-0"></span>
<span class="font-mono text-(--color-text)">{evt.slug}</span>
<span class="text-xs text-green-400">generated</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
export interface ImageModelInfo {
id: string;
@@ -11,18 +12,36 @@ export interface ImageModelInfo {
description: string;
}
export interface BookSummary {
slug: string;
title: string;
summary: string;
cover: string;
}
export const load: PageServerLoad = async () => {
// parent layout already guards admin role
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) {
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
return { models: [] as ImageModelInfo[] };
}
const data = await res.json();
return { models: (data.models ?? []) as ImageModelInfo[] };
} catch (e) {
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
return { models: [] as ImageModelInfo[] };
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as ImageModelInfo[];
})(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
};
};

View File

@@ -1,26 +1,121 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { ImageModelInfo } from './+page.server';
import type { ImageModelInfo, BookSummary } from './+page.server';
let { data }: { data: PageData } = $props();
// ── Form state ───────────────────────────────────────────────────────────────
type ImageType = 'cover' | 'chapter';
const CONFIG_KEY = 'admin_image_gen_config_v1';
interface SavedConfig {
selectedModel: string;
numSteps: number;
guidance: number;
strength: number;
width: number;
height: number;
showAdvanced: boolean;
}
function loadConfig(): Partial<SavedConfig> {
if (!browser) return {};
try {
const raw = localStorage.getItem(CONFIG_KEY);
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
} catch { return {}; }
}
function saveConfig() {
if (!browser) return;
const cfg: SavedConfig = { selectedModel, numSteps, guidance, strength, width, height, showAdvanced };
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
}
const saved = loadConfig();
let imageType = $state<ImageType>('cover');
let slug = $state('');
let chapter = $state<number>(1);
let selectedModel = $state('');
let selectedModel = $state(saved.selectedModel ?? '');
let prompt = $state('');
let referenceFile = $state<File | null>(null);
let referencePreviewUrl = $state('');
let useCoverAsRef = $state(false);
// Advanced
let showAdvanced = $state(false);
let numSteps = $state(20);
let guidance = $state(7.5);
let strength = $state(0.75);
let width = $state(1024);
let height = $state(1024);
let showAdvanced = $state(saved.showAdvanced ?? false);
let numSteps = $state(saved.numSteps ?? 20);
let guidance = $state(saved.guidance ?? 7.5);
let strength = $state(saved.strength ?? 0.75);
let width = $state(saved.width ?? 1024);
let height = $state(saved.height ?? 1024);
// Persist config on change
$effect(() => {
void selectedModel; void numSteps; void guidance; void strength;
void width; void height; void showAdvanced;
saveConfig();
});
// ── Book autocomplete ────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
const books: BookSummary[] = data.books ?? [];
let slugInput = $state('');
let slugFocused = $state(false);
let selectedBook = $state<BookSummary | null>(null);
let bookSuggestions = $derived(
slugInput.trim().length === 0
? []
: books
.filter((b) =>
b.slug.includes(slugInput.toLowerCase()) ||
b.title.toLowerCase().includes(slugInput.toLowerCase())
)
.slice(0, 8)
);
function selectBook(b: BookSummary) {
selectedBook = b;
slug = b.slug;
slugInput = b.slug;
slugFocused = false;
// Reset cover-as-ref if no cover
if (!b.cover) useCoverAsRef = false;
}
function onSlugInput() {
slug = slugInput;
// If user edits away from selected book slug, deselect
if (selectedBook && slugInput !== selectedBook.slug) {
selectedBook = null;
useCoverAsRef = false;
}
}
// When useCoverAsRef toggled on, load the book cover as reference
$effect(() => {
if (!browser) return;
if (!useCoverAsRef || !selectedBook?.cover) {
if (useCoverAsRef) useCoverAsRef = false;
return;
}
// Fetch the cover image and set as referenceFile
(async () => {
try {
const res = await fetch(selectedBook!.cover);
const blob = await res.blob();
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
const file = new File([blob], `${selectedBook!.slug}-cover.${ext}`, { type: blob.type });
handleReferenceFile(file);
} catch {
useCoverAsRef = false;
}
})();
});
// ── Generation state ─────────────────────────────────────────────────────────
let generating = $state(false);
@@ -50,18 +145,13 @@
let saveSuccess = $state(false);
// ── Model helpers ────────────────────────────────────────────────────────────
const models = data.models as ImageModelInfo[];
// svelte-ignore state_referenced_locally
const models: ImageModelInfo[] = data.models ?? [];
let filteredModels = $derived(
referenceFile
? models // show all; warn on ones without ref support
: models
);
let coverModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('cover')));
let chapterModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('chapter')));
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
let otherModels = $derived(
filteredModels.filter(
models.filter(
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
)
);
@@ -74,12 +164,10 @@
}
});
// Reset model selection when type changes if current selection no longer fits
$effect(() => {
void imageType; // track
void imageType;
const preferred = imageType === 'cover' ? coverModels : chapterModels;
if (preferred.length > 0) {
// only auto-switch if current model isn't in preferred list for this type
const current = models.find((m) => m.id === selectedModel);
if (!current || !current.recommended_for.includes(imageType)) {
selectedModel = preferred[0].id;
@@ -90,14 +178,96 @@
// ── Prompt templates ────────────────────────────────────────────────────────
let promptTemplate = $derived(
imageType === 'cover'
? `Book cover for "${slug || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
: `Illustration for chapter ${chapter} of "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
? `Book cover for "${slugInput || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
: `Illustration for chapter ${chapter} of "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
);
function applyTemplate() {
prompt = promptTemplate;
}
function injectDescription() {
const desc = selectedBook?.summary?.trim();
if (!desc) return;
const snippet = desc.length > 300 ? desc.slice(0, 300) + '…' : desc;
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
}
// ── Auto-prompt ──────────────────────────────────────────────────────────────
let autoPrompting = $state(false);
let autoPromptError = $state('');
async function autoGeneratePrompt() {
if (!slug.trim() || autoPrompting) return;
autoPrompting = true;
autoPromptError = '';
try {
const res = await fetch('/api/admin/image-gen/auto-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: slug.trim(), type: imageType, chapter: imageType === 'chapter' ? chapter : 0 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
autoPromptError = body.error ?? `Error ${res.status}`;
return;
}
prompt = body.prompt ?? '';
} catch {
autoPromptError = 'Network error.';
} finally {
autoPrompting = false;
}
}
// ── Style presets ────────────────────────────────────────────────────────────
const PRESETS_KEY = 'admin_image_gen_presets_v1';
interface StylePreset {
name: string;
prompt: string;
}
function loadPresets(): StylePreset[] {
if (!browser) return [];
try {
const raw = localStorage.getItem(PRESETS_KEY);
return raw ? (JSON.parse(raw) as StylePreset[]) : [];
} catch { return []; }
}
function savePresets(p: StylePreset[]) {
if (!browser) return;
localStorage.setItem(PRESETS_KEY, JSON.stringify(p));
}
let presets = $state<StylePreset[]>(loadPresets());
let newPresetName = $state('');
let showPresets = $state(false);
function saveCurrentAsPreset() {
const name = newPresetName.trim();
if (!name || !prompt.trim()) return;
const existing = presets.findIndex((p) => p.name === name);
const updated = [...presets];
if (existing >= 0) updated[existing] = { name, prompt };
else updated.push({ name, prompt });
presets = updated;
savePresets(updated);
newPresetName = '';
}
function applyPreset(p: StylePreset) {
prompt = p.prompt;
showPresets = false;
}
function deletePreset(name: string) {
const updated = presets.filter((p) => p.name !== name);
presets = updated;
savePresets(updated);
}
// ── Reference image handling ─────────────────────────────────────────────────
let dragOver = $state(false);
@@ -110,17 +280,22 @@
function onFileInput(e: Event) {
const input = e.target as HTMLInputElement;
handleReferenceFile(input.files?.[0] ?? null);
useCoverAsRef = false;
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
if (file && file.type.startsWith('image/')) {
handleReferenceFile(file);
useCoverAsRef = false;
}
}
function clearReference() {
handleReferenceFile(null);
useCoverAsRef = false;
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
if (input) input.value = '';
}
@@ -219,7 +394,6 @@
saveSuccess = false;
try {
// Extract the raw base64 from the data URL (data:<mime>;base64,<b64>)
const b64 = result.imageSrc.split(',')[1];
const res = await fetch('/api/admin/image-gen/save-cover', {
method: 'POST',
@@ -308,13 +482,63 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
Book slug
</label>
<input
id="slug-input"
type="text"
bind:value={slug}
placeholder="e.g. shadow-slave"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<!-- Autocomplete wrapper -->
<div class="relative">
<input
id="slug-input"
type="text"
bind:value={slugInput}
oninput={onSlugInput}
onfocus={() => (slugFocused = true)}
onblur={() => setTimeout(() => { slugFocused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if slugFocused && bookSuggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each bookSuggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={selectedBook?.slug === b.slug}
onmousedown={() => selectBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
{#if b.cover}
<img src={b.cover} alt="" class="w-8 h-10 object-cover rounded shrink-0" />
{:else}
<div class="w-8 h-10 rounded bg-(--color-surface-3) shrink-0"></div>
{/if}
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) truncate font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Book info pill when a book is selected -->
{#if selectedBook}
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-(--color-success) flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{selectedBook.title}
</span>
{#if selectedBook.summary}
<button
onclick={injectDescription}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
title="Append book description to prompt"
>
+ inject description
</button>
{/if}
</div>
{/if}
</div>
{#if imageType === 'chapter'}
@@ -380,18 +604,45 @@
</div>
<!-- Prompt -->
<div class="space-y-1">
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
Prompt
</label>
<button
onclick={applyTemplate}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
>
Use template
</button>
<div class="flex flex-wrap items-center gap-3">
{#if slug.trim()}
<button onclick={autoGeneratePrompt} disabled={autoPrompting}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors disabled:opacity-50">
{autoPrompting ? 'Generating…' : 'Auto-prompt'}
</button>
{/if}
{#if selectedBook?.summary}
<button onclick={injectDescription} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
Inject description
</button>
{/if}
<button onclick={applyTemplate} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
Use template
</button>
{#if presets.length > 0}
<button onclick={() => (showPresets = !showPresets)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
Presets ({presets.length})
</button>
{/if}
</div>
</div>
{#if showPresets && presets.length > 0}
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3 space-y-1.5">
{#each presets as p}
<div class="flex items-center gap-2 group">
<button onclick={() => applyPreset(p)} class="flex-1 text-left text-sm text-(--color-text) hover:text-(--color-brand) transition-colors truncate" title={p.prompt}>{p.name}</button>
<button onclick={() => deletePreset(p.name)} class="text-xs text-(--color-muted) hover:text-(--color-danger) transition-colors opacity-0 group-hover:opacity-100">delete</button>
</div>
{/each}
</div>
{/if}
<textarea
id="prompt-input"
bind:value={prompt}
@@ -399,13 +650,42 @@
placeholder="Describe the image to generate…"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand) resize-y"
></textarea>
{#if autoPromptError}
<p class="text-xs text-(--color-danger)">{autoPromptError}</p>
{/if}
<div class="flex gap-2">
<input type="text" bind:value={newPresetName} placeholder="Preset name…"
class="flex-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-(--color-text) text-xs placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
<button onclick={saveCurrentAsPreset} disabled={!newPresetName.trim() || !prompt.trim()}
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
Save preset
</button>
</div>
</div>
<!-- Reference image drop zone -->
<div class="space-y-1">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
</p>
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
</p>
{#if selectedBook?.cover && selectedModelInfo?.supports_ref}
<div class="flex items-center gap-1.5 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={useCoverAsRef}
aria-label="Use book cover as reference"
onclick={() => (useCoverAsRef = !useCoverAsRef)}
class="w-8 h-4 rounded-full transition-colors relative focus:outline-none focus:ring-1 focus:ring-(--color-brand) {useCoverAsRef ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
>
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform {useCoverAsRef ? 'translate-x-4' : ''}"></span>
</button>
<span class="text-xs text-(--color-muted)">Use book cover</span>
</div>
{/if}
</div>
{#if referenceFile && referencePreviewUrl}
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
<img
@@ -416,6 +696,9 @@
<div class="min-w-0 flex-1 space-y-0.5">
<p class="text-sm text-(--color-text) truncate">{referenceFile.name}</p>
<p class="text-xs text-(--color-muted)">{fmtBytes(referenceFile.size)}</p>
{#if useCoverAsRef}
<p class="text-xs text-(--color-brand)">Current book cover</p>
{/if}
</div>
<button
onclick={clearReference}
@@ -474,20 +757,20 @@
<!-- num_steps -->
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Steps</label>
<label for="img-steps" class="text-xs text-(--color-muted)">Steps</label>
<span class="text-xs text-(--color-text) font-mono">{numSteps}</span>
</div>
<input type="range" min="1" max="20" step="1" bind:value={numSteps}
<input id="img-steps" type="range" min="1" max="20" step="1" bind:value={numSteps}
class="w-full accent-(--color-brand)" />
</div>
<!-- guidance -->
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Guidance</label>
<label for="img-guidance" class="text-xs text-(--color-muted)">Guidance</label>
<span class="text-xs text-(--color-text) font-mono">{guidance.toFixed(1)}</span>
</div>
<input type="range" min="1" max="20" step="0.5" bind:value={guidance}
<input id="img-guidance" type="range" min="1" max="20" step="0.5" bind:value={guidance}
class="w-full accent-(--color-brand)" />
</div>
@@ -495,10 +778,10 @@
{#if referenceFile}
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Strength</label>
<label for="img-strength" class="text-xs text-(--color-muted)">Strength</label>
<span class="text-xs text-(--color-text) font-mono">{strength.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.05" bind:value={strength}
<input id="img-strength" type="range" min="0" max="1" step="0.05" bind:value={strength}
class="w-full accent-(--color-brand)" />
<p class="text-xs text-(--color-muted)">0 = copy reference · 1 = ignore reference</p>
</div>
@@ -530,7 +813,6 @@
flex items-center justify-center gap-2"
>
{#if generating}
<!-- Spinner -->
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
@@ -553,7 +835,8 @@
<!-- Image -->
<img
src={result.imageSrc}
alt="Generated image"
alt=""
aria-label="Generated cover"
class="w-full object-contain max-h-[36rem] bg-zinc-950"
/>

View File

@@ -1,6 +1,12 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
export interface BookSummary {
slug: string;
title: string;
}
export interface TextModelInfo {
id: string;
@@ -12,16 +18,25 @@ export interface TextModelInfo {
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
try {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) {
log.warn('admin/text-gen', 'failed to load models', { status: res.status });
return { models: [] as TextModelInfo[] };
}
const data = await res.json();
return { models: (data.models ?? []) as TextModelInfo[] };
} catch (e) {
log.warn('admin/text-gen', 'backend unreachable', { err: String(e) });
return { models: [] as TextModelInfo[] };
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as TextModelInfo[];
})(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
slug: b.slug,
title: b.title
})) as BookSummary[]
};
};

View File

@@ -1,26 +1,88 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { TextModelInfo } from './+page.server';
import type { TextModelInfo, BookSummary } from './+page.server';
let { data }: { data: PageData } = $props();
const models = data.models as TextModelInfo[];
// Server data is static per page load — intentional one-time snapshot.
// svelte-ignore state_referenced_locally
const models: TextModelInfo[] = data.models ?? [];
// svelte-ignore state_referenced_locally
const books: BookSummary[] = data.books ?? [];
// ── Config persistence ───────────────────────────────────────────────────────
const CONFIG_KEY = 'admin_text_gen_config_v2';
interface SavedConfig {
selectedModel: string;
activeTab: string;
chPattern: string;
dInstructions: string;
}
function loadConfig(): Partial<SavedConfig> {
if (!browser) return {};
try {
const raw = localStorage.getItem(CONFIG_KEY);
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
} catch { return {}; }
}
function saveConfig() {
if (!browser) return;
const cfg: SavedConfig = { selectedModel, activeTab, chPattern, dInstructions };
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
}
const saved = loadConfig();
// ── Shared ────────────────────────────────────────────────────────────────────
type ActiveTab = 'chapters' | 'description';
let activeTab = $state<ActiveTab>('chapters');
let selectedModel = $state(models[0]?.id ?? '');
type ActiveTab = 'chapters' | 'description' | 'tagline' | 'genres' | 'warnings' | 'quality';
let activeTab = $state<ActiveTab>((saved.activeTab as ActiveTab) ?? 'chapters');
let selectedModel = $state(saved.selectedModel ?? (models[0]?.id ?? ''));
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
$effect(() => { void selectedModel; void activeTab; void chPattern; void dInstructions; saveConfig(); });
function fmtCtx(n: number) {
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
return `${n} ctx`;
}
// ── Book autocomplete (shared component logic) ───────────────────────────────
function makeBookAC() {
let inputVal = $state('');
let focused = $state(false);
const suggestions = $derived(
inputVal.trim().length === 0
? []
: books
.filter((b) =>
b.slug.includes(inputVal.toLowerCase()) ||
b.title.toLowerCase().includes(inputVal.toLowerCase())
)
.slice(0, 8)
);
return {
get inputVal() { return inputVal; },
set inputVal(v: string) { inputVal = v; },
get focused() { return focused; },
set focused(v: boolean) { focused = v; },
get suggestions() { return suggestions; }
};
}
// ── Chapter names state ───────────────────────────────────────────────────────
let chAC = makeBookAC();
let chSlug = $state('');
let chPattern = $state('Chapter {n}: {scene}');
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
let chFromChapter = $state(0);
let chToChapter = $state(0);
let chJobID = $state('');
let chGenerating = $state(false);
let chError = $state('');
@@ -28,13 +90,13 @@
number: number;
old_title: string;
new_title: string;
// editable copy
edited: string;
}
let chProposals = $state<ProposedChapter[]>([]);
let chRawResponse = $state('');
let chUsedModel = $state('');
let chShowRaw = $state(false);
let chBatchProgress = $state('');
let chBatchWarnings = $state<string[]>([]);
let chApplying = $state(false);
let chApplyError = $state('');
@@ -43,13 +105,27 @@
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
let chCanApply = $derived(chProposals.length > 0 && !chApplying);
function selectChBook(b: BookSummary) {
chSlug = b.slug;
chAC.inputVal = b.slug;
chAC.focused = false;
}
function onChSlugInput() {
chSlug = chAC.inputVal;
}
async function generateChapterNames() {
if (!chCanGenerate) return;
chGenerating = true;
chError = '';
chProposals = [];
chUsedModel = '';
chBatchProgress = '';
chBatchWarnings = [];
chApplySuccess = false;
chApplyError = '';
chJobID = '';
try {
const res = await fetch('/api/admin/text-gen/chapter-names', {
@@ -58,23 +134,85 @@
body: JSON.stringify({
slug: chSlug.trim(),
pattern: chPattern.trim(),
model: selectedModel
model: selectedModel,
from_chapter: chFromChapter || undefined,
to_chapter: chToChapter || undefined
})
});
const body = await res.json().catch(() => ({}));
// Non-SSE error response (e.g. 400/404/502 before streaming started).
if (!res.ok) {
const body = await res.json().catch(() => ({}));
chError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
chProposals = ((body.chapters ?? []) as { number: number; old_title: string; new_title: string }[]).map(
(p) => ({ ...p, edited: p.new_title })
);
chRawResponse = body.raw_response ?? '';
chUsedModel = body.model ?? '';
// If backend returned chapters:[] but we have a raw response, the model
// output was unparseable (likely truncated). Treat it as an error.
if (chProposals.length === 0 && chRawResponse.trim().length > 0) {
chError = 'Model response could not be parsed (output may be truncated). Raw response shown below.';
// Stream SSE events line by line.
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
outer: while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process all complete SSE messages in the buffer.
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // keep incomplete last line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
let evt: {
job_id?: string;
batch?: number;
total_batches?: number;
chapters_done?: number;
total_chapters?: number;
model?: string;
chapters?: { number: number; old_title: string; new_title: string }[];
error?: string;
done?: boolean;
};
try {
evt = JSON.parse(payload);
} catch {
continue;
}
if (evt.job_id) chJobID = evt.job_id;
if (evt.done) {
chBatchProgress = `Done — ${evt.total_chapters ?? chProposals.length} chapters`;
chGenerating = false;
break outer;
}
if (evt.model) chUsedModel = evt.model;
if (evt.error) {
chBatchWarnings = [
...chBatchWarnings,
`Batch ${evt.batch}/${evt.total_batches} failed: ${evt.error}`
];
} else if (evt.chapters) {
const incoming = (evt.chapters as { number: number; old_title: string; new_title: string }[]).map(
(p) => ({ ...p, edited: p.new_title })
);
chProposals = [...chProposals, ...incoming];
}
if (evt.batch != null && evt.total_batches != null) {
chBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chProposals.length}/${evt.total_chapters ?? '?'} chapters`;
}
}
}
if (chProposals.length === 0 && chBatchWarnings.length === 0) {
chError = 'No proposals returned. The model may have failed to parse the chapters.';
}
} catch {
chError = 'Network error.';
@@ -112,8 +250,9 @@
}
// ── Description state ─────────────────────────────────────────────────────────
let dAC = makeBookAC();
let dSlug = $state('');
let dInstructions = $state('');
let dInstructions = $state(saved.dInstructions ?? '');
let dGenerating = $state(false);
let dError = $state('');
@@ -128,6 +267,16 @@
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
let dCanApply = $derived(dNewDesc.trim().length > 0 && !dApplying);
function selectDBook(b: BookSummary) {
dSlug = b.slug;
dAC.inputVal = b.slug;
dAC.focused = false;
}
function onDSlugInput() {
dSlug = dAC.inputVal;
}
async function generateDescription() {
if (!dCanGenerate) return;
dGenerating = true;
@@ -189,6 +338,142 @@
dApplying = false;
}
}
// ── Tagline state ─────────────────────────────────────────────────────────────
let tAC = makeBookAC();
let tSlug = $state('');
let tGenerating = $state(false);
let tError = $state('');
let tResult = $state('');
let tUsedModel = $state('');
let tCanGenerate = $derived(tSlug.trim().length > 0 && !tGenerating);
function selectTBook(b: BookSummary) { tSlug = b.slug; tAC.inputVal = b.slug; tAC.focused = false; }
function onTSlugInput() { tSlug = tAC.inputVal; }
async function generateTagline() {
if (!tCanGenerate) return;
tGenerating = true; tError = ''; tResult = '';
try {
const res = await fetch('/api/admin/text-gen/tagline', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: tSlug.trim(), model: selectedModel })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { tError = body.error ?? `Error ${res.status}`; return; }
tResult = body.new_tagline ?? '';
tUsedModel = body.model ?? '';
} catch { tError = 'Network error.'; } finally { tGenerating = false; }
}
// ── Genres state ──────────────────────────────────────────────────────────────
let gAC = makeBookAC();
let gSlug = $state('');
let gGenerating = $state(false);
let gError = $state('');
let gCurrent = $state<string[]>([]);
let gProposed = $state<string[]>([]);
let gEdited = $state<string[]>([]);
let gUsedModel = $state('');
let gApplying = $state(false);
let gApplyError = $state('');
let gApplySuccess = $state(false);
let gCanGenerate = $derived(gSlug.trim().length > 0 && !gGenerating);
let gCanApply = $derived(gEdited.length > 0 && !gApplying);
function selectGBook(b: BookSummary) { gSlug = b.slug; gAC.inputVal = b.slug; gAC.focused = false; }
function onGSlugInput() { gSlug = gAC.inputVal; }
async function generateGenres() {
if (!gCanGenerate) return;
gGenerating = true; gError = ''; gCurrent = []; gProposed = []; gEdited = []; gApplySuccess = false;
try {
const res = await fetch('/api/admin/text-gen/genres', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: gSlug.trim(), model: selectedModel })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { gError = body.error ?? `Error ${res.status}`; return; }
gCurrent = body.current_genres ?? [];
gProposed = body.proposed_genres ?? [];
gEdited = [...gProposed];
gUsedModel = body.model ?? '';
} catch { gError = 'Network error.'; } finally { gGenerating = false; }
}
async function applyGenres() {
if (!gCanApply) return;
gApplying = true; gApplyError = ''; gApplySuccess = false;
try {
const res = await fetch('/api/admin/text-gen/genres/apply', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: gSlug.trim(), genres: gEdited.filter(Boolean) })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { gApplyError = body.error ?? `Error ${res.status}`; return; }
gApplySuccess = true;
} catch { gApplyError = 'Network error.'; } finally { gApplying = false; }
}
// ── Content warnings state ────────────────────────────────────────────────────
let wAC = makeBookAC();
let wSlug = $state('');
let wGenerating = $state(false);
let wError = $state('');
let wWarnings = $state<string[]>([]);
let wUsedModel = $state('');
let wCanGenerate = $derived(wSlug.trim().length > 0 && !wGenerating);
function selectWBook(b: BookSummary) { wSlug = b.slug; wAC.inputVal = b.slug; wAC.focused = false; }
function onWSlugInput() { wSlug = wAC.inputVal; }
async function generateWarnings() {
if (!wCanGenerate) return;
wGenerating = true; wError = ''; wWarnings = [];
try {
const res = await fetch('/api/admin/text-gen/content-warnings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: wSlug.trim(), model: selectedModel })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { wError = body.error ?? `Error ${res.status}`; return; }
wWarnings = body.warnings ?? [];
wUsedModel = body.model ?? '';
} catch { wError = 'Network error.'; } finally { wGenerating = false; }
}
// ── Quality score state ───────────────────────────────────────────────────────
let qAC = makeBookAC();
let qSlug = $state('');
let qGenerating = $state(false);
let qError = $state('');
let qScore = $state(0);
let qFeedback = $state('');
let qUsedModel = $state('');
let qCanGenerate = $derived(qSlug.trim().length > 0 && !qGenerating);
function selectQBook(b: BookSummary) { qSlug = b.slug; qAC.inputVal = b.slug; qAC.focused = false; }
function onQSlugInput() { qSlug = qAC.inputVal; }
async function generateQualityScore() {
if (!qCanGenerate) return;
qGenerating = true; qError = ''; qScore = 0; qFeedback = '';
try {
const res = await fetch('/api/admin/text-gen/quality-score', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: qSlug.trim(), model: selectedModel })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { qError = body.error ?? `Error ${res.status}`; return; }
qScore = body.score ?? 0;
qFeedback = body.feedback ?? '';
qUsedModel = body.model ?? '';
} catch { qError = 'Network error.'; } finally { qGenerating = false; }
}
</script>
<svelte:head>
@@ -224,16 +509,23 @@
</div>
<!-- Tab toggle -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
{#each (['chapters', 'description'] as const) as t}
<div class="flex flex-wrap gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
{#each ([
['chapters', 'Chapter Names'],
['description', 'Description'],
['tagline', 'Tagline'],
['genres', 'Genres'],
['warnings', 'Warnings'],
['quality', 'Quality'],
] as const) as [t, label]}
<button
onclick={() => (activeTab = t)}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === t
? 'bg-(--color-surface-3) text-(--color-text)'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>
{t === 'chapters' ? 'Chapter Names' : 'Description'}
{label}
</button>
{/each}
</div>
@@ -247,13 +539,37 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
Book slug
</label>
<input
id="ch-slug"
type="text"
bind:value={chSlug}
placeholder="e.g. shadow-slave"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<div class="relative">
<input
id="ch-slug"
type="text"
bind:value={chAC.inputVal}
oninput={onChSlugInput}
onfocus={() => (chAC.focused = true)}
onblur={() => setTimeout(() => { chAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if chAC.focused && chAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each chAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={chSlug === b.slug}
onmousedown={() => selectChBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="space-y-1">
@@ -272,6 +588,27 @@
</p>
</div>
<!-- Range (optional) -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-from">
From chapter <span class="font-normal">(0=all)</span>
</label>
<input id="ch-from" type="number" bind:value={chFromChapter} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-to">
To chapter <span class="font-normal">(0=all)</span>
</label>
<input id="ch-to" type="number" bind:value={chToChapter} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
</div>
{#if chJobID}
<p class="text-xs text-(--color-muted)">Job: <span class="font-mono">{chJobID}</span></p>
{/if}
<button
onclick={generateChapterNames}
disabled={!chCanGenerate}
@@ -292,9 +629,6 @@
{#if chError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
{#if chRawResponse}
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-48 text-(--color-muted) whitespace-pre-wrap break-words">{chRawResponse}</pre>
{/if}
{/if}
</div>
@@ -309,16 +643,25 @@
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
{/if}
</p>
<button
onclick={() => (chShowRaw = !chShowRaw)}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
{chShowRaw ? 'Hide raw' : 'Show raw'}
</button>
{#if chBatchProgress}
<span class="text-xs text-(--color-muted) flex items-center gap-1.5">
{#if chGenerating}
<svg class="w-3 h-3 animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
{/if}
{chBatchProgress}
</span>
{/if}
</div>
{#if chShowRaw}
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-40 text-(--color-muted)">{chRawResponse}</pre>
{#if chBatchWarnings.length > 0}
<div class="space-y-1">
{#each chBatchWarnings as w}
<p class="text-xs text-amber-400 bg-amber-400/10 rounded-lg px-3 py-2">{w}</p>
{/each}
</div>
{/if}
<div class="max-h-[28rem] overflow-y-auto space-y-2 pr-1">
@@ -349,7 +692,7 @@
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
</button>
<button
onclick={() => { chProposals = []; chRawResponse = ''; chApplySuccess = false; }}
onclick={() => { chProposals = []; chBatchProgress = ''; chBatchWarnings = []; chApplySuccess = false; }}
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
hover:text-(--color-text) transition-colors border border-(--color-border)"
>
@@ -373,7 +716,9 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<p class="text-sm text-(--color-muted)">Generating chapter titles…</p>
<p class="text-sm text-(--color-muted)">
{chBatchProgress || 'Generating chapter titles…'}
</p>
</div>
</div>
{:else}
@@ -394,13 +739,37 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
Book slug
</label>
<input
id="d-slug"
type="text"
bind:value={dSlug}
placeholder="e.g. shadow-slave"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<div class="relative">
<input
id="d-slug"
type="text"
bind:value={dAC.inputVal}
oninput={onDSlugInput}
onfocus={() => (dAC.focused = true)}
onblur={() => setTimeout(() => { dAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if dAC.focused && dAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each dAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={dSlug === b.slug}
onmousedown={() => selectDBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="space-y-1">
@@ -513,4 +882,305 @@
</div>
</div>
{/if}
<!-- ── Tagline panel ──────────────────────────────────────────────────────── -->
{#if activeTab === 'tagline'}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div class="space-y-4">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="t-slug">Book slug</label>
<div class="relative">
<input id="t-slug" type="text" bind:value={tAC.inputVal} oninput={onTSlugInput}
onfocus={() => (tAC.focused = true)} onblur={() => setTimeout(() => { tAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave" autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
{#if tAC.focused && tAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each tAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li role="option" aria-selected={tSlug === b.slug} onmousedown={() => selectTBook(b)}
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<button onclick={generateTagline} disabled={!tCanGenerate}
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
{#if tGenerating}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>Generating…
{:else}Generate tagline{/if}
</button>
{#if tError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{tError}</p>{/if}
</div>
<div>
{#if tResult}
<div class="space-y-2">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
Tagline{#if tUsedModel}<span class="normal-case font-normal"> · {tUsedModel.split('/').pop()}</span>{/if}
</p>
<div class="bg-(--color-surface) border border-(--color-brand)/40 rounded-xl p-4">
<p class="text-base italic text-(--color-text)">{tResult}</p>
</div>
<button onclick={() => { tResult = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
</div>
{:else if tGenerating}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
</div>
{:else}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
<p class="text-sm text-(--color-muted)">Tagline will appear here</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- ── Genres panel ────────────────────────────────────────────────────────── -->
{#if activeTab === 'genres'}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div class="space-y-4">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="g-slug">Book slug</label>
<div class="relative">
<input id="g-slug" type="text" bind:value={gAC.inputVal} oninput={onGSlugInput}
onfocus={() => (gAC.focused = true)} onblur={() => setTimeout(() => { gAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave" autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
{#if gAC.focused && gAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each gAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li role="option" aria-selected={gSlug === b.slug} onmousedown={() => selectGBook(b)}
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<button onclick={generateGenres} disabled={!gCanGenerate}
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
{#if gGenerating}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>Generating…
{:else}Suggest genres{/if}
</button>
{#if gError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{gError}</p>{/if}
</div>
<div class="space-y-4">
{#if gProposed.length > 0}
<div class="space-y-3">
{#if gCurrent.length > 0}
<div>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-1.5">Current genres</p>
<div class="flex flex-wrap gap-1.5">
{#each gCurrent as g}
<span class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{g}</span>
{/each}
</div>
</div>
{/if}
<div>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-1.5">
Proposed{#if gUsedModel}<span class="normal-case font-normal"> · {gUsedModel.split('/').pop()}</span>{/if}
</p>
<div class="flex flex-wrap gap-1.5">
{#each gEdited as g, i}
<input type="text" bind:value={gEdited[i]}
class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
{/each}
<button onclick={() => { gEdited = [...gEdited, '']; }}
class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) transition-colors">+ add</button>
</div>
</div>
<div class="flex gap-2 pt-1">
<button onclick={applyGenres} disabled={!gCanApply}
class="flex-1 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{gApplying ? 'Saving…' : gApplySuccess ? 'Saved ✓' : 'Apply genres'}
</button>
<button onclick={() => { gProposed = []; gEdited = []; gApplySuccess = false; }}
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm hover:text-(--color-text) transition-colors border border-(--color-border)">
Clear
</button>
</div>
{#if gApplyError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{gApplyError}</p>{/if}
{#if gApplySuccess}<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">Genres saved successfully.</p>{/if}
</div>
{:else if gGenerating}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-32">
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
</div>
{:else}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-32">
<p class="text-sm text-(--color-muted)">Genre suggestions will appear here</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- ── Content warnings panel ─────────────────────────────────────────────── -->
{#if activeTab === 'warnings'}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div class="space-y-4">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="w-slug">Book slug</label>
<div class="relative">
<input id="w-slug" type="text" bind:value={wAC.inputVal} oninput={onWSlugInput}
onfocus={() => (wAC.focused = true)} onblur={() => setTimeout(() => { wAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave" autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
{#if wAC.focused && wAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each wAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li role="option" aria-selected={wSlug === b.slug} onmousedown={() => selectWBook(b)}
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<button onclick={generateWarnings} disabled={!wCanGenerate}
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
{#if wGenerating}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>Detecting…
{:else}Detect content warnings{/if}
</button>
{#if wError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{wError}</p>{/if}
</div>
<div>
{#if wWarnings.length > 0}
<div class="space-y-2">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
Detected warnings{#if wUsedModel}<span class="normal-case font-normal"> · {wUsedModel.split('/').pop()}</span>{/if}
</p>
<div class="flex flex-wrap gap-2">
{#each wWarnings as w}
<span class="px-3 py-1 rounded-full text-sm bg-amber-400/10 text-amber-400 border border-amber-400/30">{w}</span>
{/each}
</div>
<button onclick={() => { wWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
</div>
{:else if wWarnings.length === 0 && wUsedModel}
<div class="bg-green-400/10 border border-green-400/30 rounded-xl p-4">
<p class="text-sm text-green-400">No content warnings detected.</p>
</div>
{:else if wGenerating}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
</div>
{:else}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
<p class="text-sm text-(--color-muted)">Warnings will appear here</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- ── Quality score panel ────────────────────────────────────────────────── -->
{#if activeTab === 'quality'}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div class="space-y-4">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="q-slug">Book slug</label>
<div class="relative">
<input id="q-slug" type="text" bind:value={qAC.inputVal} oninput={onQSlugInput}
onfocus={() => (qAC.focused = true)} onblur={() => setTimeout(() => { qAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave" autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
{#if qAC.focused && qAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each qAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li role="option" aria-selected={qSlug === b.slug} onmousedown={() => selectQBook(b)}
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<button onclick={generateQualityScore} disabled={!qCanGenerate}
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
{#if qGenerating}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>Scoring…
{:else}Score description quality{/if}
</button>
{#if qError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{qError}</p>{/if}
</div>
<div>
{#if qScore > 0}
<div class="space-y-3">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
Quality score{#if qUsedModel}<span class="normal-case font-normal"> · {qUsedModel.split('/').pop()}</span>{/if}
</p>
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-4 space-y-3">
<div class="flex items-center gap-3">
<span class="text-4xl font-bold text-(--color-brand)">{qScore}</span>
<div class="flex gap-1">
{#each [1,2,3,4,5] as star}
<span class="text-xl {star <= qScore ? 'text-amber-400' : 'text-(--color-border)'}"></span>
{/each}
</div>
</div>
{#if qFeedback}
<p class="text-sm text-(--color-muted)">{qFeedback}</p>
{/if}
</div>
<button onclick={() => { qScore = 0; qFeedback = ''; qUsedModel = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
</div>
{:else if qGenerating}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
</div>
{:else}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
<p class="text-sm text-(--color-muted)">Quality score will appear here</p>
</div>
{/if}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,43 @@
/**
* POST /api/admin/catalogue/batch-covers
*
* Admin-only SSE proxy to the Go backend's batch cover generation endpoint.
* Pipes the stream straight through so the browser can consume it without buffering.
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/catalogue/batch-covers', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/catalogue/batch-covers', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(res.body, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/catalogue/batch-covers/cancel', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/catalogue/batch-covers/cancel', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,41 @@
/**
* POST /api/admin/catalogue/refresh-metadata/[slug]
*
* Admin-only SSE proxy — re-generates description + cover for a single book.
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
let res: Response;
try {
res = await backendFetch(`/api/admin/catalogue/refresh-metadata/${params.slug}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}'
});
} catch (e) {
log.error('admin/catalogue/refresh-metadata', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(res.body, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -2,10 +2,11 @@
* POST /api/admin/text-gen/chapter-names
*
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
* Returns AI-proposed chapter titles; does NOT persist anything.
* The backend streams SSE events; this handler pipes the stream through
* directly so the browser can consume it without buffering.
*/
import { json, error } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
@@ -28,6 +29,22 @@ export const POST: RequestHandler = async ({ request, locals }) => {
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
// Non-2xx: the backend returned a JSON error before switching to SSE.
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
// Pipe the SSE stream straight through — do not buffer.
return new Response(res.body, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/text-gen/content-warnings', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/content-warnings', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/text-gen/genres', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/genres', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/text-gen/genres/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/genres/apply', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/text-gen/quality-score', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/quality-score', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/text-gen/tagline', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/tagline', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -20,7 +20,9 @@
let saving = $state(false);
// ── Ratings ───────────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
let userRating = $state(data.userRating ?? 0);
// svelte-ignore state_referenced_locally
let ratingAvg = $state(data.ratingAvg ?? { avg: 0, count: 0 });
async function rate(r: number) {
@@ -138,6 +140,21 @@
let coverPreview = $state<string | null>(null);
let coverSaving = $state(false);
let coverResult = $state<'saved' | 'error' | ''>('');
let coverPromptOpen = $state(false);
function buildCoverPrompt(): string {
const title = data.book?.title ?? '';
const summary = data.book?.summary ?? '';
const excerpt = summary.length > 200 ? summary.slice(0, 200) + '…' : summary;
return `Book cover art for "${title}"${excerpt ? ` — ${excerpt}` : ''}. Epic scene with dramatic lighting, professional book cover, highly detailed, 4K.`;
}
let coverPrompt = $state('');
let coverUseAsRef = $state(false);
$effect(() => {
if (coverPromptOpen && !coverPrompt) coverPrompt = buildCoverPrompt();
});
async function generateCover() {
const slug = data.book?.slug;
@@ -145,12 +162,34 @@
coverGenerating = true;
coverPreview = null;
coverResult = '';
const promptToUse = coverPrompt.trim() || buildCoverPrompt();
try {
const res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug })
});
let res: Response;
if (coverUseAsRef && data.book?.cover) {
try {
const imgRes = await fetch(data.book.cover);
const blob = await imgRes.blob();
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
const file = new File([blob], `${slug}-cover-ref.${ext}`, { type: blob.type });
const fd = new FormData();
fd.append('json', JSON.stringify({ slug, type: 'cover', prompt: promptToUse, strength: 0.65 }));
fd.append('reference', file);
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
} catch {
// Fall back to text-only if cover fetch fails
res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
});
}
} else {
res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
});
}
if (res.ok) {
const d = await res.json();
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
@@ -195,6 +234,7 @@
let chapterCoverGenerating = $state(false);
let chapterCoverPreview = $state<string | null>(null);
let chapterCoverResult = $state<'error' | ''>('');
let chapterCoverPrompt = $state('');
async function generateChapterCover() {
const slug = data.book?.slug;
@@ -204,11 +244,12 @@
chapterCoverGenerating = true;
chapterCoverPreview = null;
chapterCoverResult = '';
const promptToUse = chapterCoverPrompt.trim() || `Chapter ${n} illustration for "${data.book?.title ?? slug}". Dramatic scene, vivid colors, detailed art, cinematic lighting.`;
try {
const res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: data.book?.title ?? slug })
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: promptToUse })
});
if (res.ok) {
const d = await res.json();
@@ -228,6 +269,7 @@
let descPreview = $state('');
let descApplying = $state(false);
let descResult = $state<'applied' | 'error' | ''>('');
let descInstructions = $state('');
async function generateDesc() {
const slug = data.book?.slug;
@@ -239,7 +281,7 @@
const res = await fetch('/api/admin/text-gen/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug })
body: JSON.stringify({ slug, instructions: descInstructions.trim() || undefined })
});
if (res.ok) {
const d = await res.json();
@@ -281,9 +323,12 @@
// ── Admin: chapter names generation ───────────────────────────────────────
let chapNamesGenerating = $state(false);
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string }[]>([]);
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string; edited: string }[]>([]);
let chapNamesApplying = $state(false);
let chapNamesResult = $state<'applied' | 'error' | ''>('');
let chapNamesPattern = $state('Chapter {n}: {scene}');
let chapNamesBatchProgress = $state('');
let chapNamesBatchWarnings = $state<string[]>([]);
async function generateChapNames() {
const slug = data.book?.slug;
@@ -291,18 +336,46 @@
chapNamesGenerating = true;
chapNamesPreview = [];
chapNamesResult = '';
chapNamesBatchProgress = '';
chapNamesBatchWarnings = [];
try {
const res = await fetch('/api/admin/text-gen/chapter-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, pattern: 'Chapter {n}: {scene}' })
body: JSON.stringify({ slug, pattern: chapNamesPattern.trim() || 'Chapter {n}: {scene}' })
});
if (res.ok) {
const d = await res.json();
chapNamesPreview = d.chapters ?? [];
} else {
if (!res.ok) {
chapNamesResult = 'error';
return;
}
// SSE streaming response
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
outer: while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
let evt: { batch?: number; total_batches?: number; chapters_done?: number; total_chapters?: number; chapters?: { number: number; old_title: string; new_title: string }[]; error?: string; done?: boolean };
try { evt = JSON.parse(payload); } catch { continue; }
if (evt.done) { chapNamesBatchProgress = `Done — ${evt.total_chapters ?? chapNamesPreview.length} chapters`; chapNamesGenerating = false; break outer; }
if (evt.error) {
chapNamesBatchWarnings = [...chapNamesBatchWarnings, `Batch ${evt.batch}/${evt.total_batches}: ${evt.error}`];
} else if (evt.chapters) {
chapNamesPreview = [...chapNamesPreview, ...evt.chapters.map((c) => ({ ...c, edited: c.new_title }))];
}
if (evt.batch != null && evt.total_batches != null) {
chapNamesBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chapNamesPreview.length}/${evt.total_chapters ?? '?'}`;
}
}
}
if (chapNamesPreview.length === 0 && chapNamesBatchWarnings.length === 0) chapNamesResult = 'error';
} catch {
chapNamesResult = 'error';
} finally {
@@ -316,7 +389,7 @@
chapNamesApplying = true;
chapNamesResult = '';
try {
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.new_title }));
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.edited?.trim() || c.new_title }));
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -890,7 +963,32 @@
<!-- Book cover generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
<div class="flex items-center gap-2">
<button
onclick={() => (coverPromptOpen = !coverPromptOpen)}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
{coverPromptOpen ? 'Hide prompt' : 'Edit prompt'}
</button>
<a href="/admin/image-gen" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Full editor ↗</a>
</div>
</div>
{#if coverPromptOpen}
<textarea
bind:value={coverPrompt}
rows="3"
placeholder={buildCoverPrompt()}
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
></textarea>
{#if book.cover}
<label class="flex items-center gap-2 cursor-pointer select-none w-fit">
<input type="checkbox" bind:checked={coverUseAsRef} class="accent-(--color-brand)" />
<span class="text-xs text-(--color-muted)">Use current cover as reference (img2img)</span>
</label>
{/if}
{/if}
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateCover}
@@ -903,7 +1001,7 @@
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{/if}
{m.book_detail_admin_generate()}
{m.book_detail_admin_generate()}{coverUseAsRef ? ' (img2img)' : ''}
</button>
{#if coverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
@@ -932,6 +1030,12 @@
<!-- Chapter cover generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_cover()}</p>
<textarea
bind:value={chapterCoverPrompt}
rows="2"
placeholder="Chapter {chapterCoverN} illustration for &quot;{data.book?.title}&quot;. Dramatic scene, vivid colors…"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
></textarea>
<div class="flex items-end gap-3 flex-wrap">
<div class="flex flex-col gap-1">
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
@@ -973,7 +1077,16 @@
<!-- Description generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
<a href="/admin/text-gen" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Full editor ↗</a>
</div>
<input
type="text"
bind:value={descInstructions}
placeholder="e.g. 3-sentence blurb, avoid spoilers, dramatic tone"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateDesc}
@@ -1019,6 +1132,12 @@
<!-- Chapter names generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_names()}</p>
<input
type="text"
bind:value={chapNamesPattern}
placeholder="Chapter {'{n}'}: {'{scene}'}"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateChapNames}
@@ -1033,18 +1152,30 @@
{/if}
{m.book_detail_admin_generate()}
</button>
{#if chapNamesBatchProgress && chapNamesGenerating}
<span class="text-xs text-(--color-muted)">{chapNamesBatchProgress}</span>
{/if}
{#if chapNamesResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if chapNamesResult === 'applied'}
<span class="text-xs text-green-400">{m.book_detail_admin_applied()} ({chapNamesPreview.length > 0 ? chapNamesPreview.length : ''})</span>
<span class="text-xs text-green-400">{m.book_detail_admin_applied()}</span>
{/if}
</div>
{#if chapNamesBatchWarnings.length > 0}
{#each chapNamesBatchWarnings as w}
<p class="text-xs text-amber-400">{w}</p>
{/each}
{/if}
{#if chapNamesPreview.length > 0}
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto rounded border border-(--color-border) p-2 bg-(--color-surface-3)">
{#each chapNamesPreview as ch}
<div class="flex gap-2 text-xs">
<div class="flex gap-2 text-xs items-center">
<span class="text-(--color-muted) flex-shrink-0 w-6 text-right">{ch.number}.</span>
<span class="text-(--color-text) truncate">{ch.new_title}</span>
<input
type="text"
bind:value={ch.edited}
class="flex-1 min-w-0 bg-transparent border-b border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) py-0.5"
/>
</div>
{/each}
</div>
@@ -1057,7 +1188,7 @@
>
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
</button>
<button onclick={() => (chapNamesPreview = [])} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
<button onclick={() => { chapNamesPreview = []; chapNamesBatchProgress = ''; chapNamesBatchWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
{/if}
</div>

View File

@@ -204,7 +204,9 @@
{ code: 'pt', label: 'PT' },
{ code: 'fr', label: 'FR' }
];
// svelte-ignore state_referenced_locally
let translationStatus = $state(data.translationStatus ?? 'idle');
// svelte-ignore state_referenced_locally
let translatingLang = $state(data.lang ?? '');
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
@@ -505,6 +507,7 @@
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
bind:this={paginatedContainerEl}
class="paginated-container mt-8"
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
@@ -611,7 +614,7 @@
{#if settingsPanelOpen}
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
<div role="none" class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">

View File

@@ -34,10 +34,13 @@
}
let prefs = $state<Prefs>(loadPrefs());
// svelte-ignore state_referenced_locally
let showOnboarding = $state(!prefs.onboarded);
// Onboarding temp state
// svelte-ignore state_referenced_locally
let tempGenres = $state<string[]>([...prefs.genres]);
// svelte-ignore state_referenced_locally
let tempStatus = $state<Prefs['status']>(prefs.status);
function finishOnboarding(skip = false) {
@@ -85,6 +88,7 @@
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
let activeTab = $state<'discover' | 'history'>('discover');
// svelte-ignore state_referenced_locally
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
// Keep in sync if server data refreshes
@@ -356,6 +360,7 @@
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
@@ -529,8 +534,10 @@
<!-- Active card -->
{#if currentBook}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={cardEl}
role="none"
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
style="
transform: {activeTransform};

View File

@@ -556,6 +556,7 @@
type="button"
role="switch"
aria-checked={autoNext}
aria-label="Auto-advance to next chapter"
onclick={() => (autoNext = !autoNext)}
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
>