Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aad42834f | ||
|
|
15a31a5c64 |
@@ -172,6 +172,14 @@ jobs:
|
||||
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:
|
||||
@@ -180,6 +188,14 @@ jobs:
|
||||
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
|
||||
|
||||
150
AGENTS.md
Normal file
150
AGENTS.md
Normal 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`
|
||||
@@ -778,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"
|
||||
@@ -794,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}
|
||||
|
||||
Reference in New Issue
Block a user