Compare commits
4 commits
4b56560bf9
...
ca27a8077b
| Author | SHA1 | Date | |
|---|---|---|---|
| ca27a8077b | |||
| a5985ef3f8 | |||
| 747244d078 | |||
| 2100184f4e |
110 changed files with 8969 additions and 211 deletions
|
|
@ -59,7 +59,8 @@
|
|||
"Bash(git config:*)",
|
||||
"Bash(git remote:*)",
|
||||
"Bash(ssh sidelinja@157.180.81.26 bash << 'REMOTE'\ncd /tmp\ngit clone ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git test-clone 2>&1\nls test-clone/\nrm -rf test-clone\necho \"DONE\"\nREMOTE)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
.env.example
Normal file
43
.env.example
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# === Lokalt utviklingsmiljo ===
|
||||
# Kopier denne filen til .env.local og fyll inn dine egne verdier.
|
||||
# cp .env.example .env.local
|
||||
#
|
||||
# .env.local er gitignored og skal ALDRI committes.
|
||||
|
||||
DOMAIN=localhost
|
||||
COMPOSE_PROJECT_NAME=sidelinja-dev
|
||||
|
||||
# === PostgreSQL ===
|
||||
POSTGRES_USER=sidelinja
|
||||
POSTGRES_PASSWORD=localdev
|
||||
POSTGRES_DB=sidelinja
|
||||
|
||||
# === Redis ===
|
||||
# Ingen ekstra config nodvendig lokalt
|
||||
|
||||
# === LiveKit (lokale test-nokler, brukes senere) ===
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=devsecret
|
||||
|
||||
# === AI Gateway (LiteLLM) ===
|
||||
LITELLM_MASTER_KEY=sk-sidelinja-dev-1234
|
||||
|
||||
# === AI-leverandorer (sett dine egne nokler) ===
|
||||
GEMINI_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
# ANTHROPIC_API_KEY=
|
||||
# XAI_API_KEY=
|
||||
|
||||
# === SpacetimeDB ===
|
||||
# Sett denne for å aktivere sanntids-chat via SpacetimeDB.
|
||||
# Uten den brukes PG-polling som fallback.
|
||||
VITE_SPACETIMEDB_URL=ws://localhost:3000
|
||||
|
||||
# === SvelteKit ===
|
||||
DATABASE_URL=postgres://sidelinja:localdev@localhost:5432/sidelinja
|
||||
AUTH_SECRET= # openssl rand -base64 33
|
||||
|
||||
# === Authentik OIDC ===
|
||||
AUTHENTIK_ISSUER=https://auth.sidelinja.org/application/o/sidelinja/
|
||||
AUTHENTIK_CLIENT_ID=
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,6 +1,12 @@
|
|||
# Docker-volumer (flyktige, ikke i Git)
|
||||
.docker-data/
|
||||
|
||||
# Scratch (testfiler, notater, midlertidig)
|
||||
.scratch/
|
||||
|
||||
# Generert kontekstfil
|
||||
scripts/server_context.md
|
||||
|
||||
# Miljovariabler
|
||||
.env.local
|
||||
.env
|
||||
|
|
|
|||
156
ARCHITECTURE.md
156
ARCHITECTURE.md
|
|
@ -86,7 +86,7 @@ Når en ny feature eller komponent introduserer data:
|
|||
### 2.3 Lokalt Utviklingsmiljø
|
||||
Det lokale miljøet (WSL2) er et **kodeutviklingsmiljø**, ikke en replika av prod. Infrastruktur-config (docker-compose, Caddy, Authentik) testes direkte i prod. **Komplett oppsett: `docs/setup/lokal.md`.**
|
||||
|
||||
* **Docker Compose Dev:** `docker-compose.dev.yml` spinner opp PostgreSQL, Redis, Caddy og Whisper lokalt. Volumene er flyktige (`.docker-data/`, gitignored).
|
||||
* **Docker Compose Dev:** `docker-compose.dev.yml` spinner opp PostgreSQL, Redis, SpacetimeDB, Caddy, Whisper og AI Gateway lokalt. Volumene er flyktige (`.docker-data/`, gitignored).
|
||||
* **SvelteKit HMR:** Kjøres utenfor Docker for rask iterasjon.
|
||||
* **Rust Workers:** Kompileres og kjøres lokalt med `cargo run`.
|
||||
* **AI Gateway / Whisper:** Lokale instanser for eksperimentering og prompt-testing.
|
||||
|
|
@ -95,16 +95,35 @@ Det lokale miljøet (WSL2) er et **kodeutviklingsmiljø**, ikke en replika av pr
|
|||
## 3. Teknologistack
|
||||
Vi følger et "Best tool for the job"-prinsipp, med en sterk preferanse for minnesikkerhet, ytelse og rene grensesnitt.
|
||||
|
||||
* **Backend/Automasjon:** **Rust**. Brukes som bakgrunnsworkers (jobbkø), logg-parsing og SpacetimeDB-moduler. Rust er *ikke* en API-server — SvelteKit server-side håndterer all HTTP-kommunikasjon og PG-tilgang direkte (se `docs/features/api_grensesnitt.md`).
|
||||
* **Backend/Automasjon:** **Rust**. Brukes som bakgrunnsworkers (jobbkø), logg-parsing og SpacetimeDB-moduler. Rust er *ikke* en API-server — SvelteKit server-side håndterer all HTTP-kommunikasjon og PG-tilgang direkte (se `docs/infra/api_grensesnitt.md`).
|
||||
* **Frontend / UI:** **SvelteKit** (med TypeScript). Bygges som en PWA. Valgt for ytelse og enkel integrasjon med WebRTC og vanilla JS-biblioteker.
|
||||
* **Sanntids Lyd/Video:** **LiveKit** (Selv-hostet). Håndterer WebRTC, fler-bruker videochat og opptak i det virtuelle "studioet".
|
||||
* **AI / Prosessering:** `faster-whisper` (lokal transkripsjon) og **LiteLLM** (AI Gateway — sentralisert ruting til Gemini, Claude, Grok, OpenRouter). All AI-kode peker på `http://ai-gateway:4000/v1`, aldri direkte til leverandører. Se `docs/features/ai_gateway.md`.
|
||||
* **AI / Prosessering:** `faster-whisper` (lokal transkripsjon) og **LiteLLM** (AI Gateway — sentralisert ruting til Gemini, Claude, Grok, OpenRouter). All AI-kode peker på `http://ai-gateway:4000/v1`, aldri direkte til leverandører. Se `docs/infra/ai_gateway.md`.
|
||||
* **SSO / Autentisering:** **Authentik** (Selv-hostet). Sentralisert rollestyring.
|
||||
|
||||
## 4. Den To-delte Databasestrategien
|
||||
1. **PostgreSQL (Historikk & Kunnskapsgraf):** Én sentralisert instans i Docker for brukerkontoer, Git-metadata, aggregert statistikk og Kunnskapsgrafen (Artikler, Faktoider).
|
||||
2. **SpacetimeDB (Sanntid & Arbeidsflyt):** In-memory database for live chat, status på episoder, og live-oppdateringer i studio. Klienten (Svelte) lytter direkte på SpacetimeDB. Strategisk avhengighet, men all persistent data synkes til PostgreSQL — ved eventuelt bortfall kan sanntidslaget erstattes uten tap av data.
|
||||
3. **Synkronisering:** Event-drevet med ~5 sek forsinkelse. SpacetimeDB er autoritativ for sanntidsdata (chat, kanban), PostgreSQL for persistent data (kunnskapsgraf, metadata). Detaljer i `docs/features/synkronisering.md`.
|
||||
2. **SpacetimeDB (Sanntidsbuffer):** In-memory database for live chat, status på episoder, og live-oppdateringer i studio. Klienten (Svelte) lytter direkte på SpacetimeDB. SpacetimeDB er en **ren sanntidsbuffer** — all data synkes til PostgreSQL innen ~5 sek. PostgreSQL-skjemaet dekker alle SpacetimeDB-tabeller, slik at SpacetimeDB kan erstattes med PG `LISTEN/NOTIFY` + SSE uten arkitekturendring. Se `docs/infra/synkronisering.md` §1.1–1.2.
|
||||
3. **Synkronisering:** Event-drevet med ~5 sek forsinkelse. SpacetimeDB er autoritativ for sanntidsdata (chat, kanban), PostgreSQL for persistent data (kunnskapsgraf, metadata). Detaljer i `docs/infra/synkronisering.md`.
|
||||
|
||||
### 4.1 Workspace-modellen (Multi-tenancy)
|
||||
Sidelinja er designet for multi-tenancy fra dag én. Hver organisasjon/podcast opererer i sin egen **workspace** — en streng datasilo som sikrer full isolasjon av kunnskap og arbeidsflyt.
|
||||
|
||||
#### Prinsipp: Ingenting deles på tvers
|
||||
* **PostgreSQL:** Supertabellen `nodes` og `graph_edges` har obligatorisk `workspace_id`. Row-Level Security (RLS) sikrer at spørringer aldri lekker data mellom workspaces. SvelteKit setter `SET app.current_workspace_id` ved tilkobling.
|
||||
* **SpacetimeDB:** WebSocket-tilkoblinger bærer et `workspace_id`-token. Modulen partisjonerer minnet og kringkaster kun til klienter i samme workspace.
|
||||
* **Mediefiler:** Lagres i `/srv/sidelinja/media/{workspace_slug}/`. Caddy ruter basert på domene.
|
||||
* **Transkripsjoner:** Ett Forgejo-repo per workspace for SRT-filer.
|
||||
* **Jobbkø:** Alle jobber i `job_queue` merkes med `workspace_id`. Rust-workers kjører som superuser (bypasser RLS) og isolerer via applikasjonskode.
|
||||
* **AI-prompts:** Whisper `initial_prompt` og LLM system-prompts lagres i `workspaces.settings` (JSONB) per workspace.
|
||||
|
||||
#### Tilgangsstyring
|
||||
Authentik styrer gruppetilhørighet. SvelteKit mapper innlogget brukers Authentik-grupper til tilgjengelige workspaces via `workspace_members`-tabellen. UI-et har en global "Workspace-switcher" som tømmer lokal state (hard reset) ved bytte for å forhindre visuell datalekkasje.
|
||||
|
||||
#### Globale ressurser (unntak fra silo)
|
||||
* `relation_types` — deles på tvers (systemdefinerte relasjonstyper).
|
||||
* `users` — Authentik-IDer er globale, workspace-tilhørighet styres via `workspace_members`.
|
||||
* Valgomat-modulen er publikumsrettet og opererer potensielt på tvers av workspaces. Eierskapsmodellen avklares ved implementering (se `docs/concepts/valgomaten.md`).
|
||||
|
||||
## 5. Datamodell: Kunnskapsgrafen
|
||||
Systemet er bygget rundt **Temaer** og **Aktører**, ikke episoder. Dette bygger et asynkront research-arkiv. Alle entiteter arver UUID fra en felles `nodes`-supertabell som gir ekte FK-integritet i grafmodellen (detaljer i `docs/features/kunnskapsgraf_og_relasjoner.md`).
|
||||
|
|
@ -128,15 +147,20 @@ Systemet er bygget rundt **Temaer** og **Aktører**, ikke episoder. Dette bygger
|
|||
* **Servering:** Caddy serverer media-mappen. MÅ ha `Accept-Ranges: bytes` aktivert for podcast-streaming.
|
||||
* **RSS-Feed:** Genereres av SvelteKit og leveres statisk eller dynamisk med aggressiv caching.
|
||||
|
||||
## 7. Planlagte Funksjoner (Feature Ideas)
|
||||
Dette er hovedkonseptene plattformen skal støtte. **Merk: Detaljerte tekniske spesifikasjoner, flytskjemaer og datastrukturer for hver av disse ligger i mappen `docs/features/`.**
|
||||
## 7. Planlagte Funksjoner
|
||||
Detaljerte spesifikasjoner ligger i `docs/concepts/` (brukeropplevelser) og `docs/features/` (byggeklosser).
|
||||
|
||||
* **Live AI-Assistent i Studio:** Sanntidstranskripsjon via mikrofonene som lytter etter nøkkelord (Named Entity Recognition). Gjør asynkrone oppslag i PostgreSQL og dytter relevante "Faktoider" live til Svelte-grensesnittet via SpacetimeDB mens programlederne snakker.
|
||||
* **AI Research-Klipper ("Ctrl+A workflow"):** Et verktøy der redaksjonen limer inn rotete nyhetsartikler. AI-en (OpenRouter) renser, oppsummerer, og trekker ut Aktører og Faktoider som lagres i Kunnskapsgrafen.
|
||||
* **Produktivitetssuiten:** En Svelte/SpacetimeDB-basert flate for Kanban-styring av episoder, trådet chat knyttet til Temaer, og kollaborative show notes.
|
||||
* **Valgomat:** En publikumsrettet, avansert og interaktiv valgomat drevet av SpacetimeDB for umiddelbar respons og vekting av svar.
|
||||
* **Podcast-Statistikk (Privacy First):** Batch-prosessering i Rust som tygger Caddy JSON-logger, dedupliserer lyttere, fjerner bots og lagrer ferdig statistikk i PostgreSQL.
|
||||
* **Podcastfabrikken:** System for automatisk og manuell publisering (Whisper transkripsjon, metadata via OpenRouter) og versjonshåndtering/cache-busting ved oppdatering av eksisterende episoder.
|
||||
### Konsepter (brukeropplevelser)
|
||||
* **Studioet:** Podcast-innspilling med LiveKit, live AI faktoid-oppslag og Aha-markør.
|
||||
* **Møterommet:** LiveKit-basert møterom med AI-referent, off-the-record, whiteboard, scratchpad og søkbar historikk.
|
||||
* **Redaksjonen:** Daglig arbeidsflate med trådet chat (channels), Kanban, show notes og AI research-klipper.
|
||||
* **Podcastfabrikken:** Automatisert publiseringspipeline — Whisper, AI-metadata, RSS, cache-busting.
|
||||
* **Kunnskapsgrafen:** Visuell utforsking og redigering av kunnskapsnettverk (D3.js/Vis.js).
|
||||
* **Valgomaten:** Publikumsrettet, crowdsourced valgomat med PCA og Sidelinja Explorer.
|
||||
* **Den Asynkrone Gjesten:** Tidsbegrenset lenke til gjester for asynkrone lydopptak som lander i redaksjonens arbeidsflyt.
|
||||
|
||||
### Features (byggeklosser)
|
||||
Chat (channels), Kanban, Whiteboard, Live transkripsjon, Live AI (faktoid + referent), Visuell graf, AI Research-Klipper, Lydmeldinger & Diktering, Podcast-statistikk, Kunnskaps-Bridge (cross-workspace), Prompt-Laboratorium.
|
||||
|
||||
## 8. Bygge-rekkefølge (Avhengighetskart)
|
||||
|
||||
|
|
@ -147,25 +171,34 @@ Dette er hovedkonseptene plattformen skal støtte. **Merk: Detaljerte tekniske s
|
|||
- [x] faster-whisper-server (lokal, testet med medium + prompt)
|
||||
|
||||
### Lag 1 — Fundament (ingen avhengigheter)
|
||||
- [ ] PostgreSQL-skjema (nodes, graph_edges, job_queue)
|
||||
- [ ] SpacetimeDB grunnoppsett
|
||||
- [ ] SvelteKit skjelett med Authentik-integrasjon
|
||||
- [ ] AI Gateway (LiteLLM) oppsett + config
|
||||
- [ ] Git-repostruktur for transkripsjoner (SRT-filer)
|
||||
- [x] Workspace-modell (workspaces, workspace_members, RLS-policies)
|
||||
- [x] PostgreSQL-skjema (nodes m/workspace_id, graph_edges, job_queue, messages, channels, media_files)
|
||||
- [x] SpacetimeDB grunnoppsett (Docker, Rust WASM-modul, TypeScript-bindings)
|
||||
- [ ] SvelteKit skjelett med Authentik-integrasjon + Workspace-switcher
|
||||
- [x] AI Gateway (LiteLLM) oppsett + config
|
||||
- [ ] Git-repostruktur for transkripsjoner (ett repo per workspace)
|
||||
|
||||
### Lag 2 — Kjernekomponenter (krever Lag 1)
|
||||
- [ ] Jobbko-worker (Rust)
|
||||
- [ ] Jobbkø-worker (Rust)
|
||||
- [ ] Kunnskapsgraf CRUD (SvelteKit server-side)
|
||||
- [ ] Produktivitetssuiten: Chat + Kanban (SpacetimeDB <-> PG synk)
|
||||
- [ ] Promptfoo testsett for forste jobbtyper (norsk testdata)
|
||||
- [~] Chat med channels (PG-adapter + SpacetimeDB hybrid-adapter ferdig, sync-worker gjenstår)
|
||||
- [ ] Kanban (SpacetimeDB ↔ PG synk)
|
||||
- [ ] Lydmeldinger & Diktering (opptak + Whisper + AI-opprydding)
|
||||
- [ ] Prompt-Laboratorium (prompt-testing mot egne data)
|
||||
- [ ] Promptfoo testsett for første jobbtyper (norsk testdata)
|
||||
|
||||
### Lag 3 — Features (krever Lag 2)
|
||||
- [ ] Podcastfabrikken (Whisper SRT -> Git -> PG-avledede formater + episodeside)
|
||||
- [ ] AI Research-Klipper (kunnskapsgraf + jobbko + AI Gateway)
|
||||
- [ ] Podcast-Statistikk (jobbko + episoder)
|
||||
- [ ] Podcastfabrikken (Whisper SRT → Git → PG-avledede formater + episodeside)
|
||||
- [ ] AI Research-Klipper (kunnskapsgraf + jobbkø + AI Gateway)
|
||||
- [ ] Podcast-Statistikk (jobbkø + episoder)
|
||||
- [ ] Whiteboard (sanntids frihåndstavle i SpacetimeDB)
|
||||
- [ ] Den Asynkrone Gjesten (gjeste-tokens + lydmeldinger)
|
||||
|
||||
### Lag 4 — Avansert (krever Lag 3)
|
||||
- [ ] Live AI-Assistent (fylt kunnskapsgraf + LiveKit + Whisper small)
|
||||
- [ ] Studioet: Live AI-Assistent (fylt kunnskapsgraf + LiveKit + Whisper small)
|
||||
- [ ] Møterommet: AI-Referent (LiveKit + Whisper + møte-oppsummering)
|
||||
- [ ] Visuell Kunnskapsgraf (D3.js/Vis.js graf-visning)
|
||||
- [ ] Kunnskaps-Bridge (pgvector, cross-workspace discovery)
|
||||
- [ ] Valgomat (selvstendig, lav prioritet)
|
||||
|
||||
## 9. Observabilitet
|
||||
|
|
@ -186,15 +219,80 @@ Alle Docker-containere skal ha `healthcheck` definert i `docker-compose.yml`:
|
|||
|
||||
### 9.3 Jobbkø-overvåking
|
||||
- Admin-visning i SvelteKit som viser `job_queue`-status (pending, running, error-count)
|
||||
- Feilede jobber (`status = 'error'`) poster automatisk en varslingsmelding til et dedikert system-tema i Produktivitetssuiten (intern chat), slik at redaksjonen ser det i sin daglige arbeidsflate
|
||||
- Feilede jobber (`status = 'error'`) poster automatisk en varslingsmelding til et dedikert system-tema i Redaksjonens chat, slik at redaksjonen ser det i sin daglige arbeidsflate
|
||||
|
||||
### 9.4 Ingen eksterne tjenester
|
||||
### 9.4 Observability Dashboard
|
||||
SvelteKit-appen inkluderer en intern admin-side (`/admin/observability`) som samler:
|
||||
- **Container-status:** Healthcheck-resultater fra Docker (via `docker compose ps` / Docker socket)
|
||||
- **Jobbkø:** Pending/running/error-count med sparkline-grafer (siste 24t)
|
||||
- **AI Gateway:** Token-bruk per jobbtype, kostnad per workspace, failover-hendelser (fra LiteLLMs innebygde logging)
|
||||
- **Disk/Minne:** Mediamappe-størrelse per workspace, PG-størrelse, SpacetimeDB-minnebruk
|
||||
|
||||
Ingen eksterne tjenester (Prometheus, Grafana) — alt bygges som SvelteKit-sider med data hentet server-side fra PG, Docker og LiteLLM. Konsistent med self-hosted-filosofien.
|
||||
|
||||
### 9.5 Ingen eksterne observability-tjenester
|
||||
All overvåking og varsling skjer internt i Sidelinja-suiten. Ingen avhengighet til Discord, Slack eller andre tredjepartstjenester.
|
||||
|
||||
## 10. AI Agent Guidelines (Instrukser for Claude Code)
|
||||
## 10. Erfaringslogg
|
||||
Mappen `docs/erfaringer/` samler praktiske lærdommer fra implementering — ikke hva vi valgte, men hva vi lærte som ikke er åpenbart fra koden. Formålet er å treffe raskere blink med neste komponent. Nye komponenter BØR legge til erfaringer etter ferdig implementering.
|
||||
|
||||
Innhold per mars 2025:
|
||||
- `svelte5_reaktivitet.md` — $state-getters, SSR-feller, polling-mønster
|
||||
- `spacetimedb_integrasjon.md` — SDK-konvensjoner, BigInt, Rust borrow-feller
|
||||
- `adapter_moenster.md` — Hybrid PG+SpacetimeDB, anti-patterns, anbefaling for neste komponent
|
||||
|
||||
## 11. Testing og Utrulling
|
||||
|
||||
### 11.1 Teststrategi
|
||||
Sidelinja har tre testnivåer. Alle nye features MÅ dekke de relevante nivåene.
|
||||
|
||||
| Nivå | Hva | Verktøy | Kjøres |
|
||||
|---|---|---|---|
|
||||
| **Enhetstester** | Rust-logikk (jobbkø, sync, parsing) | `cargo test` | Lokalt + CI |
|
||||
| **Migrasjonstester** | PG-skjema: opp-migrering mot tom DB + seed + spørringer | `psql` + testskript | Lokalt + CI |
|
||||
| **Prompt-regresjon** | LLM-prompts mot norske testsett | Promptfoo | Lokalt + CI (månedlig cron) |
|
||||
|
||||
**E2E-tester** (SvelteKit → PG → SpacetimeDB) innføres når Lag 2 er stabilt. Inntil da er manuell testing i dev-miljøet tilstrekkelig — kodebasen er liten nok til at det er effektivt.
|
||||
|
||||
### 11.2 Migrasjonstesting
|
||||
Migrasjonsfiler i `migrations/` testes automatisk:
|
||||
1. Spin opp en tom PostgreSQL-container
|
||||
2. Kjør alle migrasjoner sekvensielt
|
||||
3. Kjør seed-data (fixture) med testdata
|
||||
4. Verifiser: RLS-policies blokkerer cross-workspace-tilgang, indekser eksisterer, constraints holder
|
||||
5. Kjør `pg_dump --schema-only` og diff mot forrige kjente state
|
||||
|
||||
### 11.3 Utrullingsprosedyre (Deployment Checklist)
|
||||
```
|
||||
1. [ ] Alle tester grønne lokalt (cargo test, migrasjonstester, promptfoo)
|
||||
2. [ ] Push til Forgejo (main branch)
|
||||
3. [ ] SSH til prod: ssh sidelinja@157.180.81.26
|
||||
4. [ ] cd /srv/sidelinja && git pull
|
||||
5. [ ] Nye migrasjoner? → Kjør mot prod-PG (manuelt, verifiser først)
|
||||
6. [ ] docker compose up -d --build
|
||||
7. [ ] Verifiser healthchecks: docker compose ps (alle "healthy")
|
||||
8. [ ] Smoke-test: curl https://sidelinja.org/health
|
||||
9. [ ] Sjekk logs: docker compose logs --tail=50 <tjeneste>
|
||||
```
|
||||
|
||||
**Rollback:** `git revert` + ny deploy. Migrasjoner som endrer skjema MÅ ha en tilhørende down-migrering.
|
||||
|
||||
### 11.4 Rate Limiting
|
||||
Ressurskrevende endepunkter beskyttes mot overbruk:
|
||||
|
||||
| Endepunkt | Begrensning | Mekanisme |
|
||||
|---|---|---|
|
||||
| LiveKit (romopprettelse) | Maks 5 aktive rom per workspace | Applikasjonslogikk i SvelteKit |
|
||||
| Whisper (transkripsjon) | Maks 3 samtidige jobber per workspace | Jobbkø-constraint (`max_concurrent_per_workspace`) |
|
||||
| AI Gateway (LLM-kall) | LiteLLMs innebygde rate limiting | `max_parallel_requests` i config.yaml |
|
||||
| Filopplasting (media) | Maks 500 MB per fil, 5 GB per workspace/dag | SvelteKit middleware |
|
||||
|
||||
Caddy håndterer generell rate limiting (DDoS-beskyttelse) via `rate_limit`-direktivet.
|
||||
|
||||
## 12. AI Agent Guidelines (Instrukser for Claude Code)
|
||||
* **Start her:** Når du settes til å bygge en ny komponent, sikre alltid at det lokale utviklingsmiljøet (`docker-compose.dev.yml`) kjører først.
|
||||
* **Dokumentasjonsstandard:** Når du skal implementere en ny funksjon (feature), sjekk ALLTID om det finnes et dokument i `docs/features/<feature-navn>.md` først. Oppdater disse dokumentene hvis arkitekturen for funksjonen endres.
|
||||
* **Ingen "gh" CLI:** Vi bruker Forgejo. For Pull Requests/Issues, bruk `tea` CLI.
|
||||
* **Deployment:** Kod og test lokalt i WSL. Push til Forgejo, logg inn via SSH for å pulle kode og restarte containere/tjenester (`docker compose up -d`).
|
||||
* **Asynkron AI:** Tyngre jobber (Whisper, OpenRouter) skal aldri blokkere web-forespørsler. Alle bakgrunnsjobber kjøres via den felles PostgreSQL-baserte jobbkøen (se `docs/features/jobbkø.md`).
|
||||
* **Asynkron AI:** Tyngre jobber (Whisper, OpenRouter) skal aldri blokkere web-forespørsler. Alle bakgrunnsjobber kjøres via den felles PostgreSQL-baserte jobbkøen (se `docs/infra/jobbkø.md`).
|
||||
* **Sikkerhet:** Forsøk aldri å eksponere databaseporter ut mot internett i Docker Compose-filer (hverken lokalt eller i prod). Port 80/443 (Caddy) er de eneste inngangsportene.
|
||||
37
CLAUDE.md
37
CLAUDE.md
|
|
@ -8,18 +8,39 @@ Self-hosted på Hetzner VPS med full datakontroll.
|
|||
- `ARCHITECTURE.md` — Overordnet arkitektur, stack, datamodell og infrastruktur
|
||||
- `docs/setup/produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch
|
||||
- `docs/setup/lokal.md` — Steg-for-steg oppsett av lokalt WSL2 utviklingsmiljø
|
||||
- `docs/features/` — Detaljerte feature-spesifikasjoner:
|
||||
- `kunnskapsgraf_og_relasjoner.md` — Nodes & Edges-modell i PostgreSQL
|
||||
- `docs/setup/migration_safety.md` — Sjekkliste for PostgreSQL-migrasjoner (RLS-verifisering)
|
||||
- `docs/concepts/` — Brukeropplevelser (integrerte produkter):
|
||||
- `studioet.md` — Podcast-innspilling (LiveKit + Live AI + Aha-markør)
|
||||
- `møterommet.md` — Interne møter (LiveKit + AI-referent + Whiteboard)
|
||||
- `redaksjonen.md` — Daglig redaksjonelt arbeid (Chat + Kanban + Research)
|
||||
- `podcastfabrikken.md` — Publiseringspipeline (Whisper + AI + RSS)
|
||||
- `kunnskapsgrafen.md` — Utforsking og redigering av kunnskapsnettverk
|
||||
- `valgomaten.md` — Publikumsrettet crowdsourced valgomat
|
||||
- `den_asynkrone_gjesten.md` — Asynkrone gjestebidrag via tidsbegrenset lenke
|
||||
- `docs/features/` — Tekniske byggeklosser (brukes av flere konsepter):
|
||||
- `kunnskapsgraf_og_relasjoner.md` — Nodes & Edges datamodell i PostgreSQL
|
||||
- `chat.md` — Trådet chat med mentions og autocomplete (SpacetimeDB)
|
||||
- `kanban.md` — Drag-and-drop planlegging
|
||||
- `whiteboard.md` — Sanntids frihåndstavle (møterom, chat, solo)
|
||||
- `live_transkripsjon.md` — Whisper-pipeline (felles motor for studio/møter/fabrikk)
|
||||
- `live_ai.md` — Live AI: faktoid-oppslag (studio) + referent (møter)
|
||||
- `visuell_graf.md` — Interaktiv graf-visning (D3.js/Vis.js)
|
||||
- `ai_research_klipper.md` — AI-drevet research-inntak til kunnskapsgrafen
|
||||
- `live_ai_assistent.md` — Sanntids faktoid-oppslag under innspilling
|
||||
- `produktivitetssuite.md` — Kanban, chat, show notes (SpacetimeDB-tung)
|
||||
- `podcastfabrikken.md` — Publiseringspipeline (Whisper + OpenRouter + RSS)
|
||||
- `lydmeldinger.md` — Lydmeldinger, diktering og tale-til-tekst
|
||||
- `podcast_statistikk.md` — IAB-kompatibel lytterstatistikk fra Caddy-logger
|
||||
- `valgomat.md` — Publikumsrettet valgomat (SpacetimeDB)
|
||||
- `kunnskaps_bridge.md` — Cross-workspace discovery via vector embeddings
|
||||
- `prompt_lab.md` — Internt verktøy for testing og deploy av LLM-prompts
|
||||
- `kalender.md` — Redaksjonell kalender med abonnementsmodell og ICS-eksport
|
||||
- `docs/infra/` — Infrastruktur (ikke brukersynlig):
|
||||
- `jobbkø.md` — Felles PostgreSQL-basert køsystem for alle bakgrunnsjobber
|
||||
- `synkronisering.md` — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell
|
||||
- `api_grensesnitt.md` — Kommunikasjonskart: SvelteKit er web-API, Rust er worker
|
||||
- `ai_gateway.md` — LiteLLM som sentralisert AI-ruter (BYOK + OpenRouter fallback)
|
||||
- `docs/proposals/` — Halvtenkte idéer og kreative innfall (se `README.md` for oversikt)
|
||||
- `docs/erfaringer/` — Lærdommer fra implementering (feller, anti-patterns, løsninger):
|
||||
- `svelte5_reaktivitet.md` — $state-getters, SSR-feller, polling-mønster
|
||||
- `spacetimedb_integrasjon.md` — SDK-konvensjoner, BigInt, Rust borrow-feller
|
||||
- `adapter_moenster.md` — Hybrid PG+SpacetimeDB, anti-patterns, anbefaling for neste komponent
|
||||
|
||||
## Stack
|
||||
- **Backend/Automasjon:** Rust
|
||||
|
|
@ -45,4 +66,6 @@ Self-hosted på Hetzner VPS med full datakontroll.
|
|||
- Tunge AI-jobber (Whisper, LLM-kall) skal aldri blokkere web-requests
|
||||
- All AI-kode peker på `http://ai-gateway:4000/v1` — aldri direkte til leverandør-APIer
|
||||
- Kod og test lokalt i WSL2, deploy via push til Forgejo + SSH pull
|
||||
- Sjekk alltid `docs/features/<navn>.md` før du implementerer en feature
|
||||
- Sjekk alltid relevant doc i `docs/concepts/`, `docs/features/` eller `docs/infra/` før du implementerer
|
||||
- Sjekk `docs/erfaringer/` for kjente feller før du implementerer med Svelte 5, SpacetimeDB eller adapter-mønsteret
|
||||
- Etter ferdig implementering av en komponent: dokumenter lærdommer i `docs/erfaringer/`
|
||||
|
|
|
|||
25
config/litellm/config.yaml
Normal file
25
config/litellm/config.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
model_list:
|
||||
# === sidelinja/rutine — billig, høyt volum ===
|
||||
- model_name: "sidelinja/rutine"
|
||||
litellm_params:
|
||||
model: "gemini/gemini-2.5-flash-lite"
|
||||
api_key: "os.environ/GEMINI_API_KEY"
|
||||
- model_name: "sidelinja/rutine"
|
||||
litellm_params:
|
||||
model: "gemini/gemini-2.5-flash"
|
||||
api_key: "os.environ/GEMINI_API_KEY"
|
||||
|
||||
# === sidelinja/resonering — presis, lav volum ===
|
||||
# 2.5 Flash brukes til resonering inntil vi legger til Anthropic/OpenRouter
|
||||
- model_name: "sidelinja/resonering"
|
||||
litellm_params:
|
||||
model: "gemini/gemini-2.5-flash"
|
||||
api_key: "os.environ/GEMINI_API_KEY"
|
||||
|
||||
router_settings:
|
||||
routing_strategy: "simple-shuffle"
|
||||
num_retries: 2
|
||||
timeout: 60
|
||||
|
||||
general_settings:
|
||||
master_key: "os.environ/LITELLM_MASTER_KEY"
|
||||
|
|
@ -41,6 +41,45 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# === Lag B: Sanntid ===
|
||||
|
||||
spacetimedb:
|
||||
image: clockworklabs/spacetime:latest
|
||||
restart: unless-stopped
|
||||
command: start
|
||||
volumes:
|
||||
- ./.docker-data/spacetimedb:/stdb
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
networks:
|
||||
- sidelinja-dev
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:3000/database/ping || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# === AI Gateway ===
|
||||
|
||||
ai-gateway:
|
||||
image: ghcr.io/berriai/litellm:main-stable
|
||||
restart: unless-stopped
|
||||
command: --config /etc/litellm/config.yaml
|
||||
environment:
|
||||
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||
volumes:
|
||||
- ./config/litellm/config.yaml:/etc/litellm/config.yaml:ro
|
||||
ports:
|
||||
- "127.0.0.1:4000:4000"
|
||||
networks:
|
||||
- sidelinja-dev
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:4000/health/liveliness || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# === Whisper: Transkripsjon ===
|
||||
|
||||
whisper:
|
||||
|
|
|
|||
93
docs/concepts/den_asynkrone_gjesten.md
Normal file
93
docs/concepts/den_asynkrone_gjesten.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Konsept: Den Asynkrone Gjesten
|
||||
**Filsti:** `docs/concepts/den_asynkrone_gjesten.md`
|
||||
|
||||
## 1. Konsept
|
||||
Mange interessante gjester har ikke tid til å stille i studio. Den Asynkrone Gjesten lar redaksjonen sende en unik lenke til en gjest som kan svare på spørsmål via tale — fra mobilen, når det passer dem. Svarene lander direkte i redaksjonens arbeidsflyt, transkriberes automatisk, og kan brukes i podcasten.
|
||||
|
||||
## 2. Brukeropplevelse
|
||||
|
||||
### 2.1 Redaksjonens side
|
||||
1. Redaksjonen oppretter en "Gjestesesjon" knyttet til et Tema.
|
||||
2. Legger inn spørsmål (tekst) som gjesten skal svare på.
|
||||
3. Systemet genererer en unik, tidsbegrenset URL.
|
||||
4. URL-en sendes til gjesten via e-post, SMS eller chat.
|
||||
5. Gjestens svar (lydmeldinger) dukker opp i Tema-chatten som `voice_memo`-meldinger, automatisk transkribert.
|
||||
6. Redaksjonen triagerer svarene — kan tagge, klippe inn i episode, eller bruke som research.
|
||||
|
||||
### 2.2 Gjestens side
|
||||
1. Gjesten åpner lenken i mobilnettleseren. Ingen app, ingen konto, ingen registrering.
|
||||
2. Ser en enkel, ren flate: podcast-logo, spørsmålene fra redaksjonen, og en opptaksknapp per spørsmål.
|
||||
3. Trykker record, snakker, trykker stopp. Kan lytte tilbake og ta om igjen.
|
||||
4. Ved innsending lastes lydfilene opp og gjesten ser en bekreftelse.
|
||||
5. Lenken utløper etter gitt tid eller antall besøk.
|
||||
|
||||
### 2.3 Minimal friksjon
|
||||
- Ingen Authentik-innlogging — tilgang via signert token
|
||||
- Ingen app — ren PWA/nettleser
|
||||
- Ingen redigering — gjesten snakker bare
|
||||
- Responsivt, mobil-first design
|
||||
|
||||
## 3. Komponenter
|
||||
|
||||
| Feature | Rolle |
|
||||
|---|---|
|
||||
| Lydmeldinger | Opptakskomponent gjenbrukes (se `docs/features/lydmeldinger.md`) |
|
||||
| Chat (channels) | Svarene lander i en channel knyttet til Temaet |
|
||||
| Live transkripsjon | Whisper transkriberer via jobbkø (se `docs/features/live_transkripsjon.md`) |
|
||||
| Podcastfabrikken | Lydklipp kan trekkes inn som segment (se `docs/concepts/podcastfabrikken.md`) |
|
||||
|
||||
## 4. Autentisering: Gjeste-tokens
|
||||
|
||||
### 4.1 Datamodell
|
||||
|
||||
```sql
|
||||
guest_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
guest_name TEXT NOT NULL, -- Visningsnavn ("Erna Solberg")
|
||||
questions JSONB NOT NULL, -- [{ "sort": 1, "text": "Hva tenker du om...?" }]
|
||||
token TEXT UNIQUE NOT NULL, -- Kryptografisk sikker, URL-safe token
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
max_recordings SMALLINT DEFAULT 10, -- Maks antall opptak
|
||||
recordings_count SMALLINT DEFAULT 0,
|
||||
created_by TEXT NOT NULL REFERENCES users(authentik_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
```
|
||||
|
||||
### 4.2 Sikkerhet
|
||||
- Token er kryptografisk tilfeldig (256-bit, URL-safe base64).
|
||||
- SvelteKit validerer token ved hvert request: sjekker expiry, recordings_count < max_recordings, og workspace-tilhørighet.
|
||||
- Gjestens meldinger merkes med `author_id = NULL` og `metadata.guest_name` + `metadata.guest_token_id` for sporbarhet.
|
||||
- Ingen tilgang til andre channels, workspaces eller funksjoner.
|
||||
- Tokenet kan revokeres manuelt av redaksjonen.
|
||||
|
||||
### 4.3 Flyt (teknisk)
|
||||
```
|
||||
Gjest åpner URL med token
|
||||
→ SvelteKit validerer token
|
||||
→ Viser spørsmål + opptaksknapp
|
||||
→ Gjest tar opp svar
|
||||
→ SvelteKit streamer lydfil til media/{workspace_slug}/voice/
|
||||
→ Oppretter message (voice_memo) i channelen
|
||||
→ Oppretter whisper_transcribe-jobb i jobbkøen
|
||||
→ Inkrementerer recordings_count
|
||||
→ Redaksjonen ser svaret i Tema-chatten
|
||||
```
|
||||
|
||||
## 5. Dataklassifisering
|
||||
|
||||
| Data | Kategori | Detaljer |
|
||||
|---|---|---|
|
||||
| Gjestens lydopptak | Kritisk (backup) | Unikt innhold |
|
||||
| Guest tokens | Flyktig (TTL) | Utløper automatisk, slett expired tokens periodisk |
|
||||
| Spørsmål (JSONB) | Kritisk (PG) | Redaksjonelt innhold |
|
||||
|
||||
## 6. Instruks for Claude Code
|
||||
* `guest_tokens`-tabellen er **ikke** en node i grafen — den er ren tilgangsstyring.
|
||||
* Gjeste-UI er en egen SvelteKit-rute (`/guest/[token]`) med minimal layout (ingen navbar, ingen workspace-switcher).
|
||||
* Gjenbruk lydmeldinger-komponenten — ikke bygg en egen opptaksflyt.
|
||||
* Meldinger fra gjester har `author_id = NULL`. Frontend må håndtere dette gracefully (vis `guest_name` i stedet).
|
||||
* Tokenet skal **aldri** gi tilgang til å lese andre meldinger i channelen — gjesten kan kun skrive.
|
||||
* Alt er workspace-scopet. Token bærer workspace_id eksplisitt.
|
||||
36
docs/concepts/kunnskapsgrafen.md
Normal file
36
docs/concepts/kunnskapsgrafen.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Konsept: Kunnskapsgrafen (Utforsking og redigering)
|
||||
**Filsti:** `docs/concepts/kunnskapsgrafen.md`
|
||||
|
||||
## 1. Konsept
|
||||
Kunnskapsgrafen er Sidelinjas kjerne — et levende nettverk av Temaer, Aktører, Faktoider, Episoder og Segmenter. Inspirert av Logseq og Obsidian bygger den seg opp organisk gjennom daglig bruk og skaper "serendipity" (lykketreff) i research-fasen ved å synliggjøre uventede forbindelser.
|
||||
|
||||
## 2. Brukeropplevelse
|
||||
|
||||
### 2.1 Organisk vekst
|
||||
Grafen vokser gjennom daglig bruk av Sidelinja:
|
||||
1. **Chat-meldinger** med `#`-tags oppretter automatisk `MENTIONS`-relasjoner i grafen.
|
||||
2. **AI Research-Klipperen** trekker ut aktører og faktoider fra innlimt tekst.
|
||||
3. **Podcastfabrikken** kobler episode-segmenter til temaer og aktører.
|
||||
4. **Møtereferater** trådes automatisk mot temaer og aktører av AI-referenten.
|
||||
|
||||
### 2.2 Visuell utforsking
|
||||
En interaktiv graf-visning (se `docs/features/visuell_graf.md`) lar redaksjonen:
|
||||
- Navigere nettverket rundt en Aktør eller et Tema (2-3 ledd ut)
|
||||
- Dra streker mellom noder for å opprette nye relasjoner
|
||||
- Filtrere etter nodetype, relasjonstype, tidsperiode eller fritekst
|
||||
- Bruke `PART_OF`-hierarkier for fleksibel prosjektorganisering uten stive mappestrukturer
|
||||
|
||||
### 2.3 Søk
|
||||
Full-text search på norsk (`to_tsvector('norwegian', ...)`) gjør det mulig å søke på tvers av alle episoder, segmenter og faktoider.
|
||||
|
||||
## 3. Komponenter
|
||||
|
||||
| Feature | Rolle i Kunnskapsgrafen |
|
||||
|---|---|
|
||||
| Kunnskapsgraf datamodell | Nodes/edges i PostgreSQL (se `docs/features/kunnskapsgraf_og_relasjoner.md`) |
|
||||
| Visuell graf | Interaktiv D3.js/Vis.js-visning (se `docs/features/visuell_graf.md`) |
|
||||
| Chat | Mentions (`#`/`@`) oppretter edges automatisk (se `docs/features/chat.md`) |
|
||||
| AI Research-Klipper | Trekker ut aktører/faktoider til grafen (se `docs/features/ai_research_klipper.md`) |
|
||||
|
||||
## 4. Datamodell
|
||||
Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, workspace-isolasjon) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`.
|
||||
40
docs/concepts/møterommet.md
Normal file
40
docs/concepts/møterommet.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Konsept: Møterommet (Interne redaksjonsmøter)
|
||||
**Filsti:** `docs/concepts/møterommet.md`
|
||||
|
||||
## 1. Konsept
|
||||
Et fullverdig virtuelt møterom for Sidelinjas redaksjon. Bygget på LiveKit for lyd/video, med AI-referent som automatisk genererer referat og action points, delt whiteboard for visuell brainstorming, og søkbar møtehistorikk i Kunnskapsgrafen.
|
||||
|
||||
## 2. Brukeropplevelse
|
||||
1. En bruker oppretter et møterom i SvelteKit. Alle deltakere kobler seg til via LiveKit (WebRTC).
|
||||
2. Under møtet er følgende verktøy tilgjengelige:
|
||||
- **Video/lyd** — LiveKit-strøm mellom deltakere
|
||||
- **Whiteboard** — Frihåndstavle for skisser (se `docs/features/whiteboard.md`)
|
||||
- **Delt scratchpad** — SpacetimeDB-drevet tekstfelt for kjappe notater
|
||||
- **Aha-markør** — Markerer viktige øyeblikk med tidsstempel
|
||||
- **Off-the-record** — Pauser AI-lytting og opptak
|
||||
3. Whisper transkriberer møtet via live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`).
|
||||
4. Ved møteslutt genererer AI-referenten (se `docs/features/live_ai.md`, møte-modus):
|
||||
- Strukturert referat (Markdown)
|
||||
- Action points → foreslåtte Kanban-kort (se `docs/features/kanban.md`)
|
||||
- Identifiserte `#Temaer` og `@Aktører` → automatisk tråding i Kunnskapsgrafen
|
||||
|
||||
## 3. Komponenter
|
||||
|
||||
| Feature | Rolle i Møterommet |
|
||||
|---|---|
|
||||
| LiveKit | WebRTC lyd/video |
|
||||
| Live transkripsjon | Whisper-pipeline for møtetranskripsjon (se `docs/features/live_transkripsjon.md`) |
|
||||
| Live AI (møte-modus) | Referat, action points, tråding (se `docs/features/live_ai.md`) |
|
||||
| Whiteboard | Frihåndstavle for visuell brainstorming (se `docs/features/whiteboard.md`) |
|
||||
| Chat | Scratchpad / tekstnotater (se `docs/features/chat.md`) |
|
||||
| Kanban | Mottaker av foreslåtte action-point-kort (se `docs/features/kanban.md`) |
|
||||
|
||||
## 4. Off-the-record
|
||||
Når off-the-record er aktivt:
|
||||
- Whisper-strømmen stopper — ingen data lagres
|
||||
- Visuell indikator i alle deltakeres grensesnitt
|
||||
- Transkripsjonen får et gap i tidslinja
|
||||
- Whiteboard forblir aktivt (visuelt, ikke tale)
|
||||
|
||||
## 5. Søkbar møtehistorikk
|
||||
Møter transkriberes i segmenter (samme modell som episode-segmenter) og indekseres i PostgreSQL med full-text search. Møtereferater lenkes til Temaer og Aktører via `graph_edges`, slik at intern møtehistorikk blir søkbar i Kunnskapsgrafen.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# Feature Spec: Podcastfabrikken (Lyd & Publiserings-Pipeline)
|
||||
**Filsti:** `docs/features/podcastfabrikken.md`
|
||||
# Konsept: Podcastfabrikken (Lyd & Publiserings-Pipeline)
|
||||
**Filsti:** `docs/concepts/podcastfabrikken.md`
|
||||
|
||||
## 1. Konsept
|
||||
Den automatiserte "samlebåndet" som tar over når en ferdigklippet episode er klar, samt verktøyet for å **oppdatere eksisterende episoder** (f.eks. en rullerende intro-episode). Målet er at maskinen gjør 90 % av grovarbeidet (transkripsjon, metadata, kapittelinndeling), men at redaksjonen alltid kan overstyre resultatet manuelt før publisering.
|
||||
|
|
@ -8,7 +8,7 @@ Den automatiserte "samlebåndet" som tar over når en ferdigklippet episode er k
|
|||
Dette er en asynkron arbeidsflyt som kombinerer filsystem, AI, databaser og CI/CD.
|
||||
|
||||
1. **Trigger (Opplasting/Oppdatering):** Brukeren laster opp en `.mp3`-fil via SvelteKit-grensesnittet. Dette rutes enten som en *ny* episode (`INSERT`), eller en *oppdatering* av en eksisterende (`UPDATE`).
|
||||
2. **Kø-system (PostgreSQL jobbkø):** Siden lydprosessering tar tid (CPU-intensivt), legges oppgaven i den felles jobbkøen (se `docs/features/jobbkø.md`). Opplastingen oppretter to jobber i sekvens: først `whisper_transcribe`, deretter `openrouter_analyze` (som trigges automatisk ved fullført transkripsjon).
|
||||
2. **Kø-system (PostgreSQL jobbkø):** Siden lydprosessering tar tid (CPU-intensivt), legges oppgaven i den felles jobbkøen (se `docs/infra/jobbkø.md`). Opplastingen oppretter to jobber i sekvens: først `whisper_transcribe`, deretter `openrouter_analyze` (som trigges automatisk ved fullført transkripsjon).
|
||||
3. **Transkripsjon (faster-whisper):** Rust-worker kaller faster-whisper-server (OpenAI-kompatibelt API, `POST /v1/audio/transcriptions`) med `response_format=srt` og mottar SRT direkte. Modell: `Systran/faster-whisper-medium` med `initial_prompt` (navneliste).
|
||||
4. **Lagring av transkripsjon (Git):** Rust-worker committer SRT-filen til Forgejo. SRT er master-formatet — redigerbart, tidsstemplet, og et etablert standardformat. Git gir diff, historikk og sporbarhet. Redaksjonen kan redigere SRT direkte.
|
||||
5. **Avledede formater (PostgreSQL):** Ved commit (via Forgejo webhook) parser en Rust-worker SRT-filen og genererer:
|
||||
|
|
@ -69,8 +69,28 @@ Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen,
|
|||
Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
|
||||
```
|
||||
|
||||
## 5. Instruks for Claude Code
|
||||
## 5. Workspace-spesifikk konfigurasjon
|
||||
Hver workspace har sin egen podcast-konfigurasjon, lagret i `workspaces.settings` (JSONB):
|
||||
|
||||
### 5.1 Mediefiler
|
||||
Lydfiler lagres i undermapper per workspace: `/srv/sidelinja/media/{workspace_slug}/`. Caddy ruter trafikk basert på domene (fra `workspaces.domain`) til riktig undermappe.
|
||||
|
||||
### 5.2 Transkripsjoner
|
||||
Det opprettes **ett Forgejo-repo per workspace** for SRT-filer, slik at historikk og redigering ikke blandes på tvers av podcaster.
|
||||
|
||||
### 5.3 AI-prompts
|
||||
* **Whisper `initial_prompt`:** Navnelister og kontekst lagres per workspace i `settings.whisper_prompt`. Rust-worker bygger prompten fra statisk liste + aktører i workspace-ets kunnskapsgraf.
|
||||
* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i `settings.llm_prompts` slik at AI-en kjenner konteksten og vertene for akkurat den podcasten.
|
||||
|
||||
### 5.4 RSS-feed
|
||||
SvelteKit genererer `/feed.xml` dynamisk basert på domenet forespørselen kommer fra (matcher `workspaces.domain`), eller workspace-slug som fallback.
|
||||
|
||||
### 5.5 Statistikk
|
||||
Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig `workspace_id` basert på filsti i loggen.
|
||||
|
||||
## 6. Instruks for Claude Code
|
||||
* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer.
|
||||
* **Feilhåndtering:** Hvis OpenRouter timer ut eller Whisper feiler, må oppgaven flagges med status `error` i databasen slik at brukeren kan trigge jobben på nytt manuelt via UI.
|
||||
* **Opprydding (Disk):** Når en fil oppdateres vellykket, skal den gamle/foreldede `.mp3`-filen enten slettes fra Hetzner-serveren automatisk, eller flyttes til en `/archive/`-mappe basert på en miljøvariabel.
|
||||
* **Transkripsjoner:** Master-kopi alltid i Git. Aldri rediger avledede formater direkte i PG — de regenereres fra Git-kilden.
|
||||
* **Transkripsjoner:** Master-kopi alltid i Git. Aldri rediger avledede formater direkte i PG — de regenereres fra Git-kilden.
|
||||
* **Workspace:** Alle jobber, mediefiler og metadata opprettes med riktig `workspace_id`. Hent workspace-config (prompts, domene) fra `workspaces.settings`.
|
||||
36
docs/concepts/redaksjonen.md
Normal file
36
docs/concepts/redaksjonen.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Konsept: Redaksjonen (Daglig redaksjonelt arbeid)
|
||||
**Filsti:** `docs/concepts/redaksjonen.md`
|
||||
|
||||
## 1. Konsept
|
||||
Redaksjonen er den daglige arbeidsflaten for Sidelinjas team. Her planlegges episoder, diskuteres temaer, samles research og skrives show notes. **Temaet** er hovedobjektet — ikke episoden.
|
||||
|
||||
## 2. Brukeropplevelse
|
||||
|
||||
### 2.1 Tema-bassenget
|
||||
Alle pågående "Saker" vises i en oversikt. PostgreSQL er kilden til sannhet, SpacetimeDB holder aktive temaer i minnet for sanntidsoppdateringer.
|
||||
|
||||
### 2.2 Trådet Chat
|
||||
Hver melding tilhører et Tema. Meldinger støtter tråder (svar-på-svar) og rike mentions med `#`-tags og `@`-mentions. Se `docs/features/chat.md` for teknisk spesifikasjon.
|
||||
|
||||
### 2.3 Kanban / Kjøreplan
|
||||
Episoder fungerer som containere. Brukerne drar Temaer fra bassenget inn i en episodes kjøreplan med drag-and-drop. Se `docs/features/kanban.md` for teknisk spesifikasjon.
|
||||
|
||||
### 2.4 Show Notes
|
||||
Et kollaborativt tekstfelt koblet til et Tema. Enkle "Operational Transformation"-aktige oppdateringer (eller felt-låsing) håndteres i SpacetimeDB-modulen. Synkes til PostgreSQL for persistens.
|
||||
|
||||
### 2.5 AI Research-Klipper
|
||||
Brukere limer inn uformatert tekst fra nettet. AI-en renser, oppsummerer og trekker ut aktører og faktoider til Kunnskapsgrafen. Se `docs/features/ai_research_klipper.md`.
|
||||
|
||||
## 3. Komponenter
|
||||
|
||||
| Feature | Rolle i Redaksjonen |
|
||||
|---|---|
|
||||
| Chat | Trådet diskusjon per Tema (se `docs/features/chat.md`) |
|
||||
| Kanban | Episodeplanlegging (se `docs/features/kanban.md`) |
|
||||
| AI Research-Klipper | Research-inntak (se `docs/features/ai_research_klipper.md`) |
|
||||
| Whiteboard | Kan åpnes fra chat for visuell brainstorming (se `docs/features/whiteboard.md`) |
|
||||
|
||||
## 4. Instruks for Claude Code
|
||||
* Bruk SvelteKit for Drag-and-Drop. Unngå tunge biblioteker hvis native HTML5 Drag and Drop er tilstrekkelig.
|
||||
* SpacetimeDB er "State Manager". Frontend speiler SpacetimeDB sin tilstand — ikke bygg kompleks lokal state.
|
||||
* Alle data er workspace-scopet. SpacetimeDB-tilkoblinger bærer `workspace_id`.
|
||||
32
docs/concepts/studioet.md
Normal file
32
docs/concepts/studioet.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Konsept: Studioet (Podcast-innspilling)
|
||||
**Filsti:** `docs/concepts/studioet.md`
|
||||
|
||||
## 1. Konsept
|
||||
Det virtuelle podcast-studioet er Sidelinjas innspillingsmiljø. LiveKit håndterer WebRTC for flerbruker lyd/video, mens AI-assistenten lytter med og dytter relevante faktoider til programlederne i sanntid.
|
||||
|
||||
## 2. Brukeropplevelse
|
||||
1. Programlederne åpner studioet i SvelteKit (PWA) og kobler seg til et LiveKit-rom.
|
||||
2. Høykvalitetslyd streames mellom deltakerne via WebRTC.
|
||||
3. I bakgrunnen transkriberer Whisper lydstrømmen i chunks (~5 sek) via live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`).
|
||||
4. AI-assistenten analyserer transkripsjonen for entiteter (NER) og slår opp i Kunnskapsgrafen. Relevante faktoider popper lydløst opp på skjermen (se `docs/features/live_ai.md`, studio-modus).
|
||||
5. Programlederne kan trykke **Aha-markør** for å markere viktige øyeblikk. Tidsstempelet lagres i SpacetimeDB, koblet til episoden.
|
||||
6. Etter innspilling skyves lydfilen inn i Podcastfabrikken for full transkripsjon og publisering (se `docs/concepts/podcastfabrikken.md`).
|
||||
|
||||
## 3. Komponenter
|
||||
|
||||
| Feature | Rolle i Studioet |
|
||||
|---|---|
|
||||
| LiveKit | WebRTC lyd/video mellom deltakere |
|
||||
| Live transkripsjon | Whisper `small` for lav latens, ~1s forsinkelse (se `docs/features/live_transkripsjon.md`) |
|
||||
| Live AI (studio-modus) | NER + faktoid-oppslag fra Kunnskapsgrafen (se `docs/features/live_ai.md`) |
|
||||
| Aha-markør | Manuell markering av viktige øyeblikk, lagres i SpacetimeDB |
|
||||
|
||||
## 4. Avgrensning
|
||||
- Studioet er for **innspilling**, ikke redigering. Klipping/postproduksjon skjer utenfor Sidelinja.
|
||||
- Live-transkripsjonen her er **flyktig** (TTL 30 dager) — den endelige transkripsjonen lages via Podcastfabrikken med `medium` + `initial_prompt`.
|
||||
- Aha-markøren deles med Møterommet (se `docs/concepts/møterommet.md`), men i studio-konteksten brukes den primært til klippepunkter.
|
||||
|
||||
## 5. Utviklingsfaser
|
||||
1. Bygg SpacetimeDB-lytter i frontend + dummy faktoid-push for å verifisere UI.
|
||||
2. Koble Whisper til et offline lydopptak, kjør NER/oppslag mot PostgreSQL.
|
||||
3. Koble LiveKit-strømmen til Whisper for sanntid.
|
||||
97
docs/concepts/valgomaten.md
Normal file
97
docs/concepts/valgomaten.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Konsept: Valgomaten (Crowdsourced & Datadrevet)
|
||||
**Filsti:** `docs/concepts/valgomaten.md`
|
||||
|
||||
## 1. Konsept & Kjernefilosofi
|
||||
Neste generasjons valgomat tar et oppgjør med den redaktørstyrte, endimensjonale modellen. Istedenfor forhåndsdefinerte akser styrer brukerne innholdet "bottom-up" gjennom et marked for akser. Valgomaten kombinerer friksjonsfri "Tinder-swiping" for folk flest, med et dyptgående redaksjonelt verktøy for nerdene, alt bygget på toppen av Sidelinjas eksisterende Kunnskapsgraf og sanntidsinfrastruktur.
|
||||
|
||||
### 1.1 Markedsmekanisme
|
||||
Brukere kan fritt opprette nye politiske akser og spørsmål. Dimensjoner som engasjerer (får mange svar) "bobler opp" og blir standardakser, mens irrelevante forsvinner. Ingen begrensning på antall akser — systemet støtter komplekse skillelinjer i samfunnet.
|
||||
|
||||
### 1.2 Teoretisk fundament (Default-akser)
|
||||
For å unngå "Blank Canvas"-syndromet startes plattformen med anerkjente rammeverk som fungerer som ankere:
|
||||
* **John Haidts Moral Foundations Theory:** Omsorg, Rettferdighet, Lojalitet, Autoritet, Renhet.
|
||||
* **Rokkans skillelinjer:** Sentrum/periferi, by/land, religiøs/sekulær.
|
||||
* **Nanny State Index:** Formynderstat vs. personlig frihet og ansvar.
|
||||
* **Intensjon vs. Resultat:** Støttes politikk på bakgrunn av teoretisk målsetting eller empirisk utfall?
|
||||
|
||||
## 2. Brukeropplevelse (Trakten)
|
||||
Valgomaten er bygget som en to-trinns trakt for å maksimere viral spredning samtidig som dataintegriteten bevares.
|
||||
|
||||
### 2.1 Forsiden: Friksjonsfri & Anonym
|
||||
* **Mekanikk:** Ingen registrering kreves for å starte. SvelteKit genererer en anonym UUID som lagres i `localStorage` og brukes mot SpacetimeDB.
|
||||
* **UX:** Et rent kortstokk-grensesnitt (Tinder-stil swipe). Påstand på forsiden, brukeren velger Enig/Uenig.
|
||||
* **Tre-trinns kalibrering (OKCupid-inspirert):**
|
||||
1. Hva mener du?
|
||||
2. Hva ønsker du at kandidaten skal mene?
|
||||
3. Hvor viktig er dette for deg?
|
||||
* **Dealbreakers (Negative Veto):** Brukere kan sette absolutte grenser (f.eks. "Uansett hvor enige vi er om skatt, matcher jeg aldri med en som er for/mot vindkraft").
|
||||
* **Sanntid:** SpacetimeDB beregner PCA (Prinsipalkomponentanalyse) og oppdaterer brukerens posisjon lynraskt i minnet for hvert swipe.
|
||||
|
||||
### 2.2 Baksiden: "Snu kortet" (Krever innlogging)
|
||||
For avansert interaksjon må brukeren logge inn via Authentik (SSO). Den anonyme sesjonen flettes da automatisk med brukerprofilen. På baksiden av kortet finner man:
|
||||
* **Folkets Redaksjonsmøte:** Mulighet til å se andres kommentarer til spørsmålet, foreslå endringer, og stemme opp/ned forbedringsforslag.
|
||||
* **Podcast-snarveien:** Hvis spørsmålet er knyttet til et *Tema* i Kunnskapsgrafen, vises en "Play"-knapp. Caddy (`Accept-Ranges: bytes`) streamer tidsstemplede lydsegmenter fra tidligere Sidelinja-episoder der dette temaet ble diskutert.
|
||||
* **Multi-akse vekting:** Innsikt i hvilke akser spørsmålet påvirker (f.eks. 80% Klima, 20% Sentrum/Periferi), med mulighet for å foreslå nye akse-koblinger.
|
||||
|
||||
## 3. Matching & Resultat
|
||||
|
||||
### 3.1 Individfokus
|
||||
Matcher velgeren mot spesifikke lokalpolitikere og listekandidater, ikke bare mot det sentrale partiprogrammet.
|
||||
|
||||
### 3.2 PCA (Prinsipalkomponentanalyse)
|
||||
Matematisk reduksjon av kompleksitet. Algoritmen koker ned brukerens svar på tvers av 50+ variabler til de 2-3 hovedfaktorene som faktisk styrer vedkommendes politiske kompass, og visualiserer disse spesifikt for brukeren.
|
||||
|
||||
## 4. Crowdsourcing & Dataintegritet
|
||||
|
||||
### 4.1 Uforanderlig Historikk (Versjonering)
|
||||
Et spørsmål i PostgreSQL kan *aldri* endres (`UPDATE`) etter at folk har begynt å svare på det.
|
||||
* Hvis et forbedringsforslag stemmes frem (eller godkjennes av redaksjonen), opprettes en ny versjon (f.eks. `Spørsmål 42 (v2)`).
|
||||
* Brukere beholder sine resultater knyttet til den nøyaktige teksten de leste. Algoritmen vet at v1 og v2 tilhører samme politiske konsept.
|
||||
|
||||
### 4.2 Opprettelse av nye spørsmål og akser (Sandkassen)
|
||||
* **Inkubator:** Innloggede brukere kan opprette nye spørsmål eller definere helt nye akser (ved å angi to motpoler). Disse havner i en "Sandkasse" og vises ikke på forsiden før de har fått nok organisk engasjement fra andre innloggede brukere.
|
||||
* **Vekting via Graph Edges:** Koblingen mellom et spørsmål og en akse lagres som en relasjon i `graph_edges`. Når brukere "stemmer opp" at et spørsmål tilhører en spesifikk akse, øker feltet `confidence`. SpacetimeDB bruker denne `confidence`-scoren som multiplikator i match-algoritmen.
|
||||
|
||||
## 5. Visualisering & Sidelinja Explorer
|
||||
|
||||
### 5.1 Innhenting av referansedata
|
||||
For å bygge nøyaktige velgerkart, introduseres et valgfritt spørsmål før resultatet vises: *"For å kalibrere landskapet: Hva stemte du ved forrige valg?"*. Dette knytter den anonyme eller innloggede sesjonen mot en parti-`aktør` i Kunnskapsgrafen.
|
||||
|
||||
### 5.2 Sidelinja Explorer (Offentlig Graf)
|
||||
En egen SvelteKit-side der lekfolk og journalister kan analysere dataene.
|
||||
* **Heatmaps:** Viser klynger ("skyer") av velgermasser basert på partipreferanse på to valgfrie akser.
|
||||
* **Varder i landskapet:** Historiske figurer (Churchill, Stalin), nåværende politikere og Sidelinjas egne verter ligger inne som faste referansepunkter i grafen. AI-estimerte profiler gir pedagogisk (og underholdende) kontekst.
|
||||
|
||||
## 6. AI & Asynkron Prosessering
|
||||
Av kostnads- og ytelseshensyn skjer all AI-bruk asynkront i backend via jobbkøen og `ai-gateway` (LiteLLM). Ingen live AI-kall i klienten.
|
||||
|
||||
* **AI-Kandidater (`valgomat_generate_profile`):** En bakgrunnsjobb analyserer partiprogrammer via `sidelinja/rutine` (Gemini) og genererer "syntetiske" referanseprofiler for listekandidater og historiske figurer. Kandidater kan senere logge inn (Authentik) og overstyre AI-ens svar manuelt.
|
||||
* **Semantisk deduplisering (`valgomat_moderation`):** En asynkron jobb overvåker nye brukerskapte akser og spørsmål, slår sammen duplikater (f.eks. "Skattetrykk" og "Skattenivå"), og flagger emosjonelt ladede spørsmål for Sidelinja-redaksjonen i Redaksjonens chat.
|
||||
|
||||
## 7. Arkitektur & Ansvarsfordeling
|
||||
|
||||
| Komponent | Rolle / Ansvar |
|
||||
|---|---|
|
||||
| **SvelteKit (Klient)** | UX, Anonym UUID-håndtering i `localStorage`, kortstokk-swipe-grensesnitt, "lazy loading" av baksiden ved innlogging, visning av Heatmaps via Sidelinja Explorer. |
|
||||
| **Authentik** | SSO for brukere, kandidater og redaksjon. Sammenfletting av anonym UUID med ekte bruker-ID. |
|
||||
| **SpacetimeDB** | Sanntids match-kalkulering (Rust Reducers), swiping-logikk, PCA-beregning, og "Multiplayer"-rom ("Sofagruppa"). Holder kun aktive sesjoner i minnet. |
|
||||
| **PostgreSQL** | Kunnskapsgrafen (`nodes`, `graph_edges`). Permanent lagring av spørsmål, akser, versjonshistorikk og aggregerte data for Sidelinja Explorer. |
|
||||
| **Rust Worker (Sync)** | Synkroniserer batcher av svar fra SpacetimeDB over til PostgreSQL-lagringen via standard sync-mekanismen (se `synkronisering.md`). |
|
||||
| **Rust Worker (AI)** | Kjører `valgomat_generate_profile` og `valgomat_moderation` via jobbkøen. |
|
||||
|
||||
## 8. Dataklassifisering (ref. ARCHITECTURE.md 2.2)
|
||||
|
||||
| Data | Kategori | Detaljer |
|
||||
|---|---|---|
|
||||
| Spørsmål, akser, versjonshistorikk | Kritisk (PG) | Brukergenerert innhold, krever backup |
|
||||
| Individuelle svar (aggregert) | Kritisk (PG) | Synket fra SpacetimeDB |
|
||||
| Aktive sesjoner, live PCA-state | Flyktig (SpacetimeDB) | Tåler tap — bruker svarer på nytt |
|
||||
| AI-genererte kandidatprofiler | Avledet (PG) | Kan regenereres fra partiprogrammer |
|
||||
|
||||
## 9. Instruks for Claude Code
|
||||
1. **Datamodell:** Utvid enum `node_type` i PostgreSQL med `valgomat_question` og `valgomat_axis`. Bruk `graph_edges` med `relation_type = 'AFFECTS_AXIS'` og oppdater `confidence`-feltet for å håndtere crowdsourcet vekting av spørsmål opp mot ulike akser.
|
||||
2. **SpacetimeDB Reducers:** Implementer innkommende events som `SubmitSwipe`, `SuggestAxis`, og match-algoritmen i Rust inne i SpacetimeDB. Pass på at reducere støtter anonym `session_id`.
|
||||
3. **State Management:** SvelteKit skal ikke kreve innlogging for forsiden. Implementer Auth-guards slik at opprettelse av spørsmål, stemmegivning på andres forslag og visning av kommentarer gir `403 Forbidden` for uautoriserte og trigger Authentik-flyten.
|
||||
4. **Explorer API:** Bygg aggregerte Materialized Views i PostgreSQL for Sidelinja Explorer for å unngå tung on-the-fly kalkulering av Heatmaps over millioner av rader.
|
||||
5. **Jobbtyper:** Registrer `valgomat_generate_profile` og `valgomat_moderation` som jobbtyper i jobbkøen (se `jobbkø.md`). Bruk `sidelinja/rutine` som modellalias.
|
||||
6. **Versjonering:** Spørsmål er append-only. Nye versjoner er nye rader med referanse til forgjengeren — aldri `UPDATE` på tekst som har mottatt svar.
|
||||
20
docs/erfaringer/README.md
Normal file
20
docs/erfaringer/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Erfaringer — Ting vi lærte av å feile
|
||||
|
||||
Denne mappen samler **praktiske lærdommer** fra implementering — ikke hva vi valgte, men hva vi lærte som ikke er åpenbart fra koden eller arkitekturdokumentene.
|
||||
|
||||
Formålet er å treffe raskere blink med neste komponent. Hver fil dekker én teknologi eller ett mønster og inneholder konkrete feller, anti-patterns og løsninger vi landet på.
|
||||
|
||||
## Innhold
|
||||
|
||||
| Fil | Tema |
|
||||
|---|---|
|
||||
| `svelte5_reaktivitet.md` | Svelte 5 $state, SSR, reaktivitet gjennom funksjoner |
|
||||
| `spacetimedb_integrasjon.md` | SDK-konvensjoner, TypeScript-bindings, BigInt, tilkobling |
|
||||
| `adapter_moenster.md` | Adapter/factory for PG↔SpacetimeDB, hybrid-tilnærming |
|
||||
|
||||
## Retningslinjer
|
||||
|
||||
- **Kort og konkret.** Maks 1–2 sider per fil. Fellen først, forklaring etter.
|
||||
- **Bare ting som ikke er åpenbare.** Ikke dokumenter at `npm install` installerer pakker.
|
||||
- **Oppdater fremfor å legge til.** Hvis en erfaring utdypes, oppdater eksisterende fil.
|
||||
- **Kodereferanser.** Vis til filer der mønsteret er implementert, så man kan lese koden.
|
||||
65
docs/erfaringer/adapter_moenster.md
Normal file
65
docs/erfaringer/adapter_moenster.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Erfaring: Adapter-mønster for PG ↔ SpacetimeDB
|
||||
|
||||
## 1. Mønsteret som fungerte
|
||||
|
||||
Et felles interface (`ChatConnection`) med to implementasjoner:
|
||||
- **PG-adapter** — polling hvert 3 sek, full-fetch, ingen ekstern avhengighet utover REST API
|
||||
- **SpacetimeDB hybrid-adapter** — PG for historikk + SpacetimeDB WebSocket for sanntidspush
|
||||
|
||||
Factory-funksjon velger adapter basert på miljøvariabel (`VITE_SPACETIMEDB_URL`).
|
||||
|
||||
```
|
||||
ChatBlock.svelte → createChat() → PG-adapter (fallback)
|
||||
→ SpacetimeDB hybrid (hvis URL er satt)
|
||||
```
|
||||
|
||||
**Fordeler:**
|
||||
- Kan teste PG-adapter isolert uten Docker/SpacetimeDB
|
||||
- Fallback er trivielt — fjern env-variabelen
|
||||
- Komponenten vet ingenting om hvilken adapter som brukes
|
||||
|
||||
**Referanse:** `web/src/lib/chat/` — hele mappen er organisert etter dette mønsteret.
|
||||
|
||||
## 2. Anti-pattern: Lazy wrapper som bytter adapter
|
||||
|
||||
Vi prøvde først en "lazy wrapper" som startet med PG-adapter og byttet til SpacetimeDB-adapter når tilkoblingen var klar. Problemet:
|
||||
|
||||
- En plain `let activeConnection` i wrapperen er **ikke reaktiv** i Svelte 5
|
||||
- Når wrapperen byttet adapter, forsvant meldingene — ny adapter startet med tom liste
|
||||
- Svelte-komponentene så aldri byttet fordi proxy-referansen ikke oppdaterte seg
|
||||
|
||||
**Lærdom:** Ikke bytt adapter runtime. Velg én ved oppstart. Hybrid-adapteren løser problemet bedre — den bruker begge kilder samtidig i stedet for å bytte mellom dem.
|
||||
|
||||
## 3. Hybrid fremfor ren SpacetimeDB
|
||||
|
||||
Ren SpacetimeDB-tilnærming krever "oppvarming" — lasting av historikk fra PG inn i SpacetimeDB ved oppstart. Dette skaper:
|
||||
- Kompleksitet i Rust-modulen (load_messages-reducer)
|
||||
- Konfliktrisiko under oppvarming (§8.1 i synkronisering.md)
|
||||
- Tom chat inntil oppvarming er ferdig
|
||||
|
||||
**Hybrid-løsningen:** Frontend henter PG-historikk via REST (umiddelbart tilgjengelig) og lytter på SpacetimeDB kun for *nye* meldinger. Deduplisering skjer i klienten (sjekk mot eksisterende IDer).
|
||||
|
||||
**Fordeler:**
|
||||
- Ingen oppvarming nødvendig
|
||||
- Historikk er alltid tilgjengelig, selv om SpacetimeDB er nede
|
||||
- SpacetimeDB-modulen trenger bare håndtere nye meldinger, ikke historikk
|
||||
|
||||
## 4. Graceful degradation — stille feilhåndtering
|
||||
|
||||
SpacetimeDB-adaptere bør **aldri** vise feilmelding til brukeren ved tilkoblingsproblemer. PG-data er allerede lastet — brukeren har en fungerende chat.
|
||||
|
||||
```typescript
|
||||
.onConnectError((_ctx, err) => {
|
||||
console.warn('[spacetime] connection error, PG-data beholdes:', err);
|
||||
// Ingen error-state til UI — PG-data er intakt
|
||||
})
|
||||
```
|
||||
|
||||
## 5. Anbefaling for neste komponent
|
||||
|
||||
Når Kanban eller Whiteboard skal bygges med SpacetimeDB:
|
||||
|
||||
1. **Start med PG-adapter.** Få hele flyten til å fungere med REST/polling først.
|
||||
2. **Lag SpacetimeDB hybrid-adapter.** PG for historikk, SpacetimeDB for sanntid.
|
||||
3. **Bruk samme factory-mønster.** Felles interface, env-variabel for valg.
|
||||
4. **Test begge adaptere uavhengig** før du integrerer i UI-komponenten.
|
||||
78
docs/erfaringer/spacetimedb_integrasjon.md
Normal file
78
docs/erfaringer/spacetimedb_integrasjon.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Erfaring: SpacetimeDB-integrasjon
|
||||
|
||||
## 1. Genererte bindings — navnekonvensjoner
|
||||
|
||||
SpacetimeDB sin `spacetime generate --lang typescript` produserer bindings med inkonsistente konvensjoner. Sjekk **alltid** de genererte filene i stedet for å gjette.
|
||||
|
||||
| Hva | Konvensjon | Eksempel |
|
||||
|---|---|---|
|
||||
| Tabell-accessor på `conn.db` | snake_case | `conn.db.chat_message` |
|
||||
| Reducer-accessor på `conn.reducers` | camelCase | `conn.reducers.sendMessage()` |
|
||||
| Felt-navn i tabellrader | camelCase | `row.channelId`, `row.authorName` |
|
||||
| Reducer-parametere | Enkelt objekt | `sendMessage({ id, channelId, body, ... })` |
|
||||
|
||||
**Felle:** Man forventer at tabell-accessorer er camelCase (`chatMessage`), men de er snake_case.
|
||||
|
||||
## 2. `DbConnection.builder()` — API-detaljer (SDK v2)
|
||||
|
||||
```typescript
|
||||
DbConnection.builder()
|
||||
.withUri(spacetimeUrl)
|
||||
.withDatabaseName(moduleName) // IKKE withModuleName
|
||||
.withToken(token)
|
||||
.onConnect((connection) => { // første param er DbConnection, ikke EventContext
|
||||
connection.subscriptionBuilder()
|
||||
.subscribe([`SELECT * FROM chat_message WHERE channel_id = '${id}'`]);
|
||||
})
|
||||
.build();
|
||||
```
|
||||
|
||||
**Feller:**
|
||||
- `withModuleName()` finnes ikke — bruk `withDatabaseName()`
|
||||
- `onConnect`-callback mottar `DbConnection`, ikke `EventContext`
|
||||
- `onConnectError`-callback har signatur `(ctx, errMessage)` der `errMessage` er en string
|
||||
|
||||
## 3. BigInt-tidsstempler
|
||||
|
||||
SpacetimeDB `Timestamp`-type bruker `microsSinceEpoch` som er `BigInt`. JavaScript kan ikke blande BigInt med Number:
|
||||
|
||||
```typescript
|
||||
// FEIL — TypeError: Cannot mix BigInt and other types
|
||||
const ms = micros / 1000;
|
||||
|
||||
// RIKTIG — sjekk type, bruk BigInt-divisjon
|
||||
const ms = typeof micros === 'bigint'
|
||||
? Number(micros / 1000n)
|
||||
: Number(micros) / 1000;
|
||||
```
|
||||
|
||||
**Referanse:** `web/src/lib/chat/spacetime.svelte.ts` — `spacetimeRowToMessage()`.
|
||||
|
||||
## 4. Rust-modul — borrow checker med SpacetimeDB-makroer
|
||||
|
||||
SpacetimeDB-makroer genererer kode som tar eierskap over struct-felter. Bruk verdier **før** du flytter dem inn i structs:
|
||||
|
||||
```rust
|
||||
// FEIL — channel_id er moved inn i ChatMessage, kan ikke bruke i log!() etterpå
|
||||
let msg = ChatMessage { channel_id, body, ... };
|
||||
log::info!("Melding i kanal {}", channel_id); // borrow after move
|
||||
|
||||
// RIKTIG — bruk verdien før struct-opprettelse
|
||||
let log_msg = format!("Melding i kanal {}", channel_id);
|
||||
let msg = ChatMessage { channel_id, body, ... };
|
||||
log::info!("{}", log_msg);
|
||||
```
|
||||
|
||||
## 5. Publisering og lokal testing
|
||||
|
||||
```bash
|
||||
# Publiser modul mot lokal SpacetimeDB (må kjøre i Docker først)
|
||||
cd spacetimedb
|
||||
spacetime publish sidelinja-realtime --server http://localhost:3000
|
||||
|
||||
# Generer TypeScript-bindings
|
||||
spacetime generate --lang typescript --out-dir ../web/src/lib/chat/module_bindings \
|
||||
--project-path .
|
||||
```
|
||||
|
||||
**Merk:** Docker-containeren (`clockworklabs/spacetime:latest`) må kjøre før publisering. Modulen overlever container-restart (data i volum `.docker-data/spacetimedb`).
|
||||
73
docs/erfaringer/svelte5_reaktivitet.md
Normal file
73
docs/erfaringer/svelte5_reaktivitet.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Erfaring: Svelte 5 Reaktivitet
|
||||
|
||||
## 1. `$state` i `.svelte.ts` krever getters
|
||||
|
||||
Svelte 5 sin `$state` lager reaktive proxyer. Når en funksjon returnerer et objekt med `$state`-verdier, **mister man reaktiviteten** hvis man returnerer verdien direkte:
|
||||
|
||||
```typescript
|
||||
// FEIL — mister reaktivitet, verdien fryses ved retur
|
||||
function createThing() {
|
||||
let count = $state(0);
|
||||
return { count }; // snapshot, ikke reaktiv
|
||||
}
|
||||
|
||||
// RIKTIG — getter bevarer proxy-tilgang
|
||||
function createThing() {
|
||||
let count = $state(0);
|
||||
return {
|
||||
get count() { return count; }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Referanse:** `web/src/lib/chat/pg.svelte.ts` — alle returnerte verdier bruker getters.
|
||||
|
||||
## 2. SSR kjører alt utenfor `onMount`
|
||||
|
||||
SvelteKit server-renderer kjører komponent-script ved SSR. Alt som bruker browser-APIer (WebSocket, `fetch` til relative URLer, `setInterval`, `sessionStorage`) **krasjer på serveren** hvis det ikke er beskyttet.
|
||||
|
||||
```typescript
|
||||
// FEIL — krasjer ved SSR
|
||||
let chat = createChat(channelId); // kjøres på server
|
||||
|
||||
// RIKTIG — kun i browser
|
||||
import { onMount } from 'svelte';
|
||||
let chat = $state<ChatConnection | null>(null);
|
||||
onMount(() => {
|
||||
chat = createChat(channelId);
|
||||
return () => chat?.destroy();
|
||||
});
|
||||
```
|
||||
|
||||
Alternativt kan factory-funksjonen selv sjekke:
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
if (browser) { /* ... */ }
|
||||
```
|
||||
|
||||
**Referanse:** `web/src/lib/chat/create.svelte.ts` — `browser`-guard i factory, `web/src/lib/blocks/ChatBlock.svelte` — `onMount` for opprettelse.
|
||||
|
||||
## 3. `$derived` og `$effect` med null-initialisert state
|
||||
|
||||
Når en `$state`-variabel starter som `null` (fordi den settes i `onMount`), må `$derived` og `$effect` håndtere null-tilfellet:
|
||||
|
||||
```typescript
|
||||
let chat = $state<ChatConnection | null>(null);
|
||||
let messages = $derived(chat?.messages ?? []); // fallback til tom liste
|
||||
|
||||
$effect(() => {
|
||||
const count = messages.length; // trygt, alltid array
|
||||
if (count > prevCount) scrollToBottom();
|
||||
prevCount = count;
|
||||
});
|
||||
```
|
||||
|
||||
**Referanse:** `web/src/lib/blocks/ChatBlock.svelte` — `$derived` med optional chaining.
|
||||
|
||||
## 4. Polling: full-fetch slår inkrementell akkumulering
|
||||
|
||||
Vi prøvde først å bruke en `latestTimestamp`-cursor for å hente kun nye meldinger og appende dem. Dette ga duplikater — telleren vokste mens man tastet (polling i bakgrunnen).
|
||||
|
||||
**Løsning:** Enkel `refresh()` som alltid henter full liste og erstatter `messages` i sin helhet. For et lite volum meldinger er dette enklere og tryggere enn inkrementell logikk.
|
||||
|
||||
**Referanse:** `web/src/lib/chat/pg.svelte.ts` — `refresh()` gjør full fetch, ingen akkumulering.
|
||||
|
|
@ -7,7 +7,7 @@ Et internt redaksjonelt verktøy for å samle inn research fra nettet. Programle
|
|||
## 2. Arkitektur & Dataflyt
|
||||
1. **Input (SvelteKit):** En modal i grensesnittet der brukeren limer inn råtekst og valgfri kilde-URL, og knytter det til et *Tema* (f.eks. "Skolepolitikk").
|
||||
2. **Prosessering (Jobbkø + OpenRouter):**
|
||||
* Backend mottar teksten og oppretter en `research_clip`-jobb i jobbkøen (se `docs/features/jobbkø.md`). Rust-workeren plukker opp jobben og sender request til OpenRouter (Claude-modell).
|
||||
* Backend mottar teksten og oppretter en `research_clip`-jobb i jobbkøen (se `docs/infra/jobbkø.md`). Rust-workeren plukker opp jobben og sender request til OpenRouter (Claude-modell).
|
||||
* **System Prompt:** Skal instruere AI-en til å returnere JSON med følgende struktur:
|
||||
`{ "title": "...", "summary": ["..."], "cleaned_text": "...", "actors": ["..."], "factoids": ["..."] }`
|
||||
3. **Lagring (PostgreSQL):** Backend lagrer resultatet relasjonelt i Kunnskapsgrafen. *Aktører* som ikke finnes opprettes. *Faktoider* kobles til aktørene. Selve artikkelen knyttes til det valgte *Temaet*.
|
||||
|
|
|
|||
139
docs/features/chat.md
Normal file
139
docs/features/chat.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Feature: Chat (Channels & Meldinger)
|
||||
**Filsti:** `docs/features/chat.md`
|
||||
|
||||
## 1. Konsept
|
||||
En universell, sanntids meldingskomponent bygget på SpacetimeDB. Chat er ikke bundet til én kontekst — den kan knyttes til enhver node i Kunnskapsgrafen via **channels**. Ulike konsepter bruker chat med ulik konfigurasjon, men all infrastruktur er delt.
|
||||
|
||||
## 2. Channels-modellen
|
||||
En **channel** er en meldingsstrøm knyttet til en vilkårlig node (parent) i grafen. Channelen er selv en node (`node_type = 'channel'`), noe som betyr at den deltar i grafen og arver workspace-isolasjon.
|
||||
|
||||
```
|
||||
┌─────────────┐ parent_id ┌─────────────┐
|
||||
│ Channel │ ──────────────────► │ Vilkårlig │
|
||||
│ (node) │ │ node │
|
||||
└──────┬──────┘ └─────────────┘
|
||||
│
|
||||
│ channel_id
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Meldinger │
|
||||
│ (nodes) │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
En node kan ha **flere channels**. Eksempler:
|
||||
- Et Tema har "Diskusjon" (default) + "Research-dump"
|
||||
- En Episode har "Redaksjonelt" (intern kommentartråd)
|
||||
- Et Møte har "Scratchpad" (flyktig, TTL)
|
||||
- En Aktør har "Notater" (valgfri)
|
||||
|
||||
### 2.1 Channel-konfigurasjon
|
||||
Hver channel har en `config` (JSONB) som styrer hvilke capabilities den støtter:
|
||||
|
||||
```json
|
||||
{
|
||||
"threads": true, // reply-to-tråder
|
||||
"mentions": true, // #/@ parsing + automatiske graph_edges
|
||||
"attachments": true, // filopplasting
|
||||
"research_clips": true, // research_clip meldingstype (AI-prosessert)
|
||||
"ttl_days": null // null = permanent, tall = auto-slett etter N dager
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Standard channel-presets per konsept
|
||||
|
||||
| Konsept | Channel-navn | Config |
|
||||
|---|---|---|
|
||||
| **Redaksjonen** (Tema) | "Diskusjon" | `threads: true, mentions: true, attachments: true, research_clips: true, ttl_days: null` |
|
||||
| **Redaksjonen** (Tema) | "Research" (valgfri) | `threads: false, mentions: true, attachments: true, research_clips: true, ttl_days: null` |
|
||||
| **Møterommet** | "Scratchpad" | `threads: false, mentions: false, attachments: false, research_clips: false, ttl_days: 90` |
|
||||
| **Studioet** | "Studio-chat" | `threads: false, mentions: false, attachments: false, research_clips: false, ttl_days: 30` |
|
||||
| **Episode** | "Redaksjonelt" | `threads: true, mentions: true, attachments: true, research_clips: false, ttl_days: null` |
|
||||
|
||||
Presets er kun defaults — en workspace-admin kan justere config per channel.
|
||||
|
||||
### 2.3 Automatisk opprettelse
|
||||
Når en node opprettes som forventes å ha chat (Tema, Episode, Møte), oppretter systemet automatisk en default-channel. Dette skjer i SvelteKit server-side som en del av node-opprettelsestransaksjonen.
|
||||
|
||||
## 3. Meldinger
|
||||
|
||||
### 3.1 Datamodell (PostgreSQL)
|
||||
Meldinger er noder i Kunnskapsgrafen (`node_type = 'melding'`):
|
||||
|
||||
```sql
|
||||
messages (
|
||||
id UUID PK → nodes(id),
|
||||
channel_id UUID NOT NULL → channels(id),
|
||||
reply_to UUID → messages(id), -- tråder (hvis config.threads = true)
|
||||
author_id TEXT NOT NULL → users,
|
||||
message_type message_type, -- 'text', 'research_clip', 'factoid', 'system'
|
||||
body TEXT NOT NULL,
|
||||
metadata JSONB, -- ekstra data (research-klipp AI-resultat, etc.)
|
||||
edited_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 SpacetimeDB (sanntid)
|
||||
SpacetimeDB holder aktive channels' meldinger i minnet. Nye meldinger sendes via SpacetimeDB og kringkastes til alle tilkoblede klienter i samme workspace og channel. Synkes til PostgreSQL med ~5 sek forsinkelse (se `docs/infra/synkronisering.md`).
|
||||
|
||||
## 4. Mentions & Autocomplete
|
||||
Kun aktive når `config.mentions = true`.
|
||||
|
||||
* **Trigger-tegn:** `#` (Temaer/Aktører fra Kunnskapsgrafen), `@` (brukere/redaksjonsmedlemmer), `/` (kommandoer).
|
||||
* **Filtrering:** Svelte-klienten filtrerer den lokale SpacetimeDB-cachen umiddelbart. Skriver man `#Ha...` vises en klikkbar liste ("Hans Petter Sjøli", "Høyre").
|
||||
* **Grafkobling:** Ved `#`-mention opprettes automatisk `MENTIONS`-edges i `graph_edges` mellom meldingen og den nevnte noden.
|
||||
* **Mobil-optimalisert:** Autocomplete-listen er tappbar og tilpasset mindre skjermer.
|
||||
|
||||
## 5. Tråder
|
||||
Kun aktive når `config.threads = true`. Meldinger kan ha en `reply_to`-referanse. Frontend viser tråder som innrykk eller ekspanderbare grupper.
|
||||
|
||||
## 6. Vedlegg
|
||||
Kun aktive når `config.attachments = true`. Meldinger kan ha vedlegg via `message_attachments` → `media_files`. Whiteboard-eksport kan knyttes som vedlegg.
|
||||
|
||||
## 7. Versjonshistorikk
|
||||
Alle meldinger støtter redigering med full historikk via `message_revisions`. Original tekst bevares alltid.
|
||||
|
||||
## 8. Tale-til-tekst (Voice-to-text)
|
||||
Mobilvennlig diktering for situasjoner der tastatur er upraktisk. Brukeren trykker en mikrofon-knapp, snakker, og får teksten tilbake som en vanlig melding klar til redigering og sending.
|
||||
|
||||
### 8.1 Flyt
|
||||
1. Bruker trykker og holder (eller toggler) mikrofon-knappen i chat-feltet.
|
||||
2. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus).
|
||||
3. Lydklippet sendes til Whisper (`POST /v1/audio/transcriptions`, `response_format=text`, `language=no`) via SvelteKit server-side.
|
||||
4. Transkripsjonen settes inn i meldingsfeltet — brukeren kan redigere før sending.
|
||||
5. Ingen lagring av lydfilen — den kastes etter transkripsjon.
|
||||
|
||||
### 8.2 Avgrensning
|
||||
* Dette er **ikke** en lydmelding-feature (à la WhatsApp). Lyden er et transportmiddel for tekst. Kun teksten lagres.
|
||||
* Whisper-kallet er kort (<30 sek tale) og kan rutes direkte til Whisper-serveren uten jobbkø.
|
||||
* Bruk `small`-modellen for lav latens. Navnenøyaktighet er mindre viktig for korte chatmeldinger.
|
||||
|
||||
## 9. TTL (automatisk opprydding)
|
||||
Channels med `config.ttl_days` satt til et tall får sine meldinger automatisk slettet av en nattlig jobbkø-jobb. Brukes for flyktige kontekster (scratchpads, studio-chat).
|
||||
|
||||
## 10. Implementeringsstatus
|
||||
|
||||
### Ferdig (mars 2025)
|
||||
- **ChatBlock.svelte:** Refaktorert til adapter-mønster via `createChat()` factory
|
||||
- **PG-adapter (`pg.svelte.ts`):** Polling hvert 3. sek, full-fetch (ingen akkumulering). Fungerer som selvstendig fallback.
|
||||
- **SpacetimeDB hybrid-adapter (`spacetime.svelte.ts`):** Henter historikk fra PG via REST + lytter på SpacetimeDB WebSocket for sanntidspush. Dedupliserer mot PG-data. Faller tilbake til PG REST ved sending hvis SpacetimeDB er nede.
|
||||
- **Factory (`create.svelte.ts`):** Velger adapter basert på `VITE_SPACETIMEDB_URL`. SSR-safe med `browser`-guard.
|
||||
- **Shared types (`types.ts`):** `Message` og `ChatConnection` interface brukt av begge adaptere.
|
||||
- **SpacetimeDB Rust-modul (`spacetimedb/src/lib.rs`):** `ChatMessage`-tabell, `SyncOutbox`, `send_message`-reducer. Publisert som `sidelinja-realtime`.
|
||||
- **TypeScript-bindings:** Generert fra SpacetimeDB-modulen, camelCase-properties.
|
||||
- **Autoscroll:** `$effect` som scroller ned ved nye meldinger.
|
||||
- **Datogruppering:** Visuell datoseparator ("I dag", "I går", dato).
|
||||
|
||||
### Gjenstår
|
||||
- **Sync-worker (SpacetimeDB → PG):** Rust-worker som poller `sync_outbox` og persisterer til PostgreSQL. Se `docs/infra/synkronisering.md` §5.1.
|
||||
- **Tråder, mentions, vedlegg, TTL** — avventer Kunnskapsgraf CRUD (Lag 2).
|
||||
- **Autentisering:** `author_name`/`author_id` settes ikke automatisk ennå (avventer Authentik-integrasjon).
|
||||
|
||||
## 11. Instruks for Claude Code
|
||||
* **Opprettelsesrekkefølge:** Opprett `nodes`-rad → `channels`-rad → (for meldinger) `nodes`-rad → `messages`-rad. Alt i én transaksjon med riktig `workspace_id`.
|
||||
* **Channel-opprettelse:** Når en Tema, Episode eller Møte opprettes, opprett alltid en default-channel i samme transaksjon.
|
||||
* **Mentions-parsing:** Skjer i SvelteKit server-side ved mottak av meldingen. Parse `#`- og `@`-tags, valider mot noder i workspace-et, og opprett `graph_edges`.
|
||||
* **Config-respekt:** Frontend-komponenten må lese `channel.config` og slå av/på UI-elementer (tråd-knapp, vedlegg-knapp, mention-autocomplete) basert på config.
|
||||
* **SpacetimeDB er autoritativ** for aktive meldinger, PG for historikk.
|
||||
* **Alt er workspace-scopet.** Channels arver workspace via `nodes.workspace_id`.
|
||||
134
docs/features/kalender.md
Normal file
134
docs/features/kalender.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Kalender
|
||||
|
||||
## Oversikt
|
||||
Kalender er en workspace-blokk for redaksjonell planlegging og tidslinjevisning. Hendelser er nodes i kunnskapsgrafen og kan kobles til episoder, temaer, aktører og channels. Kalendere kan fritt abonnere på hverandre — på tvers av brukere og workspaces.
|
||||
|
||||
## Konsept
|
||||
|
||||
### Kalendere som grafnoder
|
||||
Hver kalender er en node (`node_type: 'calendar'`). Hendelser er også nodes (`node_type: 'event'`) som tilhører en kalender via en `PART_OF`-relasjon. Dette gir:
|
||||
- Full workspace-isolasjon via eksisterende RLS
|
||||
- Relasjoner mellom hendelser og resten av kunnskapsgrafen (episoder, aktører, temaer)
|
||||
- Channels knyttet til hendelser (diskusjonstråd per hendelse)
|
||||
|
||||
### Typer kalendere
|
||||
|
||||
| Type | Eier | Synlighet | Eksempel |
|
||||
|------|------|-----------|----------|
|
||||
| **Workspace** | Workspace (admin) | Alle medlemmer | "Sidelinja Produksjonskalender" |
|
||||
| **Personlig** | Bruker | Kun eier (+ eksplisitt deling) | "Vegards arbeidskalender" |
|
||||
| **Offentlig** | Workspace (admin) | Alle som abonnerer | "Sidelinja Publiseringsplan" |
|
||||
|
||||
### Abonnementsmodell
|
||||
Kalendere kan abonnere på andre kalendere via en `SUBSCRIBES_TO`-edge i grafen. Abonnenten ser hendelser read-only — bare kildekalenderen kan redigere.
|
||||
|
||||
```
|
||||
Vegards kalender ──SUBSCRIBES_TO──▶ Sidelinja Produksjon
|
||||
Vegards kalender ──SUBSCRIBES_TO──▶ Sidelinja Publisering
|
||||
Ekstern partner ──SUBSCRIBES_TO──▶ Sidelinja Publisering (offentlig)
|
||||
```
|
||||
|
||||
**Prinsipp:** Enhver kalender kan abonnere på enhver annen kalender den har tilgang til. Tilgang styres av:
|
||||
- Samme workspace → alltid tilgang til workspace-kalendere
|
||||
- Cross-workspace → kun til kalendere merket "offentlig"
|
||||
- Personlige → kun eier + eksplisitt inviterte
|
||||
|
||||
### Samlet visning
|
||||
UI-et viser én samlet kalendervisning per bruker: alle hendelser fra egne + abonnerte kalendere, fargekodet per kilde. Brukeren kan toggle synlighet per kalender.
|
||||
|
||||
## Datamodell
|
||||
|
||||
### Nye node_types
|
||||
Krever at `node_type` enum utvides med `'calendar'` og `'event'`.
|
||||
|
||||
### events-tabell (detalj for event-nodes)
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
calendar_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ,
|
||||
all_day BOOLEAN NOT NULL DEFAULT false,
|
||||
recurrence JSONB, -- iCal RRULE som JSON (valgfritt)
|
||||
location TEXT,
|
||||
color TEXT, -- hex fargekode for UI
|
||||
created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
|
||||
### calendars-tabell (detalj for calendar-nodes)
|
||||
```sql
|
||||
CREATE TABLE calendars (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||
visibility TEXT NOT NULL DEFAULT 'workspace'
|
||||
CHECK (visibility IN ('workspace', 'personal', 'public')),
|
||||
owner_user TEXT REFERENCES users(authentik_id) ON DELETE SET NULL
|
||||
-- owner_user er NULL for workspace-kalendere (eid av workspace via nodes.workspace_id)
|
||||
);
|
||||
```
|
||||
|
||||
### Abonnementer via graph_edges
|
||||
```sql
|
||||
-- Kalender A abonnerer på Kalender B
|
||||
INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type)
|
||||
VALUES (:ws, :kalender_a, :kalender_b, 'SUBSCRIBES_TO');
|
||||
```
|
||||
|
||||
Krever ny relasjonstype:
|
||||
```sql
|
||||
INSERT INTO relation_types (name, label, description, system)
|
||||
VALUES ('SUBSCRIBES_TO', 'Abonnerer på', 'Kalender følger en annen kalender', true);
|
||||
```
|
||||
|
||||
## ICS/CalDAV-eksport
|
||||
|
||||
### Read-only ICS-feed
|
||||
Hver kalender med `visibility = 'public'` (eller workspace-kalendere for innloggede) får en ICS-URL:
|
||||
```
|
||||
https://sidelinja.org/cal/{workspace_slug}/{calendar_id}.ics
|
||||
```
|
||||
|
||||
SvelteKit genererer ICS-feeden server-side. Brukere kan legge denne inn i Google Calendar, Apple Calendar, Thunderbird osv.
|
||||
|
||||
### Ingen import
|
||||
Sidelinja importerer ikke fra eksterne kalendere i Fase 1. Sidelinja er kilden til sannhet for sine egne hendelser. Eventuell CalDAV-synk (toveis) er en fremtidig utvidelse.
|
||||
|
||||
## UI-komponenter
|
||||
|
||||
### CalendarBlock (workspace-blokk)
|
||||
Integreres i det komponerbare side-systemet som blokk-type `calendar`:
|
||||
- Månedsvisning (default), ukevisning, listevisning
|
||||
- Fargekodet per kalender-kilde
|
||||
- Klikk hendelse → åpne detalj med grafkoblinger og channel
|
||||
- Dra for å opprette ny hendelse (desktop)
|
||||
- Toggle synlighet per abonnert kalender i sidebar-filter
|
||||
|
||||
### Hendelsesdetalj
|
||||
- Tittel, tid, beskrivelse, lokasjon
|
||||
- Grafkoblinger: koble til episode, tema, aktør
|
||||
- Channel: diskusjonstråd (opprettes on-demand)
|
||||
- Deltakere: workspace-medlemmer
|
||||
|
||||
## Redaksjonell verdi
|
||||
Kalenderen er ikke generisk møtebooking — den er **redaksjonell planlegging**:
|
||||
- **Publiseringsdatoer**: Når skal Episode 43 ut? Synlig for hele redaksjonen
|
||||
- **Innspillingsslots**: Bookede tider i Studioet, koblet til episode-node
|
||||
- **Gjeste-intervjuer**: Hendelse koblet til aktør-node, med forberedelseskanal
|
||||
- **Research-deadlines**: Koblet til tema-node
|
||||
- **Sesongoversikt**: Alle episoder i en sesong som hendelser på tidslinje
|
||||
|
||||
## Bygger på
|
||||
- Kunnskapsgraf (nodes, graph_edges)
|
||||
- Workspace-modell og RLS
|
||||
- Komponerbare sider (blokk-type `calendar`)
|
||||
- Channels (diskusjon per hendelse)
|
||||
|
||||
## Åpne spørsmål
|
||||
- **Recurring events**: RRULE er komplekst. Fase 1 kan starte uten recurrence og legge det til som utvidelse.
|
||||
- **Tidssoner**: Alle tider lagres som TIMESTAMPTZ (UTC i PG). Frontend viser i brukerens lokale tidssone. Eksplisitt tidssone per hendelse trengs bare ved reise/eksterne gjester.
|
||||
- **Varsler/påminnelser**: Kan kobles til jobbkøen — en `reminder`-jobb som poster i hendelsens channel X minutter før start. Utsettes til Fase 2.
|
||||
- **Drag-and-drop i kalender**: Flytte/resize hendelser. Nyttig men ikke kritisk for Fase 1 — enkel klikk-for-å-redigere holder.
|
||||
22
docs/features/kanban.md
Normal file
22
docs/features/kanban.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Feature: Kanban (Planlegging)
|
||||
**Filsti:** `docs/features/kanban.md`
|
||||
|
||||
## 1. Konsept
|
||||
Et drag-and-drop Kanban-brett for planlegging. Primært brukt til episodeplanlegging i Redaksjonen, men også mottaker av AI-genererte action points fra Møterommet.
|
||||
|
||||
## 2. Datamodell
|
||||
* **Episode** er en container (node i Kunnskapsgrafen) som samler et utvalg Temaer.
|
||||
* **Posisjon/rekkefølge** lagres i SpacetimeDB for sanntid, synkes til PostgreSQL.
|
||||
* **Status** på et Tema innenfor en episode (f.eks. "Forslag", "Bekreftet", "Innspilt") håndteres som et felt i SpacetimeDB.
|
||||
|
||||
## 3. Brukes av
|
||||
|
||||
| Konsept | Bruk |
|
||||
|---|---|
|
||||
| Redaksjonen | Episodeplanlegging — dra Temaer inn i Kjøreplanen |
|
||||
| Møterommet | AI-referenten foreslår nye kort basert på action points |
|
||||
|
||||
## 4. Instruks for Claude Code
|
||||
* Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker.
|
||||
* SpacetimeDB er autoritativ for posisjon/rekkefølge — frontend speiler SpacetimeDB-state.
|
||||
* Alt er workspace-scopet.
|
||||
78
docs/features/kunnskaps_bridge.md
Normal file
78
docs/features/kunnskaps_bridge.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Feature: Kunnskaps-Bridge (Cross-Workspace Discovery)
|
||||
**Filsti:** `docs/features/kunnskaps_bridge.md`
|
||||
|
||||
## 1. Konsept
|
||||
En valgfri, opt-in feature som lar brukere oppdage semantisk beslektet kunnskap på tvers av workspaces de har tilgang til. Bryter ikke workspace-isolasjonen — resultatet er en *peker* ("dette finnes i Podcast B"), ikke selve innholdet. Brukeren må ha tilgang til begge workspaces for å se treffet.
|
||||
|
||||
## 2. Avgrensning: Hva dette IKKE er
|
||||
- **Ikke datadeling.** Ingen data kopieres mellom workspaces. Bridge viser kun at en relevant node *finnes*.
|
||||
- **Ikke automatisk.** Begge workspaces må ha Bridge eksplisitt aktivert av en admin.
|
||||
- **Ikke synlig for gjester.** Kun for workspace-medlemmer med tilgang til begge sider.
|
||||
- **Ikke et søk i andres data.** Du ser bare treff i workspaces du allerede er medlem av.
|
||||
|
||||
## 3. Teknisk arkitektur
|
||||
|
||||
### 3.1 Vector Embeddings (pgvector)
|
||||
Krever `pgvector`-extension i PostgreSQL:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
ALTER TABLE actors ADD COLUMN embedding vector(768);
|
||||
ALTER TABLE topics ADD COLUMN embedding vector(768);
|
||||
ALTER TABLE factoids ADD COLUMN embedding vector(768);
|
||||
|
||||
CREATE INDEX idx_actors_embedding ON actors USING ivfflat (embedding vector_cosine_ops);
|
||||
CREATE INDEX idx_topics_embedding ON topics USING ivfflat (embedding vector_cosine_ops);
|
||||
CREATE INDEX idx_factoids_embedding ON factoids USING ivfflat (embedding vector_cosine_ops);
|
||||
```
|
||||
|
||||
### 3.2 Embedding-generering (`generate_embeddings`)
|
||||
En jobbkø-jobb som genererer embeddings for nye/endrede noder:
|
||||
1. Rust-worker plukker opp jobben fra jobbkøen.
|
||||
2. Bygger en tekst-representasjon av noden (navn, body, tilknyttede faktoider).
|
||||
3. Sender til AI Gateway (`sidelinja/rutine`) for embedding-generering.
|
||||
4. Lagrer vektoren i pgvector-kolonnen.
|
||||
5. Re-genereres ved vesentlige endringer av noden.
|
||||
|
||||
### 3.3 Cross-workspace søk
|
||||
Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"):
|
||||
1. SvelteKit server-side henter brukerens tilgjengelige workspaces fra `workspace_members`.
|
||||
2. Kjører et similarity-søk med `<=>` (cosine distance) **som superuser** (bypasser RLS) — men filtrerer eksplisitt mot brukerens workspace-liste:
|
||||
```sql
|
||||
SELECT n.workspace_id, t.name, t.embedding <=> $target_embedding AS distance
|
||||
FROM topics t
|
||||
JOIN nodes n ON t.id = n.id
|
||||
WHERE n.workspace_id = ANY($user_workspace_ids)
|
||||
AND n.workspace_id != $current_workspace_id
|
||||
AND t.embedding <=> $target_embedding < 0.3
|
||||
ORDER BY distance
|
||||
LIMIT 10;
|
||||
```
|
||||
3. Resultatet vises som en diskret "Finnes også i..."-seksjon i UI-et.
|
||||
|
||||
### 3.4 Workspace-config
|
||||
Bridge aktiveres per workspace i `workspaces.settings`:
|
||||
```json
|
||||
{
|
||||
"bridge_enabled": true,
|
||||
"bridge_discoverable": true
|
||||
}
|
||||
```
|
||||
- `bridge_enabled` — workspace kan søke i andre workspaces
|
||||
- `bridge_discoverable` — andre workspaces kan finne noder i dette workspace-et
|
||||
|
||||
Begge må være `true` for at en kobling skal vises.
|
||||
|
||||
## 4. Dataklassifisering
|
||||
|
||||
| Data | Kategori | Detaljer |
|
||||
|---|---|---|
|
||||
| Embedding-vektorer | Avledet (PG) | Kan regenereres fra nodeinnhold |
|
||||
|
||||
## 5. Instruks for Claude Code
|
||||
* pgvector er en ny avhengighet — dokumenter i docker-compose og setup-guides.
|
||||
* Cross-workspace søk er det **eneste** stedet i systemet der bruker-initierte handlinger bypasser RLS. Isolér denne koden grundig — egen funksjon med eksplisitt workspace-filtrering, aldri gjenbruk i andre kontekster.
|
||||
* Embedding-dimensjon (768) bør matche modellen som brukes. Konfigurér som konstant, ikke hardkod overalt.
|
||||
* Jobbtype `generate_embeddings` bruker `sidelinja/rutine` som modellalias.
|
||||
* Bridge er **Lag 4+** — krever fylt kunnskapsgraf i minst to workspaces.
|
||||
|
|
@ -62,24 +62,34 @@ CREATE INDEX idx_segments_transcript_fts ON segments USING GIN (to_tsvector('nor
|
|||
```
|
||||
|
||||
### 3.3 Kantene: `graph_edges`
|
||||
All kobling skjer i én sentral tabell med ekte FK-integritet:
|
||||
All kobling skjer i én sentral tabell med ekte FK-integritet. `workspace_id` er denormalisert fra `nodes` for å muliggjøre RLS direkte på edges:
|
||||
|
||||
```sql
|
||||
CREATE TABLE graph_edges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
target_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
relation_type TEXT NOT NULL, -- 'MENTIONS', 'CONTRADICTS', 'WORKS_FOR', 'PART_OF', 'DISCUSSED_IN'
|
||||
context_id UUID REFERENCES nodes(id) ON DELETE SET NULL,
|
||||
confidence REAL CHECK (confidence BETWEEN 0.0 AND 1.0),
|
||||
created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
|
||||
origin TEXT NOT NULL DEFAULT 'system', -- 'system', 'user', 'ai'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT no_self_reference CHECK (source_id != target_id)
|
||||
CONSTRAINT no_self_reference CHECK (source_id != target_id),
|
||||
CONSTRAINT unique_edge UNIQUE (source_id, target_id, relation_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_edges_source ON graph_edges(source_id);
|
||||
CREATE INDEX idx_edges_target ON graph_edges(target_id);
|
||||
CREATE INDEX idx_edges_relation ON graph_edges(relation_type);
|
||||
CREATE INDEX idx_edges_workspace ON graph_edges(workspace_id);
|
||||
```
|
||||
|
||||
**Merk:** `UNIQUE(source_id, target_id, relation_type)` forhindrer duplikate relasjoner. Bruk `ON CONFLICT DO NOTHING` eller `ON CONFLICT DO UPDATE SET confidence = ...` ved upsert.
|
||||
|
||||
**Merk:** `node_type`-enumet kan utvides av feature-spesifikke migrasjoner (f.eks. `valgomat_question`, `valgomat_axis`). Se `docs/concepts/valgomaten.md` for eksempel.
|
||||
|
||||
## 4. Segmenter og Transkripsjoner
|
||||
|
||||
### 4.1 Segment som grafnode
|
||||
|
|
@ -93,7 +103,7 @@ Episode 42 (node: episode)
|
|||
└── Segment 21:08-45:00 (node: segment) ──DISCUSSED_IN──► Tema: Kommuneøkonomi
|
||||
```
|
||||
|
||||
Kapitler i RSS-feeden genereres fra segmentene, men er et eget konsern (se `docs/features/podcastfabrikken.md`).
|
||||
Kapitler i RSS-feeden genereres fra segmentene, men er et eget konsern (se `docs/concepts/podcastfabrikken.md`).
|
||||
|
||||
### 4.2 Transkripsjoner: Git som master, PG som søkeindeks
|
||||
Transkripsjoner lever i **to steder** med klart eierskap:
|
||||
|
|
@ -127,8 +137,10 @@ Grafen bygger seg opp organisk gjennom daglig bruk av Sidelinja-suiten:
|
|||
5. **Publisering:** Når en episode publiseres, kobles segmentene automatisk til relevante Temaer og Aktører basert på AI-analyse av transkripsjonen.
|
||||
|
||||
## 6. Instruks for Claude Code
|
||||
* **Workspace-isolasjon:** Alle noder tilhører en workspace. Opprett alltid noder med riktig `workspace_id`. Spørringer mot detailtabeller (actors, topics, etc.) filtrerer alltid via JOIN med nodes.
|
||||
* **`nodes`-tabellen er obligatorisk.** Opprett alltid en rad i `nodes` før du inserter i en detailtabell. Bruk en hjelpefunksjon som gjør begge i én transaksjon.
|
||||
* **`graph_edges` krever `workspace_id`.** Ved opprettelse av edges, sett `workspace_id` fra kilde-noden. `UNIQUE(source_id, target_id, relation_type)` hindrer duplikater — bruk `ON CONFLICT` ved upsert.
|
||||
* **Graf-spørringer:** Bruk `WITH RECURSIVE` i PostgreSQL når du bygger endepunkter som skal hente ut "Linked Mentions" eller nettverket rundt en spesifikk Aktør opp til 2-3 ledd ut.
|
||||
* **Fremtidssikring for UI:** Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (som D3.js eller Vis.js) i Svelte-frontenden. Formatet bør være `{ "nodes": [...], "edges": [...] }`.
|
||||
* **Transkripsjon-reimport:** Workeren må være idempotent. Bruk `uuid_v5(episode_uuid, start_time_ms)` for deterministiske segment-UUIDs. Slett og gjenopprett segmenter i én transaksjon, men **ikke** slett edges som peker til segmentene — de overlever fordi UUID-en er stabil.
|
||||
* **Transkripsjon-reimport:** Workeren må være idempotent. Bruk `uuid_v5(episode_uuid, start_time_ms)` for deterministiske segment-UUIDs. Slett og gjenopprett segmenter i én transaksjon, men **ikke** slett edges som peker til segmentene — de overlever fordi UUID-en er stabil. **Merk:** Hvis manuelle korrigeringer endrer segment-grenser (start_time), endres UUID-en. Løsning: automatisk flytt eksisterende edges til nærmeste nye segment basert på tidsintervall-overlapp.
|
||||
* **Full-text search:** Bruk `to_tsvector('norwegian', transcript)` for norsk språkstøtte i søk.
|
||||
|
|
|
|||
66
docs/features/live_ai.md
Normal file
66
docs/features/live_ai.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Feature: Live AI (Faktoid-oppslag & Referent)
|
||||
**Filsti:** `docs/features/live_ai.md`
|
||||
|
||||
## 1. Konsept
|
||||
AI-drevet analyse av sanntids transkripsjon. Opererer i to moduser med samme underliggende pipeline (Whisper → NER → handling), men ulik output.
|
||||
|
||||
## 2. Studio-modus: Faktoid-oppslag
|
||||
Brukes i Studioet (se `docs/concepts/studioet.md`). En "virtuell co-host" som dytter relevante faktoider til programlederne i sanntid.
|
||||
|
||||
### 2.1 Dataflyt
|
||||
1. Live transkripsjon (se `docs/features/live_transkripsjon.md`) leverer tekst-chunks.
|
||||
2. Rust-tjenesten analyserer for egennavn (Named Entity Recognition).
|
||||
3. Lynraskt oppslag i PostgreSQL: `SELECT * FROM factoids JOIN actors... WHERE actor.name = $1`.
|
||||
4. Treff dytter `LiveFactoidEvent` inn i SpacetimeDB.
|
||||
5. SvelteKit-studio viser faktoiden lydløst i en egen boks.
|
||||
|
||||
### 2.2 Lagring
|
||||
Live-transkripsjonsloggen er flyktig (TTL 30 dager):
|
||||
|
||||
```sql
|
||||
live_transcription_log (
|
||||
id SERIAL,
|
||||
session_id UUID,
|
||||
chunk_timestamp TIMESTAMPTZ,
|
||||
chunk_text TEXT,
|
||||
matched_entities TEXT[],
|
||||
pushed_factoids UUID[],
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
```
|
||||
|
||||
En nattlig jobbkø-jobb sletter rader eldre enn TTL.
|
||||
|
||||
## 3. Møte-modus: AI-Referent
|
||||
Brukes i Møterommet (se `docs/concepts/møterommet.md`). Genererer referat og action points.
|
||||
|
||||
### 3.1 Dataflyt
|
||||
1. **Under møtet:** Whisper transkriberer i chunks. Aha-markører fra deltakerne lagres med tidsstempler.
|
||||
2. **Ved møteslutt:** En `meeting_summarize`-jobb opprettes i jobbkøen (se `docs/infra/jobbkø.md`).
|
||||
3. Rust-worker sender transkripsjonen + Aha-markører til AI Gateway (`sidelinja/rutine`) med instruksjon om å generere:
|
||||
* Referat (strukturert Markdown)
|
||||
* Action points (foreslåtte Kanban-kort)
|
||||
* Identifiserte `#Temaer` og `@Aktører`
|
||||
4. Referatet lagres som melding i relevant Tema-chat. Foreslåtte Kanban-kort vises for godkjenning.
|
||||
5. `graph_edges` opprettes automatisk mellom møtesegmenter og identifiserte Temaer/Aktører.
|
||||
|
||||
### 3.2 Off-the-record
|
||||
Når off-the-record er aktivt, stopper Whisper-strømmen og ingen data lagres. Visuell indikator i alle deltakeres grensesnitt. Transkripsjonen får et gap i tidslinja.
|
||||
|
||||
## 4. Kill Switch
|
||||
Studio-modus har en **kill switch** — en synlig "Stopp AI"-knapp i studio-grensesnittet som umiddelbart:
|
||||
1. Stopper NER-analyse av nye chunks
|
||||
2. Skjuler faktoid-boksen
|
||||
3. Logger tidspunkt og grunn (manuelt felt, valgfritt)
|
||||
|
||||
Nødvendig fordi AI-en kan dytte feil eller irrelevante faktoider under live innspilling. Programlederen må kunne slå den av uten å forlate studio-viewet.
|
||||
|
||||
Kill switch-status (`ai_enabled: bool`) lagres på LiveKit-rommet i SpacetimeDB og synkes til alle klienter i rommet.
|
||||
|
||||
## 5. Instruks for Claude Code
|
||||
* Begge moduser deler samme Whisper-pipeline — ikke dupliser transkripsjonskode.
|
||||
* Studio-modus krever lav latens — hold NER-oppslaget raskt (indekserte spørringer).
|
||||
* Møte-modus er asynkron — prosessér via jobbkøen etter møteslutt.
|
||||
* All AI-kode peker på `http://ai-gateway:4000/v1`, aldri direkte til leverandør.
|
||||
* Kill switch skal alltid være tilgjengelig i studio-modus — default: AI aktivert.
|
||||
* Alt er workspace-scopet.
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Feature Spec: Live AI-Assistent i Studio
|
||||
**Filsti:** `docs/features/live_ai_assistent.md`
|
||||
|
||||
## 1. Konsept
|
||||
En "virtuell co-host" som lytter på innspillingen i sanntid. Når programlederne nevner spesifikke personer eller organisasjoner, slår systemet opp i Kunnskapsgrafen og dytter relevante "Faktoider" til skjermen deres umiddelbart.
|
||||
|
||||
## 2. Arkitektur & Dataflyt
|
||||
Denne funksjonen krever lav forsinkelse og asynkron prosessering.
|
||||
|
||||
1. **Lydkilde (SvelteKit + LiveKit):** SvelteKit-appen bruker `livekit-client`. I tillegg til å sende høykvalitetslyd til de andre deltakerne, rutes en komprimert lydstrøm (via WebSockets eller LiveKit sine egne server-side hooks) til en lokal Rust-tjeneste.
|
||||
2. **Transkripsjon (Rust + Whisper):** Rust-tjenesten mater lyden inn i faster-whisper-server (`Systran/faster-whisper-small`) i chunks på ~5 sekunder. `small` er valgt for latency — den prosesserer ~5x raskere enn sanntid, noe som gir <1s forsinkelse per chunk. Ingen `initial_prompt` — hastighet prioriteres over navnenøyaktighet.
|
||||
3. **Entity Extraction & Oppslag (Rust + PostgreSQL):** Rust-tjenesten analyserer tekststrømmen for egennavn (Named Entity Recognition). Den gjør et lynraskt asynkront oppslag i PostgreSQL: `SELECT * FROM factoids JOIN actors... WHERE actor.name = $1`.
|
||||
4. **Sanntids-Push (SpacetimeDB):** Hvis et treff finnes, dytter Rust-tjenesten faktoiden inn i SpacetimeDB som et event: `LiveFactoidEvent`.
|
||||
5. **Visning (SvelteKit):** Studio-grensesnittet lytter på SpacetimeDB. Når `LiveFactoidEvent` inntreffer, popper faktoiden lydløst opp i en egen boks på skjermen.
|
||||
|
||||
### 2.1 Lagringsstrategi
|
||||
Live-transkripsjonen er **flyktig arbeidsdata** — ikke en del av det permanente datasettet. Den lagres i PostgreSQL som en feilsøkingslogg med TTL (standard 30 dager, konfigurerbart):
|
||||
|
||||
```sql
|
||||
live_transcription_log (
|
||||
id SERIAL,
|
||||
session_id UUID, -- knytter chunks til en innspillingssesjon
|
||||
chunk_timestamp TIMESTAMPTZ,
|
||||
chunk_text TEXT,
|
||||
matched_entities TEXT[], -- hvilke entiteter NER fant
|
||||
pushed_factoids UUID[], -- hvilke faktoider ble pushet til frontend
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
```
|
||||
|
||||
En nattlig jobbkø-jobb sletter rader eldre enn TTL. Dette gir mulighet til å feilsøke kjeden chunk → entity match → factoid push ("hvorfor dukket den faktoiden opp?") uten å akkumulere data over tid.
|
||||
|
||||
Den endelige, kvalitetssikrede transkripsjonen av hele episoden lages etterpå via `medium` + `initial_prompt` gjennom Podcastfabrikkens pipeline (se `podcastfabrikken.md`).
|
||||
|
||||
## 3. Utviklingsfaser (For Claude Code)
|
||||
* **Fase 1:** Ikke bygg live-lyd enda. Bygg funksjonaliteten der grensesnittet lytter på SpacetimeDB, og lag et dummy-script i Rust som dytter test-faktoider inn i SpacetimeDB for å verifisere UI-et.
|
||||
* **Fase 2:** Koble Whisper til et offline lydopptak og kjør NER/oppslag mot PostgreSQL.
|
||||
* **Fase 3:** Koble sammen LiveKit-strømmen og Whisper.
|
||||
40
docs/features/live_transkripsjon.md
Normal file
40
docs/features/live_transkripsjon.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Feature: Live Transkripsjon (Whisper-pipeline)
|
||||
**Filsti:** `docs/features/live_transkripsjon.md`
|
||||
|
||||
## 1. Konsept
|
||||
Den felles Whisper-pipelinen som brukes av flere konsepter for å transkribere lyd. Abstraherer bort konfigurasjonsforskjeller (modellvalg, latenskrav) bak et felles grensesnitt.
|
||||
|
||||
## 2. Moduser
|
||||
|
||||
| Kontekst | Modell | Latenskrav | initial_prompt | Output |
|
||||
|---|---|---|---|---|
|
||||
| **Studioet** (live) | `small` | <1s per chunk | Nei (hastighet prioritert) | Flyktig tekst → NER-pipeline |
|
||||
| **Møterommet** (live) | `small` | <1s per chunk | Nei | Flyktig tekst → AI-referent |
|
||||
| **Podcastfabrikken** (batch) | `medium` + prompt | Ingen (asynkront) | Ja (navneliste) | SRT → Git → PG |
|
||||
|
||||
## 3. Teknisk arkitektur
|
||||
1. **Lydkilde:** LiveKit server-side hooks (live) eller filopplasting (batch).
|
||||
2. **Whisper-server:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API). Endepunkt: `POST /v1/audio/transcriptions`.
|
||||
3. **Chunking (live):** Rust-tjeneste mater lyd i ~5-sekunders chunks. `small`-modellen prosesserer ~5x raskere enn sanntid, noe som gir <1s forsinkelse per chunk.
|
||||
4. **Output:** SRT (batch) eller ren tekst (live).
|
||||
|
||||
## 4. Whisper-konfigurasjon
|
||||
* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk.
|
||||
* **`large-v3`** KREVER `vad_filter=true` — uten hallusinerer modellen repeterende tekst.
|
||||
* **Benchmark og modellvalg:** Se `docs/concepts/podcastfabrikken.md` seksjon 4 for ytelsestall og anbefalinger.
|
||||
|
||||
### 4.1 initial_prompt (navneliste)
|
||||
Brukes kun i batch-modus (Podcastfabrikken). Prompten bygges automatisk fra workspace-config (`workspaces.settings.whisper_prompt`) + aktører i workspace-ets kunnskapsgraf.
|
||||
|
||||
Effekten er tydelig:
|
||||
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
|
||||
* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig)
|
||||
|
||||
## 5. Lagring
|
||||
* **Live-modus:** Transkripsjonen er flyktig (kategori 4, TTL 30 dager). Lagres i `live_transcription_log` for feilsøking.
|
||||
* **Batch-modus:** SRT committes til Git (Forgejo, ett repo per workspace). Avledede formater (ren tekst, segmenter, søkeindeks) i PostgreSQL.
|
||||
|
||||
## 6. Instruks for Claude Code
|
||||
* Live transkripsjon blokkerer ALDRI web-requests — prosesseres i Rust-worker eller separat tjeneste.
|
||||
* Batch-transkripsjon kjøres som `whisper_transcribe`-jobb i jobbkøen (se `docs/infra/jobbkø.md`).
|
||||
* Workspace-spesifikk config (prompts) hentes fra `workspaces.settings`.
|
||||
137
docs/features/lydmeldinger.md
Normal file
137
docs/features/lydmeldinger.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Feature: Lydmeldinger & Diktering
|
||||
**Filsti:** `docs/features/lydmeldinger.md`
|
||||
|
||||
## 1. Konsept
|
||||
En mobil-first talefeature med tre moduser som dekker spekteret fra kjappe chatmeldinger til langt feltopptak. Grunnprinsippet er det samme: **du snakker inn i telefonen, og systemet gjør noe nyttig med det**. Forskjellen er hva som er output.
|
||||
|
||||
| Modus | Typisk lengde | Output | Master-format | Lagring |
|
||||
|---|---|---|---|---|
|
||||
| **Voice-to-text** | <30 sek | Rå transkripsjon i meldingsfeltet | Tekst | Ingen (lyden kastes) |
|
||||
| **Lydmelding** | <10 min | Lydfil + transkripsjon for søk | Lyd | `media_file` + node i grafen |
|
||||
| **Diktering** | 1–30 min | AI-ryddet tekst (strukturert notat) | Tekst | Node i grafen |
|
||||
|
||||
Voice-to-text er en del av chat-featuren (se `docs/features/chat.md`, seksjon 8). Denne specen dekker de to tyngre modusene.
|
||||
|
||||
## 2. Lydmelding-modus
|
||||
|
||||
### 2.1 Brukerflyt
|
||||
1. Bruker åpner lydmelding-modus (via FAB-knapp, hurtigtast eller fra en channel).
|
||||
2. Trykker record. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus).
|
||||
3. Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert".
|
||||
4. Ved stopp lastes lydfilen opp til SvelteKit (streaming for store filer).
|
||||
5. Filen lagres som `media_file` i workspace-mappen (`/srv/sidelinja/media/{workspace_slug}/voice/`).
|
||||
6. En `whisper_transcribe`-jobb opprettes i jobbkøen for å gjøre opptaket søkbart.
|
||||
7. Transkripsjonen lagres som metadata på noden — lydfilen forblir master.
|
||||
|
||||
### 2.2 Datamodell
|
||||
Lydmeldingen er en node i Kunnskapsgrafen (`node_type = 'melding'`, `message_type = 'voice_memo'`):
|
||||
|
||||
```
|
||||
nodes (workspace_id, node_type = 'melding')
|
||||
→ messages (channel_id, message_type = 'voice_memo', body = transkripsjon, metadata = { duration, transcription_status })
|
||||
→ message_attachments → media_files (lydfilen)
|
||||
```
|
||||
|
||||
Lydmeldinger kan:
|
||||
- Sendes i en channel (som vedlegg til en melding)
|
||||
- Leve fristående i en personlig "Innboks"-channel
|
||||
- Knyttes til Temaer/Aktører via `graph_edges` (manuelt eller AI-foreslått)
|
||||
|
||||
### 2.3 Triagering
|
||||
Redaksjonen trenger en flate for å gå gjennom nye lydmeldinger:
|
||||
- Lytt, tagg med Temaer/Aktører
|
||||
- Marker for bruk i episode (kobles til Kanban/Kjøreplan)
|
||||
- Forkast / arkiver
|
||||
- Denne flaten er en filtrert visning av "Innboks"-channelen, ikke et eget system
|
||||
|
||||
### 2.4 Bruk i podcast
|
||||
Lydmeldinger markert for bruk kan trekkes inn i Podcastfabrikkens pipeline. Lydfilen er allerede i media-mappen — den kan klippes inn i en episode direkte.
|
||||
|
||||
## 3. Dikteringsmodus
|
||||
|
||||
### 3.1 Brukerflyt
|
||||
1. Bruker åpner dikteringsmodus (mobil-first, men fungerer på desktop).
|
||||
2. Snakker fritt — kan være flere minutter. Visuell indikator viser at opptak pågår.
|
||||
3. Ved stopp opprettes to jobber i jobbkøen (sekvensielt):
|
||||
- `whisper_transcribe` — rå transkripsjon (`medium` + `initial_prompt` for kvalitet)
|
||||
- `dictation_cleanup` — AI rydder i teksten
|
||||
4. Brukeren ser resultatet: rå transkripsjon og AI-ryddet versjon side om side.
|
||||
5. Kan redigere den ryddede versjonen før lagring.
|
||||
6. Lagres som et notat (node i grafen). Lydfilen slettes etter vellykket transkripsjon.
|
||||
|
||||
### 3.2 AI-opprydding (`dictation_cleanup`)
|
||||
AI Gateway (`sidelinja/rutine`) prosesserer transkripsjonen med instruksjon om å:
|
||||
- Fjerne fyllord, gjentakelser og ufullstendige setninger
|
||||
- Strukturere i avsnitt med overskrifter der det gir mening
|
||||
- Bevare talerens mening og tone — ikke omskrive til "AI-språk"
|
||||
- Foreslå `#Tema`- og `@Aktør`-tags basert på innholdet
|
||||
|
||||
### 3.3 Datamodell
|
||||
Dikterte notater er meldinger i en channel:
|
||||
|
||||
```
|
||||
nodes (workspace_id, node_type = 'melding')
|
||||
→ messages (channel_id, message_type = 'text', body = ryddet tekst, metadata = { raw_transcript, source: 'dictation' })
|
||||
```
|
||||
|
||||
Metadata bevarer rå-transkripsjonen slik at brukeren alltid kan se hva som faktisk ble sagt.
|
||||
|
||||
### 3.4 Målkanal
|
||||
Brukeren velger hvor notatet havner:
|
||||
- **Personlig notat-channel** — default, kun synlig for brukeren selv
|
||||
- **Tema-channel** — delt med redaksjonen som et innspill
|
||||
- **Direkte til en annen bruker** — som en asynkron "talemelding i tekstform"
|
||||
|
||||
## 4. Felles infrastruktur
|
||||
|
||||
### 4.1 Opptak (klient)
|
||||
Begge moduser bruker samme opptakskomponent i SvelteKit:
|
||||
- `MediaRecorder` API med WebM/Opus
|
||||
- Visuell feedback (lydnivå-indikator, varighet)
|
||||
- Modus-velger: Lydmelding / Diktering
|
||||
- Kontekst-velger: Tema, Aktør, eller Usortert
|
||||
|
||||
### 4.2 Opplasting
|
||||
SvelteKit håndterer filopplasting strømmende. For korte klipp (<2 min) kan opplasting starte umiddelbart etter stopp. For lengre opptak bør det vises en fremdriftsindikator.
|
||||
|
||||
### 4.3 Whisper
|
||||
Begge moduser bruker live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`) i batch-modus via jobbkøen. Modellvalg:
|
||||
- Lydmelding: `small` (rask, transkripsjonen er sekundær — lyden er master)
|
||||
- Diktering: `medium` + `initial_prompt` (kvalitet prioritert — teksten er master)
|
||||
|
||||
### 4.4 Personlig "Innboks"-channel
|
||||
Ved workspace-opprettelse opprettes en privat channel per bruker for usorterte lydmeldinger og notater. Config:
|
||||
|
||||
```json
|
||||
{
|
||||
"threads": false,
|
||||
"mentions": false,
|
||||
"attachments": true,
|
||||
"research_clips": false,
|
||||
"ttl_days": null
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Dataklassifisering (ref. ARCHITECTURE.md 2.2)
|
||||
|
||||
| Data | Kategori | Detaljer |
|
||||
|---|---|---|
|
||||
| Lydfiler (voice memos) | Kritisk (backup) | Unikt råmateriale — lyden *er* innholdet, kan brukes direkte i podcasten |
|
||||
| Diktert tekst (ryddet) | Kritisk (PG) | Brukerens innhold |
|
||||
| Rå transkripsjon | Avledet | Kan regenereres fra lydfil |
|
||||
| Lydfil fra diktering | Flyktig (TTL) | Lyden er kun et *transportformat* for teksten — slettes etter at brukeren har godkjent den ryddede teksten. Spart diskplass, og unngår å lagre halvformulerte tanker som lyd. |
|
||||
|
||||
## 6. Jobbtyper
|
||||
|
||||
| Jobbtype | Modellalias | Beskrivelse |
|
||||
|---|---|---|
|
||||
| `whisper_transcribe` | — | Eksisterende jobbtype, gjenbrukes |
|
||||
| `dictation_cleanup` | `sidelinja/rutine` | AI-opprydding av transkripsjon |
|
||||
|
||||
## 7. Instruks for Claude Code
|
||||
* Opptakskomponenten skal være en gjenbrukbar Svelte-komponent med modus-prop (`voice_memo` / `dictation`).
|
||||
* Lydfiler lagres i `media/{workspace_slug}/voice/` — aldri i databasen.
|
||||
* Diktering: slett lydfilen først *etter* at brukeren har godkjent den ryddede teksten.
|
||||
* `voice_memo` er en ny `message_type` — utvid enum i migrasjonen.
|
||||
* Personlig innboks-channel opprettes automatisk ved workspace-medlemskap (som del av `workspace_members`-inserten).
|
||||
* Alt er workspace-scopet.
|
||||
|
|
@ -7,13 +7,24 @@ IAB-kompatibel lytterstatistikk bygget fra bunnen av. Vi fanger all rådata via
|
|||
## 2. Arkitektur & Dataflyt
|
||||
1. **Rådata (Caddy):** Caddy konfigureres til å skrive access-logs for stien `/media/podcast/*.mp3` til en formatert JSON-fil (f.eks. `/srv/sidelinja/logs/caddy/podcast_access.log`).
|
||||
2. **Logrotate:** Standard Linux logrotate arkiverer loggene nattlig.
|
||||
3. **Rust Batch Processor (Jobbkø):** Statistikkparseren kjøres som en `stats_parse`-jobb i den felles jobbkøen (se `docs/features/jobbkø.md`), med `scheduled_for` satt 1 time frem for periodisk kjøring. Workeren re-enqueuer seg selv ved fullføring.
|
||||
3. **Rust Batch Processor (Jobbkø):** Statistikkparseren kjøres som en `stats_parse`-jobb i den felles jobbkøen (se `docs/infra/jobbkø.md`), med `scheduled_for` satt 1 time frem for periodisk kjøring. Workeren re-enqueuer seg selv ved fullføring.
|
||||
* **Steg A (Filtrering):** Leser JSON-loggen. Fjerner treff fra kjente bots ved å krysjekke `User-Agent` mot OPAWG (Open Podcast Analytics Working Group) sine åpne bot-lister.
|
||||
* **Steg B (Deduplisering):** Slår sammen byte-range forespørsler. Hvis samme IP og User-Agent har lastet ned deler av samme fil innenfor et 24-timers vindu, telles det som KUN én (1) nedlasting.
|
||||
* **Steg C (Geografi/Klient):** Mapper User-Agent til Podcast-klient (Spotify, Apple) basert på OPAWG-regler.
|
||||
4. **Lagring (PostgreSQL):** Rust-programmet skriver det aggregerte resultatet inn i PostgreSQL (`episode_stats` tabell med felter for `date`, `episode_id`, `client_name`, `unique_downloads`).
|
||||
4. **Lagring (PostgreSQL):** Rust-programmet skriver det aggregerte resultatet inn i PostgreSQL (`episode_stats` tabell med felter for `workspace_id`, `date`, `episode_id`, `client_name`, `unique_downloads`). Workspace-tilhørighet utledes fra filstien i loggen (`/media/{workspace_slug}/...`).
|
||||
|
||||
## 3. Instruks for Claude Code
|
||||
## 3. Personvern og GDPR
|
||||
|
||||
Caddy access-logger inneholder IP-adresser og User-Agent — dette er personopplysninger under GDPR.
|
||||
|
||||
**Tiltak:**
|
||||
* **IP-anonymisering:** Rust-workeren hasher IP-adresser (SHA-256 med daglig roterende salt) *før* de lagres i `episode_stats`. Rå IP-er lagres aldri i PostgreSQL.
|
||||
* **Loggretensjon:** Caddy-logger roteres og slettes etter 90 dager (kategori 4, flyktig). Etter at statistikk-workeren har prosessert en loggfil, inneholder PG kun aggregerte tall — ingen identifiserbar data.
|
||||
* **Ingen sporing på tvers:** Vi bruker ikke cookies, fingerprinting eller tredjepartstracking. Deduplisering basert på IP + User-Agent i 24-timers vindu er IAB-standard og minimumsdata.
|
||||
* **Dokumentasjon:** Podcastens RSS-feed bør lenke til en personvernerklæring som forklarer at anonymisert nedlastingsstatistikk samles inn.
|
||||
|
||||
## 4. Instruks for Claude Code
|
||||
* Bruk Rust-biblioteket `serde_json` for rask parsing av Caddy-loggene.
|
||||
* Dette programmet må skrives robust med tanke på at filer kan være låst av Caddy. Det bør tåle å avbrytes, og må holde styr på hvilken linje i loggfilen det prosesserte sist (f.eks. via en liten cursor-fil).
|
||||
* Rålogger skal ALDRI lagres i PostgreSQL.
|
||||
* Rålogger skal ALDRI lagres i PostgreSQL.
|
||||
* Statistikk er workspace-scopet. `episode_stats` merkes med `workspace_id`. Admin-visningen filtrerer per workspace.
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# Feature Spec: Produktivitetssuiten
|
||||
**Filsti:** `docs/features/produktivitetssuite.md`
|
||||
|
||||
## 1. Konsept
|
||||
En asynkron og synkron arbeidsflate der Kunnskapsgrafen møter prosjektstyring. **Temaet** er hovedobjektet, ikke episoden.
|
||||
|
||||
## 2. Datastrukturer og Ansvarsfordeling
|
||||
Dette systemet bruker SpacetimeDB tungt for sanntidsopplevelsen.
|
||||
|
||||
* **Tema-bassenget (PostgreSQL + SpacetimeDB):** Alle pågående "Saker". PostgreSQL er kilden til sannhet (langtidslagring), mens SpacetimeDB holder de aktive temaene i minnet slik at chat og oppdateringer skjer uten page-reloads.
|
||||
* **Trådet Chat (SpacetimeDB):** Meldinger sendes via SpacetimeDB. Hver melding tilhører et `Tema`. Meldinger kan ha en `parent_message_id` for å skape tråder.
|
||||
* **Sanntids Autocomplete & Mentions (Mobil-optimalisert):**
|
||||
* Siden SpacetimeDB synkroniserer data til klienten, skal Svelte-grensesnittet ha umiddelbar autocomplete på tekstfeltet.
|
||||
* Trigger-tegn: `/` (for kommandoer som `/oppgave`), `@` (for brukere/redaksjonsmedlemmer), og feks `#` (for Temaer/Aktører fra Kunnskapsgrafen).
|
||||
* Skriver man `#Ha...` filtrerer Svelte-klienten umiddelbart den lokale SpacetimeDB-cachen og viser en klikkbar/tappbar liste (f.eks. "Hans Petter Sjøli", "Høyre"). Ved trykk settes hele navnet inn, og det lenkes automatisk opp i databasen.
|
||||
* **Kanban / Kjøreplan (SpacetimeDB):** Opprettelse av en `Episode` fungerer som en container. Brukerne drar *Temaer* fra Tema-bassenget og inn i en Episodes Kjøreplan (Drag and Drop i SvelteKit). Posisjon/Rekkefølge synkroniseres til alle klienter via SpacetimeDB.
|
||||
* **Kollaborative Show Notes:** Et tekstfelt koblet til et Tema. Enkle "Operational Transformation"-aktige oppdateringer (eller felt-låsing) håndteres i Rust-modulen til SpacetimeDB.
|
||||
* **Live Studio-Markører ("Blooper-knapp"):** En funksjon i Svelte-studioet der brukere kan trykke på en knapp under innspilling. Dette fanger opp gjeldende opptakstid (timer/min/sek) og lagrer det i SpacetimeDB som et "Klippepunkt" koblet til episoden.
|
||||
|
||||
## 3. Instruks for Claude Code
|
||||
* Bruk SvelteKit for Drag-and-Drop grensesnitt. Unngå tunge biblioteker hvis native HTML5 Drag and Drop er tilstrekkelig.
|
||||
* SpacetimeDB skal fungere som "State Manager". Frontend bør ikke ha kompleks lokal state (f.eks. Redux); den skal speile SpacetimeDB sin tilstand.
|
||||
101
docs/features/prompt_lab.md
Normal file
101
docs/features/prompt_lab.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Feature: Prompt-Laboratorium
|
||||
**Filsti:** `docs/features/prompt_lab.md`
|
||||
|
||||
## 1. Konsept
|
||||
Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og godkjenne LLM-prompts mot faktiske data fra eget workspace — før de ruller ut i produksjon. Integrert med AI Gateway (LiteLLM) og Promptfoo-testsettene.
|
||||
|
||||
## 2. Problemet det løser
|
||||
- Modellkvalitet på norsk varierer kraftig mellom leverandører og versjoner.
|
||||
- Leverandører oppdaterer modeller uten varsel — kvaliteten kan degraderes over natten.
|
||||
- Redaksjonen må kunne verifisere at en prompt fungerer *med deres data* (transkripsjoner, artikler, aktørnavn) før den settes i produksjon.
|
||||
- I dag krever prompt-testing kommandolinje og Promptfoo — det bør være tilgjengelig i nettleseren.
|
||||
|
||||
## 3. Brukeropplevelse
|
||||
|
||||
### 3.1 Playground (Ad-hoc testing)
|
||||
1. Bruker velger en **jobbtype** (research_clip, whisper_postprocess, metadata_extract, dictation_cleanup, etc.).
|
||||
2. Systemet laster inn gjeldende system-prompt fra `workspaces.settings`.
|
||||
3. Bruker kan redigere prompten i et tekstfelt.
|
||||
4. Velger **testdata**: enten paste inn tekst manuelt, eller velg fra faktiske transkripsjoner/artikler i workspace-et.
|
||||
5. Velger **modeller** å teste mot (f.eks. `sidelinja/rutine` + `sidelinja/resonering`).
|
||||
6. Kjører testen — ser resultatene side-om-side.
|
||||
7. Kan iterere: endre prompten, kjør igjen, sammenlign.
|
||||
|
||||
### 3.2 Batch-evaluering (Promptfoo-integrasjon)
|
||||
1. Bruker velger et lagret **testsett** (fra `tests/prompts/` i Git).
|
||||
2. Kjører testsettene mot valgte modeller via AI Gateway.
|
||||
3. Ser en matrise: testcase × modell × resultat, med pass/fail-markering.
|
||||
4. Kan sammenligne mot tidligere kjøringer for å oppdage regresjoner.
|
||||
|
||||
### 3.3 Deploy
|
||||
Når en prompt er verifisert:
|
||||
1. Bruker klikker "Deploy til workspace".
|
||||
2. Prompten skrives til `workspaces.settings.llm_prompts[jobbtype]`.
|
||||
3. Alle fremtidige jobber av den typen bruker den nye prompten.
|
||||
4. Tidligere prompt lagres i en `prompt_history`-logg for rollback.
|
||||
|
||||
## 4. Teknisk arkitektur
|
||||
|
||||
### 4.1 Datamodell
|
||||
|
||||
```sql
|
||||
prompt_test_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
job_type TEXT NOT NULL,
|
||||
system_prompt TEXT NOT NULL,
|
||||
model_alias TEXT NOT NULL,
|
||||
input_text TEXT NOT NULL,
|
||||
output_text TEXT,
|
||||
tokens_used INTEGER,
|
||||
latency_ms INTEGER,
|
||||
created_by TEXT NOT NULL REFERENCES users(authentik_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_prompt_runs_workspace ON prompt_test_runs(workspace_id, job_type, created_at DESC);
|
||||
```
|
||||
|
||||
`prompt_test_runs` er **ikke** en node i grafen — det er en intern verktøytabell for utviklere/redaktører.
|
||||
|
||||
### 4.2 Prompt-historikk
|
||||
|
||||
```sql
|
||||
prompt_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
job_type TEXT NOT NULL,
|
||||
system_prompt TEXT NOT NULL,
|
||||
deployed_by TEXT NOT NULL REFERENCES users(authentik_id),
|
||||
deployed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
Muliggjør rollback: "forrige prompt for research_clip fungerte bedre, rull tilbake."
|
||||
|
||||
### 4.3 Kjøring
|
||||
- **Ad-hoc tester:** SvelteKit server-side sender request direkte til AI Gateway (`http://ai-gateway:4000/v1`) med brukerens prompt og testdata. Resultatet returneres synkront.
|
||||
- **Batch-evaluering:** Oppretter en `prompt_eval`-jobb i jobbkøen. Rust-worker kjører testsettene mot AI Gateway og lagrer resultatene.
|
||||
|
||||
## 5. Dataklassifisering
|
||||
|
||||
| Data | Kategori | Detaljer |
|
||||
|---|---|---|
|
||||
| Test-kjøringer | Flyktig (TTL 90 dager) | For analyse, ryddes automatisk |
|
||||
| Prompt-historikk | Kritisk (PG) | Muliggjør rollback |
|
||||
| Testsett (Promptfoo) | Gjenskapbar (Git) | `tests/prompts/` — versjonskontrollert |
|
||||
|
||||
## 6. Jobbtyper
|
||||
|
||||
| Jobbtype | Modellalias | Beskrivelse |
|
||||
|---|---|---|
|
||||
| `prompt_eval` | (varierer) | Batch-evaluering av testsett mot valgte modeller |
|
||||
|
||||
## 7. Instruks for Claude Code
|
||||
* Prompt Lab er et admin-verktøy — krev `admin` eller `owner`-rolle i workspace.
|
||||
* Ad-hoc tester kjøres synkront (SvelteKit → AI Gateway → respons). Ikke bruk jobbkø for enkelt-tester.
|
||||
* Batch-evaluering kjøres via jobbkø for å unngå timeouts.
|
||||
* Vis alltid token-bruk og latens — dette er et kostnadsbevisst verktøy.
|
||||
* Testdata fra workspace-et (transkripsjoner, artikler) lastes via SvelteKit server-side fra PG. Gjesten-UX vises aldri her.
|
||||
* `prompt_test_runs` og `prompt_history` er workspace-scopet men trenger ikke RLS — de er kun tilgjengelige for admins via applikasjonslogikk.
|
||||
* Alt er workspace-scopet.
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
# Feature Spec: PostgreSQL ↔ SpacetimeDB Synkronisering
|
||||
**Filsti:** `docs/features/synkronisering.md`
|
||||
|
||||
## 1. Konsept
|
||||
SpacetimeDB gir sanntidsopplevelsen, PostgreSQL er langtidsminnet. Denne spec-en definerer hvordan data flyter mellom dem, hvem som eier sannheten, og hva som skjer ved feil.
|
||||
|
||||
## 2. Strategi: Event-drevet med kort forsinkelse
|
||||
SpacetimeDB-modulene (Rust) produserer persisterings-events ved dataendringer. En Rust-worker konsumerer disse og skriver til PostgreSQL, batched med ~5 sekunders vindu.
|
||||
|
||||
**Akseptabelt datatap:** Maks 5 sekunder ved hard krasj av SpacetimeDB. Dette er akseptabelt for chat, kanban og show notes.
|
||||
|
||||
## 3. Dataflyt
|
||||
|
||||
```
|
||||
┌──────────────┐ events ┌──────────────┐ batch write ┌──────────────┐
|
||||
│ SpacetimeDB │ ──────────────► │ Rust Worker │ ────────────────► │ PostgreSQL │
|
||||
│ (sanntid) │ │ (sync_to_pg) │ │ (persistent)│
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
|
||||
┌──────────────┐ oppvarming (oppstart / reconnect) ┌──────────────┐
|
||||
│ PostgreSQL │ ──────────────────────────────────────► │ SpacetimeDB │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## 4. Eierskapsmodell
|
||||
|
||||
| Data | Autoritativ kilde | Synkretning | Merknad |
|
||||
|---|---|---|---|
|
||||
| Chatmeldinger | SpacetimeDB | → PG (event, batched) | |
|
||||
| Kanban-posisjon | SpacetimeDB | → PG (event) | |
|
||||
| Show notes | SpacetimeDB | → PG (event) | |
|
||||
| Live studio-markører | SpacetimeDB | → PG (event) | |
|
||||
| Kunnskapsgraf | PostgreSQL | → SpacetimeDB (oppvarming) | Read-only i SpacetimeDB |
|
||||
| Episodemetadata | PostgreSQL | Ingen synk | |
|
||||
| Brukerkontoer | PostgreSQL (Authentik) | Ingen synk | |
|
||||
| Statistikk | PostgreSQL | Ingen synk | |
|
||||
| Valgomat | TBD | TBD | Konseptet må modnes. Mulig PG-autoritativ med SpacetimeDB som serveringslag |
|
||||
|
||||
## 5. Mekanisme
|
||||
|
||||
### 5.1 SpacetimeDB → PostgreSQL (persistering)
|
||||
- SpacetimeDB-modulene kaller en intern `emit_sync_event()`-funksjon ved relevante dataendringer
|
||||
- Events bufres i en SpacetimeDB-tabell (`sync_outbox`) med tidsstempel og payload
|
||||
- Rust-workeren poller `sync_outbox` hvert ~5 sekund, leser alle usynkede events, skriver til PostgreSQL i én transaksjon, og markerer dem som synket
|
||||
- Ved PG-nedetid: events akkumuleres i `sync_outbox`. Workeren prøver igjen ved neste poll. Ingen data tapes så lenge SpacetimeDB kjører
|
||||
|
||||
### 5.2 PostgreSQL → SpacetimeDB (oppvarming)
|
||||
- Ved oppstart (eller reconnect) av SpacetimeDB laster Rust-workeren aktive data fra PG:
|
||||
- Aktive temaer med siste N chatmeldinger
|
||||
- Kanban-state for pågående episoder
|
||||
- Aktør/Tema-navn for autocomplete (read-only cache)
|
||||
- Dette er en enveis-last, ikke kontinuerlig synk. Kunnskapsgrafen oppdateres i SpacetimeDB kun ved oppstart eller eksplisitt refresh
|
||||
|
||||
## 6. Feilhåndtering
|
||||
- **SpacetimeDB krasjer:** Data siden siste synk (~5 sek) tapes. Ved restart oppvarmes fra PG
|
||||
- **PostgreSQL nede:** Sanntidsfunksjoner fortsetter å fungere. `sync_outbox` vokser. Workeren logger advarsler. Ved PG-recovery synkes backloggen automatisk
|
||||
- **Rust-worker krasjer:** `sync_outbox` akkumuleres. Ved restart plukker workeren opp der den slapp (usynkede events har ingen markering)
|
||||
|
||||
## 7. Instruks for Claude Code
|
||||
- `sync_outbox`-tabellen i SpacetimeDB bør ha et `synced`-flagg og `created_at`-tidsstempel
|
||||
- Workeren skal bruke jobbkø-infrastrukturen (se `docs/features/jobbkø.md`) for sin egen helse/observabilitet, men selve pollingen er en egen loop — ikke en vanlig jobb i køen
|
||||
- Hold sync-payloaden enkel: `{ "table": "chat_messages", "action": "insert", "data": {...} }` — workeren mapper dette til riktig PG-tabell
|
||||
- Ikke optimaliser for store datamengder ennå. Enkle INSERTs er bra nok til volumet stabiliserer seg
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# Feature Spec: Valgomat
|
||||
**Filsti:** `docs/features/valgomat.md`
|
||||
|
||||
## 1. Konsept
|
||||
En publikumsrettet web-applikasjon for å hjelpe velgere med å finne partimatch. Må være lynrask, tåle høy trafikk (Spike-trafikk etter publisering av episode), og føles interaktiv ("gamified").
|
||||
|
||||
## 2. Arkitektur
|
||||
For å gi en "instant" følelse, bypasses tradisjonell database-arkitektur under selve gjennomføringen.
|
||||
|
||||
* **Logikk og Vekting (SpacetimeDB / Rust):** SpacetimeDB egner seg perfekt for Valgomaten. Databasen holder reglene (spørsmål, vekting per parti) i minnet. Rust-koden inne i SpacetimeDB (modulen) beregner resultatet umiddelbart når en bruker sender inn et svar.
|
||||
* **Frontend (SvelteKit):** En ren, responsiv SvelteKit-klient (PWA) som kobler seg til SpacetimeDB via WebSockets. Svar klikkes, animasjoner vises, og neste spørsmål lastes uten noe nettverks-forsinkelse (fordi tilkoblingen holdes åpen).
|
||||
* **Statistikk (PostgreSQL):** Aggregerte resultater ("70% av de under 30 i Oslo fikk SV") lagres periodisk over til PostgreSQL for langsiktig analyse og grafer til podcastepisodene.
|
||||
|
||||
## 3. Instruks for Claude Code
|
||||
* Implementer vektingen (algoritmen) som en Rust-funksjon (Reducer) inne i SpacetimeDB-modulen.
|
||||
* Sørg for at brukere ikke må logge inn for å ta valgomaten (Anonym tilgang støttes i SpacetimeDB).
|
||||
20
docs/features/visuell_graf.md
Normal file
20
docs/features/visuell_graf.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Feature: Visuell Kunnskapsgraf (Graph View)
|
||||
**Filsti:** `docs/features/visuell_graf.md`
|
||||
|
||||
## 1. Konsept
|
||||
En interaktiv graf-visning i SvelteKit som gjør Kunnskapsgrafen visuelt navigerbar og redigerbar. Brukes i Kunnskapsgrafen-konseptet (se `docs/concepts/kunnskapsgrafen.md`).
|
||||
|
||||
## 2. Funksjonalitet
|
||||
* **Visualisering:** Viser noder (Temaer, Aktører, Faktoider, Segmenter) og relasjoner som et interaktivt nettverkskart. Bygges med D3.js eller Vis.js i Svelte-frontenden.
|
||||
* **Visuell redigering:** Redaksjonen kan dra streker mellom noder for å opprette nye relasjoner i `graph_edges`-tabellen. Velg relasjonstype (`PART_OF`, `CONTRADICTS`, `MENTIONS`, etc.) via en kontekstmeny.
|
||||
* **Hierarkier uten mapper:** `PART_OF`-relasjoner muliggjør fleksible prosjekthierarkier uten stive mappestrukturer. Et Tema kan være `PART_OF` et annet Tema, en Aktør kan være `PART_OF` en organisasjon, osv.
|
||||
* **Filtrering:** Brukeren kan filtrere grafen etter nodetype, relasjonstype, tidsperiode eller fritekst.
|
||||
|
||||
## 3. Datakilde
|
||||
* **SvelteKit server-side:** Henter grafdata via Recursive CTE-spørringer mot PostgreSQL og returnerer `{ "nodes": [...], "edges": [...] }` til klienten.
|
||||
* **SpacetimeDB:** Brukes ikke for graf-visualisering — dette er historiske data som lever i PostgreSQL.
|
||||
|
||||
## 4. Instruks for Claude Code
|
||||
* Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (D3.js, Vis.js).
|
||||
* Begrens traversering til 2-3 ledd ut fra startnode for å unngå eksplosjoner i store grafer.
|
||||
* Workspace-scopet: alle noder filtreres via `nodes.workspace_id`.
|
||||
34
docs/features/whiteboard.md
Normal file
34
docs/features/whiteboard.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Feature Spec: Whiteboard (Frihåndstavle)
|
||||
**Filsti:** `docs/features/whiteboard.md`
|
||||
|
||||
## 1. Konsept
|
||||
Et delt, sanntids tegnebrett for frihåndsskisser, diagrammer og visuell brainstorming. Whiteboardet er en selvstendig komponent som kan brukes i flere kontekster: i møterom, i chat-tråder knyttet til et Tema, eller alene som personlig skisseblokk.
|
||||
|
||||
## 2. Brukskontekster
|
||||
|
||||
| Kontekst | Deltakere | Lagring |
|
||||
|---|---|---|
|
||||
| **Møterom** | Alle møtedeltakere i sanntid | Eksporteres som bilde ved møteslutt, knyttes til møtereferatet |
|
||||
| **Tema-chat** | Alle med tilgang til temaet | Lagres som vedlegg til en melding, synlig i chat-historikken |
|
||||
| **Personlig** | Kun brukeren selv | Privat skisse, kan deles til et Tema senere |
|
||||
|
||||
## 3. Sanntidssynkronisering
|
||||
* **SpacetimeDB** synkroniserer strøk (penseltype, farge, koordinater) mellom alle deltakere i sanntid.
|
||||
* Hvert whiteboard har en unik ID og tilhører en workspace.
|
||||
* Tilgangskontroll følger konteksten: møterom-deltakere, tema-medlemmer, eller kun eieren for personlige tavler.
|
||||
|
||||
## 4. Funksjonalitet
|
||||
* **Tegneverktøy:** Frihåndstegning, rette linjer, rektangler, ellipser, tekst, piler.
|
||||
* **Interaksjon:** Fargevelger, strektykkelse, viskelær, angre/gjør om.
|
||||
* **Implementering:** HTML Canvas eller SVG i SvelteKit. Vurder et lett bibliotek (f.eks. tldraw, Excalidraw) hvis frihåndskvaliteten krever det — men foretrekk egenutviklet for å holde avhengigheter nede.
|
||||
|
||||
## 5. Lagring og Eksport
|
||||
* **Sanntidsdata (SpacetimeDB):** Strøk-historikk holdes i minnet så lenge tavlen er aktiv.
|
||||
* **Eksport (PostgreSQL + filsystem):** Når tavlen "lukkes" eller deles, rendres den til PNG eller SVG og lagres som en `media_file` i workspace-mappen. Referansen knyttes til konteksten (melding, møte) via `message_attachments` eller tilsvarende.
|
||||
* **Dataklassifisering:** Strøk-data i SpacetimeDB er flyktig (kategori 4). Eksporterte bilder er avledet (kategori 3) — kan gjenskapes fra strøk-data så lenge tavlen er aktiv, men etter lukking er bildet den permanente kopien.
|
||||
|
||||
## 6. Instruks for Claude Code
|
||||
* Whiteboard-komponenten skal være en gjenbrukbar Svelte-komponent som kan mountes i møterom, chat og som frittstående side.
|
||||
* SpacetimeDB-tabellen for strøk bør være enkel: `whiteboard_id`, `stroke_data` (JSON), `user_id`, `timestamp`.
|
||||
* Ikke bygg et fullverdig tegneprogram — start med frihåndstegning, viskelær og farger. Utvid ved behov.
|
||||
* Alle whiteboards er workspace-scopet.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# Feature Spec: AI Gateway (LiteLLM)
|
||||
**Filsti:** `docs/features/ai_gateway.md`
|
||||
# Infrastruktur: AI Gateway (LiteLLM)
|
||||
**Filsti:** `docs/infra/ai_gateway.md`
|
||||
|
||||
## 1. Konsept
|
||||
Sidelinja bruker en sentralisert AI Gateway (LiteLLM) som eneste kontaktpunkt for alle AI-kall i systemet. All kode — Rust-workers, SvelteKit server-side — snakker med `http://ai-gateway:4000/v1`. Aldri direkte til leverandør-APIer.
|
||||
|
|
@ -131,7 +131,7 @@ Dette lar oss svare på:
|
|||
### 5.3 Når vi kjører tester
|
||||
* **Ved ny prompt:** Før den tas i bruk i produksjon
|
||||
* **Ved modellbytte:** Før en leverandør/modell settes som primær for en jobbtype
|
||||
* **Periodisk:** Månedlig regresjonssjekk — leverandører oppdaterer modeller uten varsel
|
||||
* **Periodisk (CI):** Månedlig cron-jobb i Forgejo Actions kjører `promptfoo eval` mot alle testsett. Resultater postes som issue ved regresjoner. Leverandører oppdaterer modeller uten varsel — automatisk regresjonssjekk fanger dette opp.
|
||||
* **Ved kvalitetsklager:** Når redaksjonen rapporterer dårlig output
|
||||
|
||||
### 5.4 Lagring av testsett
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# Feature Spec: API-grensesnitt og Tjenesteansvar
|
||||
**Filsti:** `docs/features/api_grensesnitt.md`
|
||||
# Infrastruktur: API-grensesnitt og Tjenesteansvar
|
||||
**Filsti:** `docs/infra/api_grensesnitt.md`
|
||||
|
||||
## 1. Konsept
|
||||
Definerer hvordan SvelteKit-frontenden kommuniserer med backend-tjenestene. Prinsippet er: **SvelteKit er web-serveren, Rust er workeren.** Ingen separat Rust HTTP API.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# Feature Spec: Jobbkø (PostgreSQL-basert)
|
||||
**Filsti:** `docs/features/jobbkø.md`
|
||||
# Infrastruktur: Jobbkø (PostgreSQL-basert)
|
||||
**Filsti:** `docs/infra/jobbkø.md`
|
||||
|
||||
## 1. Konsept
|
||||
Et felles, sentralisert køsystem for alle asynkrone bakgrunnsjobber i Sidelinja. Bygget som en enkel tabell i PostgreSQL med Rust-workers som konsumerer jobber. Ingen ekstern message broker — PostgreSQL er køen.
|
||||
|
|
@ -17,18 +17,19 @@ Et felles, sentralisert køsystem for alle asynkrone bakgrunnsjobber i Sidelinja
|
|||
CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'retry');
|
||||
|
||||
CREATE TABLE job_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_type TEXT NOT NULL, -- 'whisper_transcribe', 'openrouter_analyze', 'stats_parse', 'research_clip'
|
||||
payload JSONB NOT NULL, -- Inputdata (filsti, tekst, tema_id, etc.)
|
||||
status job_status NOT NULL DEFAULT 'pending',
|
||||
priority SMALLINT NOT NULL DEFAULT 0, -- Høyere = viktigere
|
||||
result JSONB, -- Resultatet ved fullført jobb
|
||||
error_msg TEXT, -- Feilmelding ved error
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
max_attempts SMALLINT NOT NULL DEFAULT 3,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
job_type TEXT NOT NULL, -- 'whisper_transcribe', 'openrouter_analyze', 'stats_parse', 'research_clip'
|
||||
payload JSONB NOT NULL, -- Inputdata (filsti, tekst, tema_id, etc.)
|
||||
status job_status NOT NULL DEFAULT 'pending',
|
||||
priority SMALLINT NOT NULL DEFAULT 0, -- Høyere = viktigere
|
||||
result JSONB, -- Resultatet ved fullført jobb
|
||||
error_msg TEXT, -- Feilmelding ved error
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
max_attempts SMALLINT NOT NULL DEFAULT 3,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now() -- For utsatte jobber / retry med backoff
|
||||
);
|
||||
|
||||
|
|
@ -74,9 +75,21 @@ CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for AS
|
|||
| `openrouter_analyze` | Podcastfabrikken | Metadata-uttrekk fra transkripsjon |
|
||||
| `research_clip` | AI Research-Klipper | Rens og strukturer innlimt tekst |
|
||||
| `stats_parse` | Podcast-Statistikk | Batch-prosesser Caddy-logger |
|
||||
| `meeting_summarize` | Møterommet | Generer møtereferat og action points fra transkripsjon |
|
||||
| `valgomat_generate_profile` | Valgomat | Generer syntetiske kandidatprofiler fra partiprogrammer |
|
||||
| `valgomat_moderation` | Valgomat | Semantisk deduplisering og nøytralitetsvask av brukerspørsmål |
|
||||
| `dictation_cleanup` | Lydmeldinger | AI-opprydding av diktert transkripsjon til strukturert notat |
|
||||
| `generate_embeddings` | Kunnskaps-Bridge | Generer vector embeddings for noder (pgvector) |
|
||||
| `prompt_eval` | Prompt-Laboratorium | Batch-evaluering av testsett mot valgte modeller |
|
||||
|
||||
## 6. Observabilitet
|
||||
- Jobber med `status = 'error'` skal være synlige i Produktivitetssuiten (enkel admin-visning)
|
||||
## 6. Workspace-isolasjon
|
||||
Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode:
|
||||
* Worker leser `workspace_id` fra jobben og bruker det til å lagre resultater tilbake i riktig silo
|
||||
* Workspace-spesifikk config (AI-prompts, navnelister) hentes fra `workspaces.settings`
|
||||
* Feilede jobber vises kun for brukere i riktig workspace i admin-visningen
|
||||
|
||||
## 7. Observabilitet
|
||||
- Jobber med `status = 'error'` skal være synlige i admin-visningen (SvelteKit `/admin/jobs`)
|
||||
- Valgfritt: SpacetimeDB-event ved statusendring slik at UI kan vise fremdrift i sanntid (f.eks. "Transkriberer... 2/3 forsøk")
|
||||
|
||||
## 7. Instruks for Claude Code
|
||||
|
|
@ -84,4 +97,5 @@ CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for AS
|
|||
- Hver jobbtype får sin egen handler-funksjon, men deler polling-loopen
|
||||
- Unngå å spinne opp mange tråder — én tokio-task per jobbtype er tilstrekkelig
|
||||
- Aldri lagre lydfiler i `payload` — bruk filstier
|
||||
- Opprett alltid jobber med riktig `workspace_id` — hent fra konteksten (innlogget bruker, webhook, etc.)
|
||||
- Ved `stats_parse`: denne erstatter den frittstående cronjobben beskrevet i podcast_statistikk.md — bruk jobbkøen med `scheduled_for` for periodisk kjøring
|
||||
121
docs/infra/synkronisering.md
Normal file
121
docs/infra/synkronisering.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Infrastruktur: PostgreSQL ↔ SpacetimeDB Synkronisering
|
||||
**Filsti:** `docs/infra/synkronisering.md`
|
||||
|
||||
## 1. Konsept
|
||||
SpacetimeDB gir sanntidsopplevelsen, PostgreSQL er langtidsminnet. Denne spec-en definerer hvordan data flyter mellom dem, hvem som eier sannheten, og hva som skjer ved feil.
|
||||
|
||||
### 1.1 Grunnregel: SpacetimeDB er en ren sanntidsbuffer
|
||||
SpacetimeDB skal **aldri** være eneste lagringssted for data med varig verdi. All data som SpacetimeDB holder skal enten:
|
||||
1. Allerede eksistere i PostgreSQL (read-only cache, f.eks. aktør-navn for autocomplete), eller
|
||||
2. Synkes til PostgreSQL innen ~5 sekunder (chat, kanban, markører).
|
||||
|
||||
**Konsekvens for design:** Ethvert SpacetimeDB-modul-skjema skal ha en tilsvarende PostgreSQL-tabell som kan fungere som drop-in erstatning dersom SpacetimeDB fjernes. Frontend-kode som leser fra SpacetimeDB skal kunne peke om til et SvelteKit SSE-endepunkt + REST uten arkitekturendring.
|
||||
|
||||
### 1.2 PostgreSQL-fallback-prinsippet
|
||||
Dersom SpacetimeDB fjernes fra stacken, skal systemet fungere med følgende erstatning:
|
||||
- **Sanntidsoppdateringer:** PostgreSQL `LISTEN/NOTIFY` → SvelteKit Server-Sent Events (SSE)
|
||||
- **Skriving:** Direkte til PostgreSQL via SvelteKit server-side
|
||||
- **Autocomplete/cache:** PostgreSQL-spørringer med cursor-basert paginering
|
||||
|
||||
Denne fallbacken trenger ikke implementeres på forhånd, men SpacetimeDB-moduler skal designes slik at fallbacken forblir triviell.
|
||||
|
||||
## 2. Strategi: Event-drevet med kort forsinkelse
|
||||
SpacetimeDB-modulene (Rust) produserer persisterings-events ved dataendringer. En Rust-worker konsumerer disse og skriver til PostgreSQL, batched med ~5 sekunders vindu.
|
||||
|
||||
**Akseptabelt datatap:** Maks 5 sekunder ved hard krasj av SpacetimeDB. Dette er akseptabelt for chat, kanban og show notes.
|
||||
|
||||
## 3. Dataflyt
|
||||
|
||||
```
|
||||
┌──────────────┐ events ┌──────────────┐ batch write ┌──────────────┐
|
||||
│ SpacetimeDB │ ──────────────► │ Rust Worker │ ────────────────► │ PostgreSQL │
|
||||
│ (sanntid) │ │ (sync_to_pg) │ │ (persistent)│
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
|
||||
┌──────────────┐ oppvarming (oppstart / reconnect) ┌──────────────┐
|
||||
│ PostgreSQL │ ──────────────────────────────────────► │ SpacetimeDB │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## 4. Eierskapsmodell
|
||||
|
||||
| Data | Autoritativ kilde | Synkretning | Merknad |
|
||||
|---|---|---|---|
|
||||
| Chatmeldinger | SpacetimeDB | → PG (event, batched) | |
|
||||
| Kanban-posisjon | SpacetimeDB | → PG (event) | |
|
||||
| Show notes | SpacetimeDB | → PG (event) | |
|
||||
| Live studio-markører | SpacetimeDB | → PG (event) | |
|
||||
| Kunnskapsgraf | PostgreSQL | → SpacetimeDB (oppvarming) | Read-only i SpacetimeDB |
|
||||
| Episodemetadata | PostgreSQL | Ingen synk | |
|
||||
| Brukerkontoer | PostgreSQL (Authentik) | Ingen synk | |
|
||||
| Statistikk | PostgreSQL | Ingen synk | |
|
||||
| Valgomat | TBD | TBD | Konseptet må modnes. Mulig PG-autoritativ med SpacetimeDB som serveringslag |
|
||||
|
||||
## 5. Mekanisme
|
||||
|
||||
### 5.1 SpacetimeDB → PostgreSQL (persistering)
|
||||
- SpacetimeDB-modulene kaller en intern `emit_sync_event()`-funksjon ved relevante dataendringer
|
||||
- Events bufres i en SpacetimeDB-tabell (`sync_outbox`) med tidsstempel og payload
|
||||
- Rust-workeren poller `sync_outbox` hvert ~5 sekund, leser alle usynkede events, skriver til PostgreSQL i én transaksjon, og markerer dem som synket
|
||||
- Ved PG-nedetid: events akkumuleres i `sync_outbox`. Workeren prøver igjen ved neste poll. Ingen data tapes så lenge SpacetimeDB kjører
|
||||
|
||||
### 5.2 PostgreSQL → SpacetimeDB (oppvarming)
|
||||
- Ved oppstart (eller reconnect) av SpacetimeDB laster Rust-workeren aktive data fra PG:
|
||||
- Aktive temaer med siste N chatmeldinger
|
||||
- Kanban-state for pågående episoder
|
||||
- Aktør/Tema-navn for autocomplete (read-only cache)
|
||||
- Dette er en enveis-last, ikke kontinuerlig synk. Kunnskapsgrafen oppdateres i SpacetimeDB kun ved oppstart eller eksplisitt refresh
|
||||
|
||||
## 6. Feilhåndtering
|
||||
- **SpacetimeDB krasjer:** Data siden siste synk (~5 sek) tapes. Ved restart oppvarmes fra PG
|
||||
- **PostgreSQL nede:** Sanntidsfunksjoner fortsetter å fungere. `sync_outbox` vokser. Workeren logger advarsler. Ved PG-recovery synkes backloggen automatisk
|
||||
- **Rust-worker krasjer:** `sync_outbox` akkumuleres. Ved restart plukker workeren opp der den slapp (usynkede events har ingen markering)
|
||||
|
||||
## 7. Workspace-isolasjon
|
||||
SpacetimeDB-synkronisering er fullstendig workspace-scopet:
|
||||
* SpacetimeDB-tilkoblinger bærer `workspace_id` som context-token. Modulen partisjonerer minnet og kringkaster kun til klienter i samme workspace.
|
||||
* `sync_outbox`-events inkluderer `workspace_id` i payloaden, slik at Rust-workeren skriver til riktig silo i PostgreSQL.
|
||||
* Ved oppvarming (PG → SpacetimeDB) laster workeren kun data for den aktuelle workspace-en.
|
||||
|
||||
## 8. Konflikthåndtering
|
||||
|
||||
### 8.1 Strategi: Last-Write-Wins (LWW) med reservasjoner
|
||||
SpacetimeDB er autoritativ for sanntidsdata. Konflikter kan oppstå i to scenarioer:
|
||||
|
||||
**Scenario A — Samtidig redigering i SpacetimeDB:**
|
||||
SpacetimeDB er single-threaded per modul. Reducer-funksjoner serialiseres automatisk. Ingen klassiske race conditions.
|
||||
|
||||
**Scenario B — PG-oppvarming kolliderer med fersk SpacetimeDB-state:**
|
||||
Ved restart av SpacetimeDB lastes data fra PG (oppvarming). Hvis en klient skriver til SpacetimeDB *under* oppvarming, kan PG-dataen overskrive ferske endringer.
|
||||
|
||||
**Løsning:** Oppvarming setter et `warming_up`-flagg i SpacetimeDB-modulen. Klienttilkoblinger aksepteres, men skrivinger bufres til oppvarmingen er fullført. Buffrede skrivinger appliseres deretter i rekkefølge.
|
||||
|
||||
### 8.2 Kanban: Posisjonskonflikter
|
||||
Kanban-kort har en `position`-kolonne (float). To brukere som drar kort samtidig kan skape overlappende posisjoner. Løsning: SpacetimeDB-modulen re-beregner posisjoner (midtpunkts-strategi) og kringkaster oppdatert rekkefølge til alle klienter.
|
||||
|
||||
### 8.3 Chat: Ingen konflikter
|
||||
Meldinger er append-only. Redigering av egne meldinger er last-write-wins — akseptabelt fordi kun én bruker eier meldingen.
|
||||
|
||||
## 9. Implementeringsstatus (mars 2025)
|
||||
|
||||
### Ferdig
|
||||
- **SpacetimeDB Rust-modul** (`spacetimedb/src/lib.rs`): `ChatMessage`- og `SyncOutbox`-tabeller. `send_message`-reducer skriver til begge. Publisert som `sidelinja-realtime`.
|
||||
- **Hybrid-adapter i frontend** (`web/src/lib/chat/spacetime.svelte.ts`): Henter historikk fra PG via REST, lytter på SpacetimeDB for sanntidspush. Ingen oppvarming nødvendig — PG har alltid historikken.
|
||||
- **PG-fallback:** Fungerer automatisk. Hvis `VITE_SPACETIMEDB_URL` ikke er satt, brukes ren PG-polling (3 sek intervall).
|
||||
|
||||
### Gjenstår
|
||||
- **Sync-worker (§5.1):** Rust-worker som poller `sync_outbox` i SpacetimeDB og batch-skriver til PostgreSQL. Uten denne workeren persisteres meldinger sendt via SpacetimeDB kun i SpacetimeDB-minnet — de overlever ikke restart.
|
||||
- **Oppvarming (§5.2):** Ikke implementert, og hybrid-adapteren gjør dette mindre kritisk (klienten henter alltid PG-historikk uavhengig av SpacetimeDB).
|
||||
- **Workspace-partisjonering (§7):** SpacetimeDB-modulen har `workspace_id`-felt men bruker ikke workspace-token på tilkobling ennå.
|
||||
|
||||
### Designvalg tatt
|
||||
- **Hybrid fremfor ren SpacetimeDB:** Frontend bruker PG for historikk og SpacetimeDB kun for nye meldinger. Dette unngår oppvarmingsproblematikk og gir umiddelbar tilgang til all historikk.
|
||||
- **Graceful degradation:** SpacetimeDB-tilkoblingsfeil faller stille tilbake til PG. Brukeren ser ingen feilmelding — PG-data beholdes.
|
||||
- **Adapter-mønster:** `ChatConnection`-interface med to implementasjoner (PG og SpacetimeDB hybrid). Factory velger basert på env-variabel. Gjør det trivielt å teste hver adapter isolert.
|
||||
|
||||
## 10. Instruks for Claude Code
|
||||
- `sync_outbox`-tabellen i SpacetimeDB bør ha et `synced`-flagg og `created_at`-tidsstempel
|
||||
- Workeren skal bruke jobbkø-infrastrukturen (se `docs/infra/jobbkø.md`) for sin egen helse/observabilitet, men selve pollingen er en egen loop — ikke en vanlig jobb i køen
|
||||
- Hold sync-payloaden enkel: `{ "table": "chat_messages", "action": "insert", "data": {...} }` — workeren mapper dette til riktig PG-tabell
|
||||
- Ikke optimaliser for store datamengder ennå. Enkle INSERTs er bra nok til volumet stabiliserer seg
|
||||
- Alle sync-payloads må inkludere `workspace_id`. Workeren bruker dette til å sette RLS-kontekst (`SET app.current_workspace_id`) før PG-skriving, eller kjører som superuser og filtrerer eksplisitt
|
||||
40
docs/proposals/README.md
Normal file
40
docs/proposals/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Forslag (Proposals)
|
||||
|
||||
Halvtenkte idéer, kreative innfall og ting vi vil utforske når vi får tid. Ikke spesifisert, ikke forpliktet — bare parkert.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
proposals/ → features/ eller concepts/
|
||||
(idé) (spesifisert, klar for implementering)
|
||||
```
|
||||
|
||||
Når en idé modnes nok til å bli implementert, skrives en full spec i `docs/features/` eller `docs/concepts/` og forslaget slettes herfra.
|
||||
|
||||
## Oversikt
|
||||
|
||||
| Forslag | Innsats | Wow-faktor | Bygger på |
|
||||
|---|---|---|---|
|
||||
| [Auto-Clipper](auto_clipper.md) | Middels | Høy | Live transkripsjon, jobbkø, Caddy byte-range |
|
||||
| [Graph Health Monitor](graph_health_monitor.md) | Lav | Middels | Kunnskapsgraf, pgvector, jobbkø |
|
||||
| [Serendipity Roulette](serendipity_roulette.md) | Lav | Høy | Kunnskapsgraf, Live AI |
|
||||
| [Podcast Time Machine](podcast_time_machine.md) | Lav | Høy | Segmenter, Caddy byte-range, Live AI |
|
||||
| [Meme Generator](meme_generator.md) | Lav | Høy | Whiteboard, transkripsjon, AI Gateway |
|
||||
| [Valgomat Roast](valgomat_roast.md) | Lav | Middels | Valgomat, kunnskapsgraf |
|
||||
| [Live Audience Q&A](live_audience_qa.md) | Middels | Høy | Valgomat, LiveKit, SpacetimeDB |
|
||||
| [Guest Prep Simulator](guest_prep_simulator.md) | Middels | Høy | Kunnskapsgraf, AI Gateway |
|
||||
| [Debate Club](debate_club.md) | Middels | Middels | Kunnskapsgraf, AI Gateway, jobbkø |
|
||||
| [Ghost Host TTS](ghost_host_tts.md) | Stor | Høy | LiveKit, AI Gateway, ny TTS-infra |
|
||||
| [Artikkel-publisering](artikkel_publisering.md) | Middels | Høy | Kunnskapsgraf, Caddy, jobbkø, AI Gateway |
|
||||
| [Sosial publisering](social_posting.md) | Lav–Middels | Høy | Chat, jobbkø, workspace settings |
|
||||
| [Komponerbare sider](komponerbare_sider.md) | Lav (Fase 1) | Middels–Høy | Workspace-modell, SvelteKit, alle feature-komponenter |
|
||||
|
||||
**Lavthengende frukter** (lav innsats, høy wow): Serendipity Roulette, Podcast Time Machine, Meme Generator.
|
||||
|
||||
## Format
|
||||
Forslagsfiler er lette — ingen streng mal. Minimum:
|
||||
- Hva er idéen?
|
||||
- Hvorfor er den interessant?
|
||||
- Hva bygger den på? (eksisterende features/infra)
|
||||
- **Innsats** (Lav / Middels / Stor) og **Wow-faktor** (Lav / Middels / Høy)
|
||||
- Åpne spørsmål
|
||||
128
docs/proposals/artikkel_publisering.md
Normal file
128
docs/proposals/artikkel_publisering.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Forslag: Artikkel-publisering
|
||||
|
||||
## Idé
|
||||
Utvide Sidelinja fra et rent podcast-verktøy til en fullverdig publiseringsplattform. Brukere kan skrive, redigere og publisere artikler — integrert i kunnskapsgrafen, med støtte for vitenskapelig notasjon og en visuell standard som matcher de beste uavhengige publikasjonene.
|
||||
|
||||
## Designfilosofi: Vakre tekster
|
||||
|
||||
Sidelinja-artikler skal føles som noe mellom en Substack-essay og en akademisk publikasjon. Ingen sidebar-rot, ingen widget-helvete, ingen distraksjoner. Bare tekst, typografi og innhold.
|
||||
|
||||
**Prinsipper:**
|
||||
- **Typografi først.** Seriffont for brødtekst (Georgia, Literata eller lignende), god linjehøyde (1.6–1.8), komfortabel lesbredde (60–75 tegn). Overskrifter i sans-serif for kontrast.
|
||||
- **Luft.** Generøse marginer. Innholdet puster. Bilder og figurer får plass.
|
||||
- **Mørk/lys.** Respekterer `prefers-color-scheme`. Begge moduser skal være like gjennomtenkte.
|
||||
- **Matematikk er førsteklasses.** LaTeX-notasjon rendres med KaTeX (raskere enn MathJax, server-side-kompatibelt). Formler skal se like naturlige ut som brødtekst.
|
||||
- **Ingen visuelt støy.** Metadata (forfatter, dato, temaer) er diskret plassert. Delingsknappar og navigasjon forsvinner under lesing. Fokus er teksten.
|
||||
|
||||
**Inspirasjon:** Gwern.net (typografi + fotnoter), Distill.pub (interaktive figurer), Stratechery (ren leseopplevelse), Edward Tufte (informasjonstetthet uten rot).
|
||||
|
||||
## Hvorfor er det interessant?
|
||||
- **Naturlig forlengelse:** Redaksjonen har allerede chat, research-klipper og kunnskapsgraf. Artikler er neste logiske steg — fra intern diskusjon til publisert innhold.
|
||||
- **Grafkobling:** Artikler som noder i kunnskapsgrafen arver automatisk koblinger til temaer, aktører og episoder. En artikkel om "Skolepolitikk" kobles til alle podcast-episoder, faktoider og research-klipp om samme tema.
|
||||
- **SEO & Distribusjon:** Podcast-innhold er vanskelig å søkemotor-indeksere. Artikler gir tekstlig innhold som rangerer, med innebygde lenker tilbake til lydsegmenter.
|
||||
- **Show notes 2.0:** Dagens show notes kan bli fullverdige artikler med egne URL-er, illustrasjoner og grafkoblinger.
|
||||
- **Vitenskapelig troverdighet:** LaTeX-støtte gjør det mulig å publisere kvantitative analyser, statistikk og formelle argumenter med presisjon som matcher akademisk standard.
|
||||
|
||||
## Hva bygger den på?
|
||||
- **Kunnskapsgrafen:** Artikkel som ny `node_type` (`'artikkel'`), med edges til temaer/aktører
|
||||
- **Chat/Channels:** Hver artikkel kan ha en diskusjons-channel (kommentarfelt)
|
||||
- **Jobbkø:** AI-assistert skriving, faktasjekk mot kunnskapsgrafen, automatisk oppsummering
|
||||
- **Caddy:** Servering av publiserte artikler på egne URL-er
|
||||
- **AI Gateway:** Forslag til relaterte temaer, faktoider og episodesegmenter under skriving
|
||||
|
||||
## Skisse
|
||||
|
||||
### Innholdsformat: Markdown + LaTeX + Embeds
|
||||
|
||||
Artikler skrives i utvidet Markdown med tre tillegg:
|
||||
|
||||
**1. LaTeX-notasjon (KaTeX)**
|
||||
```markdown
|
||||
Gini-koeffisienten beregnes som:
|
||||
|
||||
$$G = \frac{\sum_{i=1}^{n} \sum_{j=1}^{n} |x_i - x_j|}{2n^2 \bar{x}}$$
|
||||
|
||||
der $x_i$ er inntekten til individ $i$ og $\bar{x}$ er gjennomsnittsinntekten.
|
||||
```
|
||||
|
||||
Inline-matte med `$...$`, blokk-matte med `$$...$$`. KaTeX rendrer server-side i SvelteKit (ingen klient-JS for lesere). Editoren viser live preview av formler.
|
||||
|
||||
**2. Podcast-embeds**
|
||||
```markdown
|
||||
{{segment:550e8400-e29b-41d4-a716-446655440000}}
|
||||
```
|
||||
Rendrer en innebygd lydspiller med transkripsjonstekst fra segmentet. Klikk for å lytte til akkurat det øyeblikket i episoden.
|
||||
|
||||
**3. Sidemerknad-fotnoter (Tufte-stil)**
|
||||
```markdown
|
||||
Dette er en påstand som trenger kontekst.^[Sidemerknad som vises i margen
|
||||
på brede skjermer, som popup på mobil. Kan inneholde $\LaTeX$ og lenker.]
|
||||
```
|
||||
|
||||
På brede skjermer (>1200px) vises fotnoter i margen ved siden av referansepunktet — aldri nederst på siden. På smale skjermer kollapses de til klikkbare popups.
|
||||
|
||||
### Datamodell
|
||||
```sql
|
||||
ALTER TYPE node_type ADD VALUE 'artikkel';
|
||||
|
||||
CREATE TABLE articles (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL, -- URL-vennlig tittel
|
||||
body TEXT NOT NULL, -- Markdown + LaTeX + embeds (kildeformat)
|
||||
body_html TEXT, -- Pre-rendret HTML (KaTeX + Markdown → HTML ved lagring)
|
||||
excerpt TEXT, -- Kort oppsummering for RSS/OG-tags
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- 'draft', 'published', 'archived'
|
||||
author_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
|
||||
published_at TIMESTAMPTZ,
|
||||
CONSTRAINT unique_slug_per_workspace UNIQUE (id) -- slug-unikhet via appkode + workspace
|
||||
);
|
||||
```
|
||||
|
||||
`body` er kildeformatet (Markdown + LaTeX). `body_html` er pre-rendret ved lagring, slik at lesere aldri venter på klient-side rendering. Editoren jobber mot `body`, leseren får `body_html`.
|
||||
|
||||
### Publiseringsflyt
|
||||
```
|
||||
Skriving (draft) → Intern review (channel) → Publisering → RSS + sitemap + OG-tags
|
||||
```
|
||||
|
||||
### Funksjoner
|
||||
- **Markdown + LaTeX editor** med split-view live preview i SvelteKit
|
||||
- **KaTeX server-side rendering** — ingen JavaScript-avhengighet for lesere
|
||||
- **Sidemerknad-fotnoter** (Tufte-stil) på brede skjermer, popup på mobil
|
||||
- **#-mentions** i artikkeltekst som automatisk oppretter graf-edges
|
||||
- **Innebygg podcast-klipp:** `{{segment:uuid}}` rendrer innebygd lydspiller
|
||||
- **Relatert innhold:** Diskret panel under artikkelen med automatisk foreslåtte temaer, aktører og episoder basert på graf-nabolag
|
||||
- **RSS-feed for artikler:** Separat fra podcast-RSS (Atom-format med full HTML-innhold)
|
||||
- **OG-tags / deling:** Automatisk generert Open Graph-metadata
|
||||
|
||||
### Typografi-stack (CSS)
|
||||
```
|
||||
Brødtekst: Literata / Georgia / serif (1.6 linjehøyde, 60–75ch bredde)
|
||||
Overskrifter: Inter / system-ui / sans-serif (stramt, tydelig hierarki)
|
||||
Kode: JetBrains Mono / monospace (med ligaturer)
|
||||
Matematikk: KaTeX default (skalerer med brødtekst)
|
||||
```
|
||||
|
||||
Fontene lastes som `font-display: swap` med system-fallback for umiddelbar rendering.
|
||||
|
||||
### URL-struktur
|
||||
```
|
||||
sidelinja.org/artikler/ → Artikkelliste (ren, minimal)
|
||||
sidelinja.org/artikler/skolepolitikk-2026 → Enkeltartikkel (full leseopplevelse)
|
||||
sidelinja.org/feed/artikler.xml → Artikkel-RSS (Atom)
|
||||
```
|
||||
|
||||
## Innsats
|
||||
**Middels** — Datamodellen er enkel (ny node_type + detailtabell). KaTeX har ferdig Svelte-integrasjon og SSR-støtte. Sidemerknad-fotnoter krever litt CSS-arbeid. Den tunge delen er å gjøre typografien og embed-syntaksen virkelig god.
|
||||
|
||||
## Wow-faktor
|
||||
**Høy** — Kombinasjonen av vakker typografi, LaTeX-støtte og podcast-embeds skaper noe som ikke finnes andre steder. En publiseringsplattform der en politisk analyse kan ha både formler, lydklipp fra intervjuer, og koblinger til kunnskapsgrafen.
|
||||
|
||||
## Åpne spørsmål
|
||||
- Skal artikler være multi-author (wiki-stil) eller single-author?
|
||||
- Kommentarfelt for publikum, eller kun intern diskusjon?
|
||||
- Versjonering av publiserte artikler (à la Wikipedia)?
|
||||
- Skal draft-artikler leve i SpacetimeDB for sanntids-samskriving, eller er PG + autosave tilstrekkelig?
|
||||
- Bilder: Content-addressable via `media_files`, eller ekstern hosting (CDN)?
|
||||
- Skal KaTeX-rendering skje ved lagring (raskere serving) eller ved request (enklere oppdatering)?
|
||||
26
docs/proposals/auto_clipper.md
Normal file
26
docs/proposals/auto_clipper.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Forslag: Auto-Clipper (Sosiale medier-klipp)
|
||||
**Innsats:** Middels | **Wow-faktor:** Høy
|
||||
|
||||
## Idé
|
||||
Under studio-innspilling (eller møterom) kjører en bakgrunnsprosess som lytter til Whisper-chunks + Aha-markører. Når AI-en oppdager en "punchline", en sterk mening eller et morsomt øyeblikk, genereres automatisk 15–45 sekunders lydklipp + thumbnail-tekst. Klippene havner i en "Clip Inbox"-channel med ett-klikk eksport.
|
||||
|
||||
## Hvorfor
|
||||
- Podcast-klipp er gull for vekst, men ingen vil klippe manuelt etter innspilling
|
||||
- Utnytter allerede eksisterende live-transkripsjon + faktoid-oppslag
|
||||
- Gir umiddelbar ROI: redaksjonen får 5–10 klipp per episode "gratis"
|
||||
|
||||
## Bygger på
|
||||
- Live transkripsjon + Live AI (studio-modus)
|
||||
- Jobbkø (`clip_extract`-jobb)
|
||||
- Caddy byte-range (klippene streames direkte fra original MP3 uten å duplisere filer)
|
||||
- Kunnskapsgraf (klipp kobles automatisk til `#Tema` og `@Aktør`)
|
||||
|
||||
## Dataklassifisering
|
||||
- Lydklipp: Avledet (kategori 3) — kan regenereres fra original MP3 + tidsstempler
|
||||
- Klipp-metadata (tidsstempler, score, thumbnail-tekst): Kritisk (PG)
|
||||
|
||||
## Åpne spørsmål
|
||||
- Scoring-modell: hva gjør et øyeblikk "klippverdig"? Humor, nyhet, kontrovers, emosjonell intensitet?
|
||||
- Skal klippene genereres som faktiske MP3-filer umiddelbart, eller lagre kun tidsstempler og generere on-demand ved eksport?
|
||||
- Eksportmål: TikTok/Instagram/YouTube Shorts — trenger vi video med waveform/teksting, eller holder lyd?
|
||||
- Kan dette kombineres med Podcast Time Machine for "throwback-klipp"?
|
||||
24
docs/proposals/debate_club.md
Normal file
24
docs/proposals/debate_club.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Forslag: Debate Club (Simulerte debatter)
|
||||
|
||||
## Idé
|
||||
Velg to aktører + et tema. Systemet genererer en simulert debatt der AI-en spiller begge roller basert på faktiske faktoider og sitater fra kunnskapsgrafen.
|
||||
|
||||
Kan brukes i studioet som research-verktøy: *"Hva ville Støre og Solberg sagt om dette i dag?"* — og eksporteres som lydklipp til episoden via TTS.
|
||||
|
||||
## Hvorfor
|
||||
- Kreativt bruk av kunnskapsgrafen som går utover oppslag
|
||||
- Research-verktøy: hjelper programlederne å forberede motargumenter
|
||||
- Underholdningsverdi: genuint morsomt når det treffer (og når det går galt)
|
||||
- Kan brukes i valgomaten som "hør hva partiene mener" basert på AI-profiler
|
||||
|
||||
## Bygger på
|
||||
- Kunnskapsgrafen (faktoider, aktør-profiler, `CONTRADICTS`-edges)
|
||||
- AI Gateway (`sidelinja/resonering` for nyansert debatt)
|
||||
- Jobbkø (generering tar tid)
|
||||
- Valgfritt: TTS for lydklipp (se `ghost_host_tts.md`)
|
||||
|
||||
## Åpne spørsmål
|
||||
- Etikk: OK å legge ord i munnen på ekte politikere? Trenger tydelig "AI-generert"-merking
|
||||
- Skal debatten baseres kun på faktoider vi har, eller kan AI-en fylle inn basert på offentlig kjent posisjon?
|
||||
- Node-type `debate` i grafen, eller bare en melding med spesiell `message_type`?
|
||||
- Kobling til valgomaten: kan debatter genereres fra AI-kandidatprofiler?
|
||||
29
docs/proposals/ghost_host_tts.md
Normal file
29
docs/proposals/ghost_host_tts.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Forslag: Ghost Host (AI Text-to-Speech i Studio)
|
||||
|
||||
## Idé
|
||||
Under innspilling kan programlederne trykke "Ghost Host"-knappen. AI-en genererer en kort kommentar (10-15 sek) basert på kunnskapsgrafen og tidligere episoder, og spiller den av med syntetisk stemme direkte i LiveKit-rommet.
|
||||
|
||||
*"Vegard, du sa akkurat 'det er jo helt bananas', men i episode 17 sa du det samme om vindkraft — skal vi sette inn et klipp?"*
|
||||
|
||||
## Hvorfor
|
||||
- Tar live AI-assistenten fra passiv (tekst-popup) til aktiv (snakker med i rommet)
|
||||
- Kan gi ikoniske podcast-øyeblikk
|
||||
- Unik feature som ingen andre podcast-plattformer har
|
||||
|
||||
## Bygger på
|
||||
- Live AI-assistent (faktoid-oppslag, NER)
|
||||
- Kunnskapsgrafen (faktoider, segmenter)
|
||||
- LiveKit (lydstrøm)
|
||||
- AI Gateway (tekst-generering)
|
||||
|
||||
## Ny avhengighet
|
||||
- **Text-to-Speech (TTS)** — dette krever ny infrastruktur:
|
||||
- Ekstern: ElevenLabs API (kan rutes via LiteLLM?)
|
||||
- Lokal: Piper TTS, Coqui TTS, eller Tortoise-TTS (Docker-container)
|
||||
- Vurdering: Lokal TTS passer bedre med self-hosted-filosofien, men kvaliteten er vesentlig lavere enn ElevenLabs
|
||||
|
||||
## Åpne spørsmål
|
||||
- Stemme: nøytral syntetisk stemme, eller voice clone av en vert? (etiske implikasjoner)
|
||||
- Latens: kan vi generere tekst + TTS + injisere i LiveKit under 3 sekunder?
|
||||
- Godkjenning: bør det spilles av direkte, eller vises som "Ghost Host vil si noe" med play-knapp?
|
||||
- Kill switch: hva om den sier noe feil live? Trenger en "avbryt"-knapp
|
||||
39
docs/proposals/graph_health_monitor.md
Normal file
39
docs/proposals/graph_health_monitor.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Forslag: Knowledge Graph Health Monitor
|
||||
**Innsats:** Lav | **Wow-faktor:** Middels
|
||||
|
||||
## Idé
|
||||
En admin-side i SvelteKit som viser "Graf-helse": tetthet, isolerte noder, svake relasjoner, ubalanserte temaer. AI-en foreslår ukentlig nye edges:
|
||||
|
||||
*"Du har 47 faktoider om Støre og 32 om Ap — skal vi koble dem med WORKS_FOR?"*
|
||||
*"Dette segmentet nevner vindkraft 9 ganger, men ingen #Tema-knute finnes."*
|
||||
|
||||
## Hvorfor
|
||||
- Kunnskapsgrafen vokser organisk, men kan bli rotete uten vedlikehold
|
||||
- Gir redaksjonen en "huskeliste" av ting de bør koble manuelt eller godkjenne
|
||||
- Synliggjør verdien av grafen — man ser den bli smartere over tid
|
||||
- Forhindrer "orphan nodes" som aldri dukker opp i oppslag
|
||||
|
||||
## Bygger på
|
||||
- Kunnskapsgrafen (nodes, graph_edges — rekursive CTEs for å finne isolerte subgrafer)
|
||||
- pgvector (allerede planlagt i Kunnskaps-Bridge — brukes for å finne semantisk like noder som mangler eksplisitt kobling)
|
||||
- `generate_embeddings`-jobb (eksisterende jobbtype)
|
||||
- Jobbkø (`graph_suggest_edges` — ny jobbtype, scheduled ukentlig)
|
||||
- AI Gateway (`sidelinja/resonering` for naturlig språk-forslag)
|
||||
|
||||
## Helsemetrikker
|
||||
| Metrikk | SQL-skisse | Handlingsforslag |
|
||||
|---|---|---|
|
||||
| Isolerte noder | `nodes LEFT JOIN graph_edges ... WHERE edge IS NULL` | "Koble til tema eller slett" |
|
||||
| Temaer uten faktoider | `themes LEFT JOIN factoids ... HAVING count = 0` | "Tomt tema — berik eller arkiver" |
|
||||
| Aktører uten relasjoner | Samme mønster | "Ny aktør — trenger kontekst" |
|
||||
| Semantisk like noder | pgvector cosine distance < 0.15 | "Mulig duplikat — slå sammen?" |
|
||||
| Manglende edges | AI-analyse av node-par med høy co-occurrence i segmenter | "Koble Støre → Ap?" |
|
||||
|
||||
## Dataklassifisering
|
||||
- Edge-forslag: Flyktig (TTL 30 dager) — godkjente forslag blir ekte `graph_edges`
|
||||
- Helsemetrikker: Avledet (beregnes on-demand fra grafen)
|
||||
|
||||
## Åpne spørsmål
|
||||
- Skal forslagene dukke opp som "innboks-kort" i Redaksjonen, eller kun på en dedikert admin-side?
|
||||
- Terskel for "semantisk lik": hvor lav cosine distance = mulig duplikat?
|
||||
- Bør monitoren kjøre per workspace eller globalt (cross-workspace via Bridge)?
|
||||
29
docs/proposals/guest_prep_simulator.md
Normal file
29
docs/proposals/guest_prep_simulator.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Forslag: Guest Prep Simulator
|
||||
**Innsats:** Middels | **Wow-faktor:** Høy
|
||||
|
||||
## Idé
|
||||
Før man sender async-gjest-lenke eller inviterer noen til studio, kan redaksjonen trykke "Simuler gjest". Systemet genererer en 3–5 min "prøve-debatt" der AI-en spiller gjesten basert på alt som finnes i kunnskapsgrafen om vedkommende (faktoider, tidligere episoder, sitater).
|
||||
|
||||
*"La meg forberede deg — her er hva 'AI-Erna' svarer når du spør om vindkraft..."*
|
||||
|
||||
## Hvorfor
|
||||
- Reduserer "overraskelser" live — programlederne er bedre forberedt
|
||||
- Gir bedre spørsmål og motargumenter på forhånd
|
||||
- Morsomt å høre AI-versjonen av gjesten svare på dagens tema
|
||||
- Naturlig utvidelse av Debate Club-idéen, men som forberedelsesverktøy
|
||||
|
||||
## Bygger på
|
||||
- Kunnskapsgraf + `graph_edges` (`CONTRADICTS`, `MENTIONS`)
|
||||
- AI Gateway (`sidelinja/resonering` — krever nyansert rollespill)
|
||||
- Debate Club-forslaget (lignende genereringslogikk)
|
||||
- Den Asynkrone Gjesten (konseptuell kobling — simuler *før* reell invitasjon)
|
||||
|
||||
## Dataklassifisering
|
||||
- Generert script (Markdown): Avledet (kategori 3)
|
||||
- TTS-lydfil (valgfritt): Flyktig (TTL 7 dager)
|
||||
|
||||
## Åpne spørsmål
|
||||
- Trenger vi TTS for å gjøre det "levende", eller holder tekst-basert simulering?
|
||||
- Etikk: tydelig "AI-simulert"-merking, spesielt hvis TTS brukes
|
||||
- Skal simuleringen baseres kun på faktoider vi har, eller kan AI-en fylle inn basert på offentlig kjent posisjon?
|
||||
- Kobling til Debate Club: er dette bare en spesialisert variant av samme feature?
|
||||
69
docs/proposals/komponerbare_sider.md
Normal file
69
docs/proposals/komponerbare_sider.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Komponerbare sider (Dashboard-komposisjon)
|
||||
|
||||
## Idé
|
||||
Brukere ser ferdige sider (Redaksjonen, Studioet, etc.), men admin kan komponere egne sider fra tilgjengelige byggeklosser — chat, kanban, statistikk, graf-visning, whiteboard, osv.
|
||||
|
||||
## Hvorfor interessant?
|
||||
Ulike redaksjoner jobber ulikt. Noen vil ha chat + kanban side-om-side, andre vil ha statistikk + research. I stedet for å hardkode alle kombinasjoner, gir vi admin verktøy til å sette opp sider tilpasset teamets arbeidsflyt. Brukerne ser resultatet som ferdige sider i navigasjonen.
|
||||
|
||||
## Modell
|
||||
|
||||
### Fase 1: Forhåndsdefinerte layouts (implementeres først)
|
||||
- Admin velger fra en katalog av **blokker** (chat, kanban, statistikk, etc.)
|
||||
- Plasserer dem i et **grid-layout** med forhåndsdefinerte maler (1-kolonne, 2-kolonne, 2+1, etc.)
|
||||
- Lagres som JSONB i `workspaces.settings` (nøkkel `pages`)
|
||||
- Brukere ser sidene i navigasjonen — ingen komposisjon, bare bruk
|
||||
- Mobil: blokkene stacker vertikalt (grid kollapser)
|
||||
|
||||
```jsonc
|
||||
// Eksempel: admin-definert side
|
||||
{
|
||||
"slug": "oversikt",
|
||||
"title": "Redaksjonsoversikt",
|
||||
"layout": "two-column", // Forhåndsdefinert mal
|
||||
"blocks": [
|
||||
{ "type": "chat", "channel": "general", "span": 1 },
|
||||
{ "type": "kanban", "board": "episoder", "span": 1 },
|
||||
{ "type": "stats", "view": "lyttere-7d", "span": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Fase 2: Konfigurerbare dashboards (senere, ved behov)
|
||||
- Brukere kan lage egne personlige dashboards
|
||||
- Drag-and-drop for å endre rekkefølge og størrelse
|
||||
- Lagres per bruker i `workspace_members.dashboard_config` JSONB (PG, ikke fil)
|
||||
|
||||
### Fase 3: Full fri tiling (trolig unødvendig)
|
||||
- VS Code / Bloomberg-stil fritt plassering med splitters
|
||||
- Ekstremt høy kompleksitet, lav marginalverdi vs. Fase 2
|
||||
- Unngå med mindre det er et demonstrert behov
|
||||
|
||||
## Arkitektur-krav
|
||||
Hver feature-komponent MÅ bygges som en **selvstendig Svelte-komponent** som:
|
||||
- Tar imot `workspaceId` (og evt. config-props som `channelId`, `boardId`)
|
||||
- Håndterer sin egen datahenting og tilstand
|
||||
- Respekterer container-størrelse (responsiv innenfor sin blokk)
|
||||
- Eksponerer en `blockMeta`-descriptor (tittel, min-bredde, ikon) for katalogen
|
||||
|
||||
Dette koster ingenting å gjøre fra start og gir full fleksibilitet senere.
|
||||
|
||||
## Bygger på
|
||||
- Workspace-modell, SvelteKit layout
|
||||
- Alle feature-komponenter (chat, kanban, whiteboard, statistikk, etc.)
|
||||
|
||||
## Innsats
|
||||
- Fase 1: **Lav** (grid-layout + JSON-config, ingen drag-and-drop)
|
||||
- Fase 2: **Middels** (svelte-grid, bruker-lagring)
|
||||
- Fase 3: **Stor** (custom tiling engine)
|
||||
|
||||
## Wow-faktor
|
||||
Middels–Høy. Gir en "dette er MITT verktøy"-følelse som skiller Sidelinja fra rigide alternativer.
|
||||
|
||||
## Åpne spørsmål
|
||||
- Skal sider være workspace-globale (alle ser samme oppsett) eller per-bruker?
|
||||
→ Fase 1: workspace-globale via `workspaces.settings` JSONB. Fase 2: personlige overrides i `workspace_members.dashboard_config` JSONB. Alt i PG — ingen filer per bruker.
|
||||
- Hvordan håndtere blokker som krever mye plass (whiteboard, graf) på mobil?
|
||||
→ Fullskjerm-modus per blokk som fallback.
|
||||
- Bør det finnes et "standard-oppsett" per workspace-type (podcast, nyhetsredaksjon)?
|
||||
→ Ja, som templates admin kan velge som utgangspunkt.
|
||||
52
docs/proposals/live_audience_qa.md
Normal file
52
docs/proposals/live_audience_qa.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Forslag: Live Audience Q&A (Studio-integrasjon)
|
||||
**Innsats:** Middels | **Wow-faktor:** Høy
|
||||
|
||||
## Idé
|
||||
Under live-innspilling åpner programlederne en "Spør oss"-QR-kode. Tilskuere (eller lyttere på nett) kan sende spørsmål anonymt via en mini-flate. Spørsmålene dukker opp i studio-chatten sortert etter popularitet (real-time voting). AI-en kan foreslå "Svar på dette med faktoid X" i sanntid.
|
||||
|
||||
*"En lytter spør: 'Hvorfor sa dere det motsatte i episode 17?' — og AI-en har allerede funnet klippet."*
|
||||
|
||||
## Hvorfor
|
||||
- Gjør innspilling interaktiv uten å miste kontroll
|
||||
- Gir gullmateriale til episoden ("En lytter spurte akkurat...")
|
||||
- Kobler publikum direkte inn i studioet — bygger community
|
||||
- Kan gjenbruke valgomat-konseptets anonyme deltakelse
|
||||
|
||||
## Bygger på
|
||||
- Valgomat (anonym UUID + SpacetimeDB for sanntids-stemming)
|
||||
- LiveKit + studio-chat (spørsmål vises i studio-UI)
|
||||
- Kunnskapsgraf (spørsmål kobles automatisk til `#Tema` via NER)
|
||||
- Live AI (kan foreslå relevante faktoider som svar)
|
||||
|
||||
## Datamodell (skisse)
|
||||
```
|
||||
audience_sessions (
|
||||
id UUID,
|
||||
workspace_id UUID,
|
||||
livekit_room_id TEXT,
|
||||
created_at TIMESTAMPTZ,
|
||||
closed_at TIMESTAMPTZ
|
||||
)
|
||||
|
||||
audience_questions (
|
||||
id UUID,
|
||||
session_id UUID,
|
||||
anonymous_id UUID, -- ingen brukerregistrering
|
||||
body TEXT,
|
||||
votes INT DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending', -- pending / shown / dismissed
|
||||
created_at TIMESTAMPTZ
|
||||
)
|
||||
```
|
||||
|
||||
## Dataklassifisering
|
||||
- Spørsmål brukt i episode: Kritisk (PG) — del av episodhistorikken
|
||||
- Spørsmål ikke brukt: Flyktig (TTL per sesjon, slettes ved lukking)
|
||||
- Anonym ID: Flyktig — ingen kobling til ekte bruker
|
||||
|
||||
## Åpne spørsmål
|
||||
- Moderering: bør spørsmål gjennom en "godkjenn"-queue før de vises i studio?
|
||||
- Spam: rate limiting per anonymous_id? Captcha? Eller holder det med manuell moderering?
|
||||
- Kan dette utvides til live-polling ("Er dere enige med Erna?") med sanntids-resultater?
|
||||
- Kobling til valgomat: bør QA-flaten være en utvidelse av valgomat-UI, eller helt separat?
|
||||
- Personvern: lagre IP? Kun for rate limiting, aldri i PG?
|
||||
22
docs/proposals/meme_generator.md
Normal file
22
docs/proposals/meme_generator.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Forslag: Møteroms-Meme Generator
|
||||
|
||||
## Idé
|
||||
Når whiteboard lukkes i møterommet, kjører en `meme_gen`-jobb som tar siste 30 sek transkripsjon + whiteboard-PNG og genererer 3 meme-forslag (Impact-font, norske tekster). Lagres som vedlegg i scratchpad-channelen.
|
||||
|
||||
## Hvorfor
|
||||
- Redaksjonen elsker dette allerede i Slack — her blir det innebygd
|
||||
- Viser at plattformen har personlighet
|
||||
- Utnytter whiteboard + transkripsjon + AI som allerede finnes
|
||||
- Lav innsats, høy moro
|
||||
|
||||
## Bygger på
|
||||
- Whiteboard (eksportert PNG)
|
||||
- Live transkripsjon (siste 30 sek)
|
||||
- AI Gateway (tekst-generering for meme-tekster)
|
||||
- Jobbkø (`meme_gen`-jobb)
|
||||
- Chat/channels (vedlegg i scratchpad)
|
||||
|
||||
## Åpne spørsmål
|
||||
- Trenger vi bildegenerering (DALL-E/Stable Diffusion), eller holder det med tekst-overlay på whiteboard-bildet?
|
||||
- Impact-font rendering server-side: Rust image-lib, eller ImageMagick i Docker?
|
||||
- Opt-in per møte, eller alltid-på?
|
||||
23
docs/proposals/podcast_time_machine.md
Normal file
23
docs/proposals/podcast_time_machine.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Forslag: Podcast Time Machine
|
||||
|
||||
## Idé
|
||||
Mens programlederne snakker om et tema, vises en "Play past clip"-knapp i studio-UI-et. Trykker du den, streamer systemet et 20-sekunders segment fra en gammel episode der samme aktør/tema ble nevnt.
|
||||
|
||||
Morsom variant: "Time Machine Roast" — viser bare de mest selvmotsigende eller pinlige sitatene fra arkivet.
|
||||
|
||||
## Hvorfor
|
||||
- Naturlig utvidelse av segment-søk som allerede finnes
|
||||
- Gjør podcast-arkivet til en aktiv ressurs under innspilling
|
||||
- Kan gi gullmomenter: "Du sa jo det stikk motsatte i episode 17!"
|
||||
- Caddy serverer allerede media med `Accept-Ranges: bytes` — byte-range streaming fungerer
|
||||
|
||||
## Bygger på
|
||||
- Segmenter med tidsstempler i PG (allerede indeksert med full-text search)
|
||||
- `DISCUSSED_IN` og `MENTIONS` edges i kunnskapsgrafen
|
||||
- Live AI-assistent (NER matcher aktører/temaer → oppslag)
|
||||
- Caddy media-servering (byte-range for å streame bare segmentet)
|
||||
|
||||
## Åpne spørsmål
|
||||
- Trenger vi en egen "clip cache" eller kan vi streame direkte fra MP3 med start/end byte offset?
|
||||
- Bør AI-en velge det mest *relevante* klippet, eller det mest *underholdende*?
|
||||
- Kan dette kombineres med Serendipity Roulette til et "throwback"-modus?
|
||||
22
docs/proposals/serendipity_roulette.md
Normal file
22
docs/proposals/serendipity_roulette.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Forslag: Serendipity Roulette
|
||||
|
||||
## Idé
|
||||
Med jevne mellomrom (konfigurerbart, f.eks. hvert 8. minutt) under innspilling i Studioet, trekker systemet en tilfeldig `DISCUSSED_IN`-edge fra kunnskapsgrafen — 2-3 ledd unna dagens tema. AI-en presenterer en morsom eller relevant faktoide som en "overraskelse" i studio-UI-et.
|
||||
|
||||
*"Visste dere at Jonas Gahr Støre i 2011 søkte jobb i AP… samtidig som han var i en episode der dere kalte ham 'den evige kronprinsen'?"*
|
||||
|
||||
## Hvorfor
|
||||
- Gir ekte "live co-host"-følelse — systemet bidrar aktivt til samtalen
|
||||
- Utnytter kunnskapsgrafen på en måte som belønner at den er godt fylt
|
||||
- Kan gi genuint gode podcast-øyeblikk som ellers ikke ville oppstått
|
||||
- Kan skrus av med én toggle
|
||||
|
||||
## Bygger på
|
||||
- Kunnskapsgrafen (graph_edges, recursive CTE for 2-3 ledd)
|
||||
- Live AI-assistent (SpacetimeDB push til studio-UI)
|
||||
- Eksisterende segmenter + faktoider
|
||||
|
||||
## Åpne spørsmål
|
||||
- Trenger vi tekst-til-tale (TTS) for at AI-en "leser høyt", eller holder en tekst-popup?
|
||||
- Hvordan unngå gjentakelser? Trenger vi en "already shown"-tabell per sesjon?
|
||||
- Bør den være smart nok til å vente på en naturlig pause i samtalen?
|
||||
150
docs/proposals/social_posting.md
Normal file
150
docs/proposals/social_posting.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Forslag: Sosial publisering fra chat
|
||||
|
||||
## Idé
|
||||
Post til X (og senere Bluesky/Mastodon) direkte fra Redaksjonens chat. Noen sier noe morsomt eller skarpt — én kommando, og det er ute i verden. Flere teammedlemmer deler én konto uten å dele passord.
|
||||
|
||||
## Hvorfor er det interessant?
|
||||
- **Senker terskelen.** En podcast-konto på X dør ofte fordi kun én person har tilgang og gidder. Med chat-integrasjon kan hvem som helst foreslå en post, og flyten er én handling — ikke "åpne X, logg inn på riktig konto, skriv, post".
|
||||
- **Spontanitet.** De beste sosiale medie-postene er de som kommer i øyeblikket. Å fange dem rett fra chatten der samtalen skjer bevarer energien.
|
||||
- **Kontroll uten friksjon.** Konfigurerbar godkjenningsflyt per workspace — fra "alle poster fritt" til "admin godkjenner alt".
|
||||
- **Bygger på det som finnes.** Chat, jobbkø, workspace-settings — ingen ny infrastruktur.
|
||||
|
||||
## Hva bygger den på?
|
||||
- **Chat** — `/x`-kommando som trigger publisering
|
||||
- **Jobbkø** — `social_post`-jobbtype med retry ved rate limit
|
||||
- **Workspace settings** — API-nøkler og godkjenningsflyt per workspace
|
||||
- **Admin-panel** — Workspace-innstillinger for plattformer og tilgangsstyring
|
||||
|
||||
## Flyt
|
||||
|
||||
### Uten godkjenning (workspace-innstilling: `approval: none`)
|
||||
```
|
||||
Vegard i chat: "Fantastisk intervju med @støre i dag, han innrømmet at han liker brunost på vaffel"
|
||||
Marte svarer: /x
|
||||
┌──────────────────────────────────────┐
|
||||
│ Post til X: │
|
||||
│ │
|
||||
│ Fantastisk intervju med @støre i │
|
||||
│ dag, han innrømmet at han liker │
|
||||
│ brunost på vaffel 🧇 │
|
||||
│ │
|
||||
│ 156/280 tegn │
|
||||
│ │
|
||||
│ [Rediger] [Avbryt] [Post nå] │
|
||||
└──────────────────────────────────────┘
|
||||
→ Jobb opprettes → Rust-worker poster til X
|
||||
→ Systemmelding i chat: "Marte postet til X: https://x.com/sidelinja/status/..."
|
||||
```
|
||||
|
||||
### Med godkjenning (workspace-innstilling: `approval: required`)
|
||||
```
|
||||
Marte: /x
|
||||
→ Jobb opprettes med status 'pending_approval'
|
||||
→ Systemmelding: "Marte foreslår X-post: «...» — reager med ✅ for å godkjenne"
|
||||
→ Admin reagerer med ✅
|
||||
→ Jobb endres til 'pending', worker poster
|
||||
→ Systemmelding oppdateres: "Postet av Marte, godkjent av Lars: https://x.com/..."
|
||||
```
|
||||
|
||||
### Med godkjenning fra hvem som helst med riktig rolle (workspace-innstilling: `approval: role`)
|
||||
Samme flyt, men `approval_role` i settings styrer hvem som kan godkjenne (default: `admin`+`owner`).
|
||||
|
||||
## Workspace-innstillinger
|
||||
|
||||
Lagres i `workspaces.settings` under nøkkelen `social`:
|
||||
|
||||
```json
|
||||
{
|
||||
"social": {
|
||||
"platforms": {
|
||||
"x": {
|
||||
"enabled": true,
|
||||
"api_key": "...",
|
||||
"api_secret": "...",
|
||||
"access_token": "...",
|
||||
"access_token_secret": "..."
|
||||
}
|
||||
},
|
||||
"approval": "none",
|
||||
"approval_role": "admin",
|
||||
"default_hashtags": ["#sidelinja"],
|
||||
"max_daily_posts": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`approval`-verdier:**
|
||||
| Verdi | Oppførsel |
|
||||
|---|---|
|
||||
| `none` | Alle workspace-medlemmer kan poste direkte |
|
||||
| `required` | Alle poster krever godkjenning |
|
||||
| `role` | Poster fra brukere med lavere rolle enn `approval_role` krever godkjenning |
|
||||
|
||||
## Datamodell
|
||||
|
||||
Ingen ny tabell — bruker jobbkøen med `job_type = 'social_post'`:
|
||||
|
||||
```json
|
||||
{
|
||||
"job_type": "social_post",
|
||||
"payload": {
|
||||
"platform": "x",
|
||||
"text": "Fantastisk intervju med @støre...",
|
||||
"source_message_id": "uuid",
|
||||
"suggested_by": "authentik_id",
|
||||
"approved_by": null,
|
||||
"thread_ids": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For godkjenningsflyt brukes en ekstra jobbstatus. Jobber med `approval: required` opprettes med `status = 'pending_approval'` (ny enum-verdi) og endres til `pending` ved godkjenning.
|
||||
|
||||
## Admin-panel (workspace-innstillinger)
|
||||
|
||||
Denne featuren forutsetter et minimalt admin-panel i SvelteKit (`/workspace/settings`). Første iterasjon trenger kun:
|
||||
|
||||
- **Sosiale kontoer:** Koble til X (OAuth-flyt), se tilkoblet konto, fjern
|
||||
- **Godkjenningsflyt:** Velg mellom `none` / `required` / `role`
|
||||
- **Historikk:** Liste over poster sendt fra dette workspacet (fra jobbkø-data)
|
||||
|
||||
Admin-panelet vil vokse med andre workspace-innstillinger over tid (Whisper-config, AI-prompts, channel-defaults, etc.), men sosial publisering er et godt første bruksområde.
|
||||
|
||||
## Plattform-abstraksjon
|
||||
|
||||
Chat-kommandoen og jobbkøen vet ikke om X spesifikt. Arkitekturen er:
|
||||
|
||||
```
|
||||
Chat (/x, /bsky, /social)
|
||||
│
|
||||
▼
|
||||
Jobbkø: social_post { platform: "x", text: "..." }
|
||||
│
|
||||
▼
|
||||
Rust worker: matcher platform → riktig API-klient
|
||||
├── X (OAuth 1.0a, v2 API)
|
||||
├── Bluesky (AT Protocol) [fremtidig]
|
||||
└── Mastodon (OAuth 2.0, REST) [fremtidig]
|
||||
```
|
||||
|
||||
Å legge til en ny plattform er å implementere én ny handler i Rust-workeren + legge til credentials i workspace-settings. Ingen endring i chat-koden.
|
||||
|
||||
## X API: Tekniske detaljer
|
||||
- **API:** X API v2, `POST /2/tweets`
|
||||
- **Auth:** OAuth 1.0a (User Context) — krever app + bruker-tokens
|
||||
- **Rate limit:** 200 poster/15 min per bruker (mer enn nok)
|
||||
- **Tråd-støtte:** `reply.in_reply_to_tweet_id` for tråder
|
||||
- **Pris:** Free tier tillater 1500 poster/mnd. Basic ($100/mnd) gir 3000 poster + lesing. Free er nok for start.
|
||||
|
||||
## Innsats
|
||||
**Lav–Middels** — X API-integrasjonen er enkel. Chat-kommandoen er en ny handler. Det meste av arbeidet er UI for preview/redigering-popupen og admin-panelet for innstillinger.
|
||||
|
||||
## Wow-faktor
|
||||
**Høy** — Gjør podcast-kontoen levende uten at noen må "huske å poste". Demokratiserer kontoen uten å dele passord.
|
||||
|
||||
## Åpne spørsmål
|
||||
- Skal `/x` kunne brukes på en hel tråd (flere meldinger → X-tråd)?
|
||||
- Bilder/media i poster — fra chat-vedlegg eller kun tekst i v1?
|
||||
- Skal poster inkludere en automatisk lenke tilbake til episoden/temaet fra grafen?
|
||||
- Bør det finnes en `/draft`-kommando som legger posten i en kø uten å sende (planlagt posting)?
|
||||
- Varsling i chat når en foreslått post venter på godkjenning for lenge?
|
||||
21
docs/proposals/valgomat_roast.md
Normal file
21
docs/proposals/valgomat_roast.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Forslag: Valgomat Roast Mode
|
||||
|
||||
## Idé
|
||||
Når en bruker matcher dårlig mot en politiker i valgomaten, får de en ekstra fane: "Sidelinja Roast". Systemet trekker morsomme sitater fra kunnskapsgrafen der politikeren har motsagt seg selv, eller der brukeren er overraskende enig med noen de ikke forventet.
|
||||
|
||||
*"Du er 98% uenig med Støre… men her er 3 ganger han har sagt akkurat det samme som deg."*
|
||||
|
||||
## Hvorfor
|
||||
- Gjør valgomaten delbar og viral — folk poster roasts på sosiale medier
|
||||
- Utnytter `CONTRADICTS`-edges og faktoider som allerede finnes i grafen
|
||||
- Pedagogisk: viser at politikk er mer nyansert enn venstre/høyre
|
||||
|
||||
## Bygger på
|
||||
- Valgomaten (match-algoritme, brukerprofil)
|
||||
- Kunnskapsgrafen (faktoider, `CONTRADICTS`-edges, AI-kandidatprofiler)
|
||||
- AI Gateway (generering av "roast"-tekst)
|
||||
|
||||
## Åpne spørsmål
|
||||
- Tone: morsom og spiss, eller saklig og overraskende? Begge?
|
||||
- Bør roasts modereres av redaksjonen før de vises?
|
||||
- Juss: kan vi "roaste" ekte politikere basert på AI-generert innhold?
|
||||
|
|
@ -88,9 +88,29 @@ docker compose -f docker-compose.dev.yml --env-file .env.local exec postgres pg_
|
|||
Lokale tjenester (docker-compose.dev.yml):
|
||||
- **PostgreSQL** — `127.0.0.1:5432` (kodeutvikling, migrasjoner)
|
||||
- **Redis** — `127.0.0.1:6379`
|
||||
- **SpacetimeDB** — `127.0.0.1:3000` (sanntidsbuffer for chat, kanban m.m.)
|
||||
- **AI Gateway (LiteLLM)** — `127.0.0.1:4000` (AI-ruter for Gemini, Claude, etc.)
|
||||
- **Caddy** — `127.0.0.1:80/443` (lokal HTTPS for WebRTC)
|
||||
- **faster-whisper** — `127.0.0.1:8000` (transkripsjon-eksperimentering)
|
||||
|
||||
## 5.1 SpacetimeDB-modul (valgfritt — kun for sanntidschat)
|
||||
|
||||
Hvis du vil teste sanntidschat via SpacetimeDB (i stedet for PG-polling):
|
||||
|
||||
```bash
|
||||
# Installer SpacetimeDB CLI
|
||||
curl -sSf https://install.spacetimedb.com | bash
|
||||
|
||||
# Bygg og publiser Rust-modulen mot lokal SpacetimeDB
|
||||
cd spacetimedb
|
||||
spacetime publish sidelinja-realtime --server http://localhost:3000
|
||||
|
||||
# Legg til env-variabel for SvelteKit (web/.env)
|
||||
echo "VITE_SPACETIMEDB_URL=ws://localhost:3000" > web/.env
|
||||
```
|
||||
|
||||
Uten `VITE_SPACETIMEDB_URL` faller chatten automatisk tilbake til ren PG-polling — SpacetimeDB er ikke påkrevd for utvikling.
|
||||
|
||||
## 6. Utviklingsflyt
|
||||
|
||||
### Kode (daglig)
|
||||
|
|
@ -129,5 +149,6 @@ ssh sidelinja@157.180.81.26 "cd /srv/sidelinja && git pull && docker compose up
|
|||
| Forgejo | Ikke installert — push til prod | Docker container |
|
||||
| Authentik | Ikke installert — bypass auth lokalt | Docker container |
|
||||
| DB-data | Flyktig (`.docker-data/`, gitignored) | Persistent, backupes daglig |
|
||||
| SpacetimeDB | Lokal, modul publiseres manuelt | Docker container, modul deployes via CI |
|
||||
| Whisper | For eksperimentering | Produksjonstranskripsjon |
|
||||
| AI Gateway | For prompt-testing | Produksjonsruting |
|
||||
|
|
|
|||
66
docs/setup/migration_safety.md
Normal file
66
docs/setup/migration_safety.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Migration Safety Checklist
|
||||
|
||||
Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod.
|
||||
|
||||
## Før migrering
|
||||
|
||||
- [ ] Les migrasjonfilen og forstå hva den gjør
|
||||
- [ ] Har migrasjonen en tilhørende **down-migrering**? (påkrevd for skjema-endringer)
|
||||
- [ ] Tar migrasjonen backup-hensyn? (dropper den kolonner/tabeller med data?)
|
||||
|
||||
## Etter migrering
|
||||
|
||||
### RLS-verifisering (KRITISK)
|
||||
Etter *enhver* migrering som oppretter eller endrer tabeller med `workspace_id`:
|
||||
|
||||
```sql
|
||||
-- 1. Verifiser at RLS er aktivert på alle workspace-tabeller
|
||||
SELECT tablename, rowsecurity
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('nodes', 'graph_edges', 'messages', 'channels',
|
||||
'media_files', 'job_queue', 'message_attachments')
|
||||
ORDER BY tablename;
|
||||
-- Forventet: rowsecurity = true for alle
|
||||
|
||||
-- 2. Verifiser at policies eksisterer
|
||||
SELECT tablename, policyname, cmd, qual
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY tablename;
|
||||
-- Forventet: workspace_isolation_* policy for hver tabell
|
||||
|
||||
-- 3. Test isolasjon: sett workspace A, forsøk å lese workspace B
|
||||
SET app.current_workspace_id = '<workspace_a_uuid>';
|
||||
SELECT count(*) FROM nodes WHERE workspace_id = '<workspace_b_uuid>';
|
||||
-- Forventet: 0 rader (RLS blokkerer)
|
||||
|
||||
-- 4. Verifiser at superuser IKKE blokkeres (Rust workers trenger dette)
|
||||
RESET app.current_workspace_id;
|
||||
SET ROLE postgres;
|
||||
SELECT count(*) FROM nodes;
|
||||
-- Forventet: alle rader synlige
|
||||
```
|
||||
|
||||
### Indeksverifisering
|
||||
```sql
|
||||
-- Sjekk at viktige indekser finnes
|
||||
SELECT indexname, tablename
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('nodes', 'graph_edges', 'messages')
|
||||
ORDER BY tablename;
|
||||
```
|
||||
|
||||
### Constraint-verifisering
|
||||
```sql
|
||||
-- Sjekk at foreign keys er intakte
|
||||
SELECT tc.table_name, tc.constraint_name, tc.constraint_type
|
||||
FROM information_schema.table_constraints tc
|
||||
WHERE tc.table_schema = 'public'
|
||||
AND tc.constraint_type IN ('FOREIGN KEY', 'CHECK', 'UNIQUE')
|
||||
ORDER BY tc.table_name;
|
||||
```
|
||||
|
||||
## Automatisering
|
||||
Disse sjekkene kjøres automatisk i migrasjonstestene (se `ARCHITECTURE.md` §10.2). Manuell kjøring er kun nødvendig ved prod-migrasjoner til automatiserte tester er på plass.
|
||||
363
migrations/0001_initial_schema.sql
Normal file
363
migrations/0001_initial_schema.sql
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
-- Sidelinja: Initial database schema
|
||||
-- Workspaces, kunnskapsgraf, jobbkø, meldinger, mediefiler
|
||||
--
|
||||
-- Kjøres mot en tom PostgreSQL-database.
|
||||
-- Krever: PostgreSQL 15+ med pgcrypto (gen_random_uuid)
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================
|
||||
-- Extensions
|
||||
-- ============================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- uuid_generate_v5() for deterministiske segment-UUIDs
|
||||
|
||||
-- ============================================================
|
||||
-- 0. Felles: updated_at trigger-funksjon
|
||||
-- ============================================================
|
||||
-- Brukes av alle tabeller som trenger automatisk oppdatering av
|
||||
-- updated_at-kolonnen. Nødvendig for inkrementell synk, cache-
|
||||
-- invalidering og UI ("sist redigert").
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Workspaces
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL, -- URL-vennlig ID, brukes i filstier og media-ruting
|
||||
domain TEXT UNIQUE, -- Valgfritt eget domene for RSS/media
|
||||
settings JSONB NOT NULL DEFAULT '{}', -- Workspace-config: Whisper initial_prompt, AI system-prompts
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_workspaces_updated_at BEFORE UPDATE ON workspaces
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Brukere (tynn cache fra Authentik)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE users (
|
||||
authentik_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
cached_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TYPE workspace_role AS ENUM ('owner', 'admin', 'member');
|
||||
|
||||
CREATE TABLE workspace_members (
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
|
||||
role workspace_role NOT NULL DEFAULT 'member',
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, user_id)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Nodes — supertabell for alle grafentiteter
|
||||
-- ============================================================
|
||||
|
||||
CREATE TYPE node_type AS ENUM (
|
||||
'tema', 'aktør', 'faktoide', 'episode', 'segment',
|
||||
'channel', 'melding', 'meeting'
|
||||
);
|
||||
|
||||
CREATE TABLE nodes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
node_type node_type NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_nodes_workspace ON nodes(workspace_id);
|
||||
CREATE INDEX idx_nodes_type ON nodes(workspace_id, node_type);
|
||||
|
||||
CREATE TRIGGER trg_nodes_updated_at BEFORE UPDATE ON nodes
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Relasjonstyper (globale, ikke workspace-spesifikke)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE relation_types (
|
||||
name TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
description TEXT,
|
||||
system BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
INSERT INTO relation_types (name, label, description, system) VALUES
|
||||
('MENTIONS', 'Nevner', 'Refererer til denne aktøren/temaet', true),
|
||||
('ABOUT', 'Handler om', 'Faktoide eller klipp som omhandler', true),
|
||||
('DISCUSSED_IN', 'Diskutert i', 'Tema diskutert i dette segmentet', true),
|
||||
('WORKS_FOR', 'Jobber for', 'Person tilknyttet organisasjon', true),
|
||||
('CONTRADICTS', 'Motsier', 'Står i motsetning til', true),
|
||||
('PART_OF', 'Del av', 'Tilhører / er underordnet', true),
|
||||
('AFFECTS_AXIS', 'Påvirker akse', 'Valgomat: spørsmål påvirker akse', true);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Graph edges (workspace-scopet med RLS)
|
||||
-- ============================================================
|
||||
-- workspace_id er denormalisert fra nodes for å muliggjøre RLS direkte
|
||||
-- på edges. Uten dette kan en direkte SELECT på graph_edges lekke
|
||||
-- relasjoner på tvers av workspaces.
|
||||
|
||||
CREATE TABLE graph_edges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
target_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
relation_type TEXT NOT NULL REFERENCES relation_types(name),
|
||||
context_id UUID REFERENCES nodes(id) ON DELETE SET NULL,
|
||||
confidence REAL CHECK (confidence BETWEEN 0.0 AND 1.0),
|
||||
created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
|
||||
origin TEXT NOT NULL DEFAULT 'system',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT no_self_reference CHECK (source_id != target_id),
|
||||
CONSTRAINT unique_edge UNIQUE (source_id, target_id, relation_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_edges_source ON graph_edges(source_id);
|
||||
CREATE INDEX idx_edges_target ON graph_edges(target_id);
|
||||
CREATE INDEX idx_edges_relation ON graph_edges(relation_type);
|
||||
CREATE INDEX idx_edges_workspace ON graph_edges(workspace_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 6. Detailtabeller — aktører, temaer, episoder, segmenter
|
||||
-- ============================================================
|
||||
-- Workspace-tilhørighet arves via FK til nodes.
|
||||
-- Spørringer filtrerer alltid via JOIN med nodes.
|
||||
|
||||
CREATE TABLE actors (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT -- 'person', 'organisasjon', etc.
|
||||
);
|
||||
|
||||
CREATE INDEX idx_actors_name ON actors(name);
|
||||
|
||||
CREATE TABLE topics (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_topics_name ON topics(name);
|
||||
|
||||
CREATE TABLE episodes (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
published_at TIMESTAMPTZ,
|
||||
guid TEXT UNIQUE NOT NULL -- RSS <guid>, immutabel etter opprettelse
|
||||
);
|
||||
|
||||
-- Forhindre endring av episode-GUID etter opprettelse.
|
||||
-- RSS-klienter bruker GUID for å identifisere episoder — endring bryter feeds.
|
||||
CREATE OR REPLACE FUNCTION prevent_guid_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.guid IS NOT NULL AND NEW.guid != OLD.guid THEN
|
||||
RAISE EXCEPTION 'episode guid er immutabel og kan ikke endres etter opprettelse';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_episodes_guid_immutable BEFORE UPDATE ON episodes
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_guid_change();
|
||||
|
||||
CREATE TABLE segments (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
episode_id UUID NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
start_time INTERVAL NOT NULL,
|
||||
end_time INTERVAL NOT NULL,
|
||||
transcript TEXT,
|
||||
CONSTRAINT valid_timerange CHECK (end_time > start_time)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_segments_episode ON segments(episode_id);
|
||||
CREATE INDEX idx_segments_fts ON segments USING GIN (to_tsvector('norwegian', transcript));
|
||||
|
||||
-- ============================================================
|
||||
-- 7. Channels (chat-kontekster knyttet til vilkårlig node)
|
||||
-- ============================================================
|
||||
-- En channel er en meldingsstrøm knyttet til en parent-node.
|
||||
-- Enhver node (tema, episode, møte, aktør, ...) kan ha én eller flere channels.
|
||||
-- Config styrer hvilke capabilities kanalen har.
|
||||
|
||||
CREATE TABLE channels (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
parent_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Diskusjon',
|
||||
config JSONB NOT NULL DEFAULT '{
|
||||
"threads": true,
|
||||
"mentions": true,
|
||||
"attachments": true,
|
||||
"research_clips": false,
|
||||
"ttl_days": null
|
||||
}'::jsonb
|
||||
-- config.threads — tillat reply-to-tråder
|
||||
-- config.mentions — #/@ mention-parsing + grafkoblinger
|
||||
-- config.attachments — filopplasting
|
||||
-- config.research_clips — research_clip meldingstype
|
||||
-- config.ttl_days — null = permanent, tall = auto-slett etter N dager
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channels_parent ON channels(parent_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 8. Faktoider
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE factoids (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE factoid_votes (
|
||||
factoid_id UUID NOT NULL REFERENCES factoids(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
|
||||
vote SMALLINT NOT NULL CHECK (vote IN (-1, 1)),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (factoid_id, user_id)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 9. Meldinger (chat)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TYPE message_type AS ENUM (
|
||||
'text', -- Vanlig Markdown-melding
|
||||
'research_clip', -- Innlimt artikkel (Ctrl+A)
|
||||
'factoid', -- Faktoid delt i chat
|
||||
'voice_memo', -- Lydmelding (lyd er master, transkripsjon som metadata)
|
||||
'system' -- Automatisk systemmelding
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
author_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker
|
||||
message_type message_type NOT NULL DEFAULT 'text',
|
||||
body TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
edited_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_messages_channel ON messages(channel_id, created_at);
|
||||
|
||||
CREATE TABLE message_revisions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
edited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT revision_order UNIQUE (message_id, edited_at)
|
||||
);
|
||||
|
||||
CREATE TABLE message_votes (
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
|
||||
vote SMALLINT NOT NULL CHECK (vote IN (-1, 1)),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 10. Mediefiler (content-addressable, workspace-scopet)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE media_files (
|
||||
sha256 TEXT PRIMARY KEY,
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
uploaded_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL, -- NULL = slettet bruker
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_media_workspace ON media_files(workspace_id);
|
||||
|
||||
CREATE TABLE message_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
sha256 TEXT NOT NULL REFERENCES media_files(sha256),
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 11. Jobbkø (workspace-scopet)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'retry');
|
||||
|
||||
CREATE TABLE job_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
job_type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
status job_status NOT NULL DEFAULT 'pending',
|
||||
priority SMALLINT NOT NULL DEFAULT 0,
|
||||
result JSONB,
|
||||
error_msg TEXT,
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
max_attempts SMALLINT NOT NULL DEFAULT 3,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for ASC)
|
||||
WHERE status IN ('pending', 'retry');
|
||||
CREATE INDEX idx_job_queue_workspace ON job_queue(workspace_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 12. Row-Level Security (Workspace-isolasjon)
|
||||
-- ============================================================
|
||||
-- SvelteKit setter session-variabel ved tilkobling:
|
||||
-- SET app.current_workspace_id = '<uuid>';
|
||||
--
|
||||
-- Rust-workers kjører som superuser (bypasser RLS) for å prosessere
|
||||
-- jobber på tvers av workspaces. Workspace-isolasjon for workers
|
||||
-- sikres i applikasjonskode (job_queue.workspace_id).
|
||||
--
|
||||
-- Detailtabeller (actors, topics, etc.) har ikke egen RLS — de
|
||||
-- arver workspace-tilhørighet via FK til nodes.
|
||||
-- Applikasjonskode filtrerer via JOIN med nodes.
|
||||
|
||||
ALTER TABLE nodes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE graph_edges ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE media_files ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE job_queue ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY workspace_isolation_nodes ON nodes
|
||||
USING (workspace_id = current_setting('app.current_workspace_id')::uuid);
|
||||
|
||||
CREATE POLICY workspace_isolation_edges ON graph_edges
|
||||
USING (workspace_id = current_setting('app.current_workspace_id')::uuid);
|
||||
|
||||
CREATE POLICY workspace_isolation_media ON media_files
|
||||
USING (workspace_id = current_setting('app.current_workspace_id')::uuid);
|
||||
|
||||
CREATE POLICY workspace_isolation_jobs ON job_queue
|
||||
USING (workspace_id = current_setting('app.current_workspace_id')::uuid);
|
||||
|
||||
COMMIT;
|
||||
65
migrations/seed_dev.sql
Normal file
65
migrations/seed_dev.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
-- Utviklingsdata for lokalt testmiljø.
|
||||
-- Kjøres etter 0001_initial_schema.sql.
|
||||
-- IKKE bruk i produksjon.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Test-workspace
|
||||
INSERT INTO workspaces (id, name, slug) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', 'Sidelinja Podcast', 'sidelinja');
|
||||
|
||||
-- Vegard (Authentik sub claim + dev-user alias)
|
||||
INSERT INTO users (authentik_id, display_name) VALUES
|
||||
('f0c628bf-2dde-42a9-86f9-6a308248a38f', 'Vegard Nøtnæs'),
|
||||
('dev-user-1', 'Vegard (dev)');
|
||||
|
||||
-- Koble begge bruker-IDer til workspace
|
||||
INSERT INTO workspace_members (workspace_id, user_id, role) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', 'f0c628bf-2dde-42a9-86f9-6a308248a38f', 'owner'),
|
||||
('a0000000-0000-0000-0000-000000000001', 'dev-user-1', 'owner');
|
||||
|
||||
-- Workspace-rot-node (parent for workspace-level channels)
|
||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||
('a0000000-0000-0000-0000-000000000010', 'a0000000-0000-0000-0000-000000000001', 'channel');
|
||||
|
||||
-- Generell chat-kanal
|
||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||
('a0000000-0000-0000-0000-000000000011', 'a0000000-0000-0000-0000-000000000001', 'channel');
|
||||
INSERT INTO channels (id, parent_id, name) VALUES
|
||||
('a0000000-0000-0000-0000-000000000011', 'a0000000-0000-0000-0000-000000000010', 'Generelt');
|
||||
|
||||
-- Redaksjons-kanal
|
||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||
('a0000000-0000-0000-0000-000000000012', 'a0000000-0000-0000-0000-000000000001', 'channel');
|
||||
INSERT INTO channels (id, parent_id, name) VALUES
|
||||
('a0000000-0000-0000-0000-000000000012', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonen');
|
||||
|
||||
-- Default-sider for workspace
|
||||
UPDATE workspaces SET settings = jsonb_set(
|
||||
COALESCE(settings, '{}'::jsonb),
|
||||
'{pages}',
|
||||
'[
|
||||
{
|
||||
"slug": "redaksjonen",
|
||||
"title": "Redaksjonen",
|
||||
"icon": "📰",
|
||||
"layout": "2-1",
|
||||
"blocks": [
|
||||
{"id": "chat-1", "type": "chat", "title": "Redaksjonschat", "props": {"channelId": "a0000000-0000-0000-0000-000000000012"}},
|
||||
{"id": "kanban-1", "type": "kanban", "title": "Planlegging"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "research",
|
||||
"title": "Research",
|
||||
"icon": "🔍",
|
||||
"layout": "2-col",
|
||||
"blocks": [
|
||||
{"id": "research-1", "type": "research", "title": "Research-klipp"},
|
||||
{"id": "graph-1", "type": "graph", "title": "Kunnskapsgraf"}
|
||||
]
|
||||
}
|
||||
]'::jsonb
|
||||
) WHERE slug = 'sidelinja';
|
||||
|
||||
COMMIT;
|
||||
58
scripts/collect-docs.sh
Executable file
58
scripts/collect-docs.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# Samler all prosjektdokumentasjon til én fil for deling med AI-er etc.
|
||||
# Bruk: ./collect-docs.sh → skriver scripts/server_context.md
|
||||
# ./collect-docs.sh - → skriver til stdout (for piping)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
OUT="$SCRIPT_DIR/server_context.md"
|
||||
|
||||
files=(
|
||||
# Overordnet
|
||||
"$ROOT/ARCHITECTURE.md"
|
||||
"$ROOT/CLAUDE.md"
|
||||
|
||||
# Konsepter (brukeropplevelser)
|
||||
"$ROOT"/docs/concepts/*.md
|
||||
|
||||
# Features (tekniske byggeklosser)
|
||||
"$ROOT"/docs/features/*.md
|
||||
|
||||
# Infrastruktur
|
||||
"$ROOT"/docs/infra/*.md
|
||||
|
||||
# Forslag (halvtenkte idéer)
|
||||
"$ROOT"/docs/proposals/*.md
|
||||
|
||||
# Erfaringer (lærdommer fra implementering)
|
||||
"$ROOT"/docs/erfaringer/*.md
|
||||
|
||||
# Setup
|
||||
"$ROOT"/docs/setup/*.md
|
||||
|
||||
# Migrasjoner
|
||||
"$ROOT"/migrations/*.sql
|
||||
)
|
||||
|
||||
collect() {
|
||||
for f in "${files[@]}"; do
|
||||
[[ -f "$f" ]] || continue
|
||||
rel="${f#"$ROOT/"}"
|
||||
echo "================================================================"
|
||||
echo "FILE: $rel"
|
||||
echo "================================================================"
|
||||
echo ""
|
||||
cat "$f"
|
||||
echo ""
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-" ]]; then
|
||||
collect
|
||||
else
|
||||
collect > "$OUT"
|
||||
echo "Wrote $OUT ($(wc -l < "$OUT") lines)"
|
||||
fi
|
||||
947
spacetimedb/Cargo.lock
generated
Normal file
947
spacetimedb/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,947 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0e60b75072ecd4168020818c0107f2857bb6c4e64252d8d3983f6263b40a5c3"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decorum"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "enum-as-inner"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "ethnum"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "second-stack"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4904c83c6e51f1b9b08bfa5a86f35a51798e8307186e6f5513852210a219c0bb"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "sidelinja-realtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "503ac8c991a76998d4ba699ef9b7f0085d3d7c363d1fcce4219314f909746bca"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
"derive_more",
|
||||
"getrandom 0.2.17",
|
||||
"http",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"scoped-tls",
|
||||
"serde_json",
|
||||
"spacetimedb-bindings-macro",
|
||||
"spacetimedb-bindings-sys",
|
||||
"spacetimedb-lib",
|
||||
"spacetimedb-primitives",
|
||||
"spacetimedb-query-builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-bindings-macro"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1214628a7c29ee58255d511b7c4dbaaa463bc5022dba9401f264c1c85b5a891c"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"humantime",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"spacetimedb-primitives",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-bindings-sys"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4777d90692bade6601887a21a074b71c157b34a92a5cfc8d5ecb46a0c571094"
|
||||
dependencies = [
|
||||
"spacetimedb-primitives",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-lib"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5e91c66b10dc38cce01928d3f77313276e34c635504e54afdec6f186a2fc9c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"derive_more",
|
||||
"enum-as-inner",
|
||||
"hex",
|
||||
"itertools",
|
||||
"log",
|
||||
"spacetimedb-bindings-macro",
|
||||
"spacetimedb-primitives",
|
||||
"spacetimedb-sats",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-primitives"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f0321a161fa39f0937aceb436b47115cd811212799ddaf7996a8ecac3476d8d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"either",
|
||||
"enum-as-inner",
|
||||
"itertools",
|
||||
"nohash-hasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-query-builder"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df8137b6dc2739d4efbc6218c5fb106f1e105a1345819a74053b677bd38c429"
|
||||
dependencies = [
|
||||
"spacetimedb-lib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-sats"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4e07b1bc933156b0cbe6b6c759e57381ce95df52c4522d9c3d71df59c01cf20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"decorum",
|
||||
"derive_more",
|
||||
"enum-as-inner",
|
||||
"ethnum",
|
||||
"hex",
|
||||
"itertools",
|
||||
"rand 0.9.2",
|
||||
"second-stack",
|
||||
"sha3",
|
||||
"smallvec",
|
||||
"spacetimedb-bindings-macro",
|
||||
"spacetimedb-primitives",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
11
spacetimedb/Cargo.toml
Normal file
11
spacetimedb/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "sidelinja-realtime"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
spacetimedb = "1.0"
|
||||
log = "0.4"
|
||||
148
spacetimedb/src/lib.rs
Normal file
148
spacetimedb/src/lib.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use spacetimedb::{table, reducer, Table, ReducerContext, Timestamp};
|
||||
|
||||
// === Tabeller ===
|
||||
|
||||
/// Chat-melding. Speiler PostgreSQL `messages`-tabellen.
|
||||
/// `public` = alle tilkoblede klienter i samme subscription kan lese.
|
||||
#[table(name = chat_message, public)]
|
||||
pub struct ChatMessage {
|
||||
/// PostgreSQL node UUID (settes av klienten ved oppvarming, auto ved ny melding)
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
pub channel_id: String,
|
||||
pub workspace_id: String,
|
||||
pub author_id: String,
|
||||
pub author_name: String,
|
||||
pub body: String,
|
||||
pub message_type: String,
|
||||
pub reply_to: String,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
/// Outbox for synkronisering til PostgreSQL.
|
||||
/// Rust sync-worker leser denne og batch-skriver til PG.
|
||||
#[table(name = sync_outbox, public)]
|
||||
pub struct SyncOutbox {
|
||||
#[auto_inc]
|
||||
#[primary_key]
|
||||
pub id: u64,
|
||||
pub table_name: String,
|
||||
pub action: String,
|
||||
pub payload: String,
|
||||
pub workspace_id: String,
|
||||
pub created_at: Timestamp,
|
||||
pub synced: bool,
|
||||
}
|
||||
|
||||
// === Reducers ===
|
||||
|
||||
/// Send en ny melding. Kalles fra klienten.
|
||||
/// Oppretter ChatMessage + SyncOutbox-event.
|
||||
#[reducer]
|
||||
pub fn send_message(
|
||||
ctx: &ReducerContext,
|
||||
id: String,
|
||||
channel_id: String,
|
||||
workspace_id: String,
|
||||
author_name: String,
|
||||
body: String,
|
||||
reply_to: String,
|
||||
) -> Result<(), String> {
|
||||
if body.trim().is_empty() {
|
||||
return Err("Melding kan ikke være tom".to_string());
|
||||
}
|
||||
|
||||
// Bygg payload først (før verdiene flyttes inn i ChatMessage)
|
||||
let payload = format!(
|
||||
r#"{{"id":"{}","channel_id":"{}","workspace_id":"{}","author_id":"{}","body":"{}","reply_to":"{}"}}"#,
|
||||
id, channel_id, workspace_id, ctx.sender.to_hex(),
|
||||
body.trim().replace('"', r#"\""#),
|
||||
reply_to
|
||||
);
|
||||
|
||||
let msg = ChatMessage {
|
||||
id,
|
||||
channel_id,
|
||||
workspace_id: workspace_id.clone(),
|
||||
author_id: ctx.sender.to_hex().to_string(),
|
||||
author_name,
|
||||
body: body.trim().to_string(),
|
||||
message_type: "text".to_string(),
|
||||
reply_to,
|
||||
created_at: ctx.timestamp,
|
||||
};
|
||||
|
||||
ctx.db.chat_message().insert(msg);
|
||||
|
||||
ctx.db.sync_outbox().insert(SyncOutbox {
|
||||
id: 0,
|
||||
table_name: "messages".to_string(),
|
||||
action: "insert".to_string(),
|
||||
payload,
|
||||
workspace_id,
|
||||
created_at: ctx.timestamp,
|
||||
synced: false,
|
||||
});
|
||||
|
||||
log::info!("Melding sendt");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Laster meldinger fra PostgreSQL ved oppvarming.
|
||||
/// Kalles av sync-worker, ikke av klienter direkte.
|
||||
#[reducer]
|
||||
pub fn load_messages(
|
||||
ctx: &ReducerContext,
|
||||
messages_json: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let count = messages_json.len();
|
||||
for json_str in messages_json {
|
||||
// Enkel parsing — sync-worker sender ferdig-formaterte meldinger
|
||||
let parts: Vec<&str> = json_str.splitn(8, '|').collect();
|
||||
if parts.len() < 8 {
|
||||
log::warn!("Ugyldig melding ved oppvarming: {}", json_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.db.chat_message().insert(ChatMessage {
|
||||
id: parts[0].to_string(),
|
||||
channel_id: parts[1].to_string(),
|
||||
workspace_id: parts[2].to_string(),
|
||||
author_id: parts[3].to_string(),
|
||||
author_name: parts[4].to_string(),
|
||||
body: parts[5].to_string(),
|
||||
message_type: parts[6].to_string(),
|
||||
reply_to: parts[7].to_string(),
|
||||
created_at: ctx.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
log::info!("Oppvarming fullført: {} meldinger lastet", count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Markerer sync-outbox-events som synket.
|
||||
/// Kalles av sync-worker etter vellykket PG-skriving.
|
||||
#[reducer]
|
||||
pub fn mark_synced(ctx: &ReducerContext, ids: Vec<u64>) -> Result<(), String> {
|
||||
for id in &ids {
|
||||
if let Some(mut entry) = ctx.db.sync_outbox().id().find(*id) {
|
||||
entry.synced = true;
|
||||
ctx.db.sync_outbox().id().update(entry);
|
||||
}
|
||||
}
|
||||
log::info!("{} sync-events markert som synket", ids.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Livssyklus ===
|
||||
|
||||
#[reducer(client_connected)]
|
||||
pub fn client_connected(ctx: &ReducerContext) {
|
||||
log::info!("Klient tilkoblet: {}", ctx.sender.to_hex());
|
||||
}
|
||||
|
||||
#[reducer(client_disconnected)]
|
||||
pub fn client_disconnected(ctx: &ReducerContext) {
|
||||
log::info!("Klient frakoblet: {}", ctx.sender.to_hex());
|
||||
}
|
||||
5
web/.gitignore
vendored
Normal file
5
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
.env
|
||||
.env.local
|
||||
2145
web/package-lock.json
generated
Normal file
2145
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
web/package.json
Normal file
27
web/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "sidelinja-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.41.1",
|
||||
"@auth/sveltekit": "^1.11.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"postgres": "^3.4.8",
|
||||
"spacetimedb": "^2.0.4",
|
||||
"svelte": "^5.53.12",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"svelte-check": "^4.4.5"
|
||||
}
|
||||
}
|
||||
24
web/src/app.d.ts
vendored
Normal file
24
web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Workspace } from '$lib/server/db';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
/** Innlogget bruker fra Authentik (satt av Auth.js) */
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string;
|
||||
} | null;
|
||||
/** Aktiv workspace (satt av hooks basert på cookie) */
|
||||
workspace: Workspace | null;
|
||||
}
|
||||
interface PageData {
|
||||
user: App.Locals['user'];
|
||||
workspace: App.Locals['workspace'];
|
||||
workspaces: Workspace[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
web/src/app.html
Normal file
12
web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
66
web/src/hooks.server.ts
Normal file
66
web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { handle as authHandle } from '$lib/server/auth';
|
||||
import { getWorkspaceForUser, getUserWorkspaces } from '$lib/server/db';
|
||||
|
||||
const WORKSPACE_COOKIE = 'sidelinja_workspace';
|
||||
const isDev = env.NODE_ENV !== 'production' && !env.AUTHENTIK_CLIENT_ID;
|
||||
|
||||
/**
|
||||
* Dev-only auth: simulerer innlogget bruker uten OIDC.
|
||||
* Setter locals.auth() til å returnere en fast dev-bruker.
|
||||
*/
|
||||
const devAuthHandle: Handle = async ({ event, resolve }) => {
|
||||
const DEV_USER = { id: 'dev-user-1', name: 'Vegard', email: 'vegard@localhost' };
|
||||
|
||||
// Legg til auth()-metode som hooks og layout forventer
|
||||
event.locals.auth = async () => ({ user: DEV_USER, expires: '' });
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Workspace-resolving: les aktiv workspace fra cookie,
|
||||
* verifiser tilgang, og legg i locals.
|
||||
*/
|
||||
const workspaceHandle: Handle = async ({ event, resolve }) => {
|
||||
const session = await event.locals.auth();
|
||||
const user = session?.user;
|
||||
|
||||
if (user?.id) {
|
||||
event.locals.user = {
|
||||
id: user.id,
|
||||
name: user.name ?? '',
|
||||
email: user.email ?? '',
|
||||
image: user.image ?? undefined
|
||||
};
|
||||
|
||||
const workspaceId = event.cookies.get(WORKSPACE_COOKIE);
|
||||
if (workspaceId) {
|
||||
event.locals.workspace = await getWorkspaceForUser(workspaceId, user.id);
|
||||
}
|
||||
|
||||
// Hvis ingen gyldig workspace i cookie, velg den første
|
||||
if (!event.locals.workspace) {
|
||||
const workspaces = await getUserWorkspaces(user.id);
|
||||
if (workspaces.length > 0) {
|
||||
event.locals.workspace = workspaces[0];
|
||||
event.cookies.set(WORKSPACE_COOKIE, workspaces[0].id, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 365
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
event.locals.workspace = null;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handle = isDev
|
||||
? sequence(devAuthHandle, workspaceHandle)
|
||||
: sequence(authHandle, workspaceHandle);
|
||||
25
web/src/lib/blocks/CalendarBlock.svelte
Normal file
25
web/src/lib/blocks/CalendarBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="icon">📅</span>
|
||||
<p class="label">Kalender</p>
|
||||
<p class="hint">Kommer snart</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: #8b92a5;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.icon { font-size: 2rem; }
|
||||
.label { font-weight: 600; color: #e1e4e8; }
|
||||
.hint { font-size: 0.8rem; }
|
||||
</style>
|
||||
290
web/src/lib/blocks/ChatBlock.svelte
Normal file
290
web/src/lib/blocks/ChatBlock.svelte
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { createChat } from '$lib/chat/create.svelte';
|
||||
import type { Message, ChatConnection } from '$lib/chat/types';
|
||||
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
|
||||
const channelId = props.channelId as string | undefined;
|
||||
|
||||
let chat = $state<ChatConnection | null>(null);
|
||||
let input = $state('');
|
||||
let sending = $state(false);
|
||||
let messagesEl: HTMLDivElement | undefined;
|
||||
let inputEl: HTMLTextAreaElement | undefined;
|
||||
|
||||
async function send() {
|
||||
if (!chat || !input.trim() || sending) return;
|
||||
const body = input.trim();
|
||||
input = '';
|
||||
sending = true;
|
||||
try {
|
||||
await chat.send(body);
|
||||
scrollToBottom();
|
||||
} finally {
|
||||
sending = false;
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEl?.scrollTo(0, messagesEl.scrollHeight);
|
||||
});
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const today = new Date();
|
||||
if (d.toDateString() === today.toDateString()) return 'I dag';
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return 'I går';
|
||||
return d.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
let messages = $derived(chat?.messages ?? []);
|
||||
let prevCount = 0;
|
||||
|
||||
$effect(() => {
|
||||
const count = messages.length;
|
||||
if (count > prevCount) {
|
||||
scrollToBottom();
|
||||
}
|
||||
prevCount = count;
|
||||
});
|
||||
|
||||
let grouped = $derived.by(() => {
|
||||
const groups: { date: string; messages: Message[] }[] = [];
|
||||
let currentDate = '';
|
||||
for (const msg of messages) {
|
||||
const date = formatDate(msg.created_at);
|
||||
if (date !== currentDate) {
|
||||
currentDate = date;
|
||||
groups.push({ date, messages: [] });
|
||||
}
|
||||
groups[groups.length - 1].messages.push(msg);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (channelId) {
|
||||
const user = $page.data.user;
|
||||
chat = createChat(channelId, {
|
||||
id: user?.id ?? 'anonymous',
|
||||
name: user?.name ?? 'Ukjent'
|
||||
});
|
||||
}
|
||||
return () => chat?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !channelId}
|
||||
<div class="no-channel">
|
||||
<p>Ingen kanal konfigurert for denne blokken.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="chat">
|
||||
<div class="messages" bind:this={messagesEl}>
|
||||
{#each grouped as group}
|
||||
<div class="date-divider">
|
||||
<span>{group.date}</span>
|
||||
</div>
|
||||
{#each group.messages as msg (msg.id)}
|
||||
<div class="message">
|
||||
<div class="message-header">
|
||||
<span class="author">{msg.author_name ?? 'Ukjent'}</span>
|
||||
<span class="time">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<div class="message-body">{msg.body}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#if messages.length === 0 && !chat?.error}
|
||||
<div class="empty">Ingen meldinger ennå. Skriv noe!</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chat?.error}
|
||||
<div class="error">{chat.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="input-row">
|
||||
<textarea
|
||||
bind:this={inputEl}
|
||||
bind:value={input}
|
||||
onkeydown={onKeydown}
|
||||
placeholder="Skriv en melding..."
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
onclick={send}
|
||||
disabled={sending || !input.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.date-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.date-divider span {
|
||||
font-size: 0.7rem;
|
||||
color: #8b92a5;
|
||||
background: #0f1117;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.65rem;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
font-size: 0.85rem;
|
||||
color: #e1e4e8;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: #8b92a5;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.75rem;
|
||||
color: #f87171;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #2d3148;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #e1e4e8;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.input-row button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-row button:disabled {
|
||||
background: #1e2235;
|
||||
color: #8b92a5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-row button:not(:disabled):hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.no-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #8b92a5;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
25
web/src/lib/blocks/GraphBlock.svelte
Normal file
25
web/src/lib/blocks/GraphBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="icon">🕸️</span>
|
||||
<p class="label">Graph</p>
|
||||
<p class="hint">Kommer snart</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: #8b92a5;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.icon { font-size: 2rem; }
|
||||
.label { font-weight: 600; color: #e1e4e8; }
|
||||
.hint { font-size: 0.8rem; }
|
||||
</style>
|
||||
25
web/src/lib/blocks/KanbanBlock.svelte
Normal file
25
web/src/lib/blocks/KanbanBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="icon">📋</span>
|
||||
<p class="label">Kanban</p>
|
||||
<p class="hint">Kommer snart</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: #8b92a5;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.icon { font-size: 2rem; }
|
||||
.label { font-weight: 600; color: #e1e4e8; }
|
||||
.hint { font-size: 0.8rem; }
|
||||
</style>
|
||||
25
web/src/lib/blocks/NotesBlock.svelte
Normal file
25
web/src/lib/blocks/NotesBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="icon">📝</span>
|
||||
<p class="label">Notes</p>
|
||||
<p class="hint">Kommer snart</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: #8b92a5;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.icon { font-size: 2rem; }
|
||||
.label { font-weight: 600; color: #e1e4e8; }
|
||||
.hint { font-size: 0.8rem; }
|
||||
</style>
|
||||
25
web/src/lib/blocks/ResearchBlock.svelte
Normal file
25
web/src/lib/blocks/ResearchBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="icon">🔍</span>
|
||||
<p class="label">Research</p>
|
||||
<p class="hint">Kommer snart</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: #8b92a5;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.icon { font-size: 2rem; }
|
||||
.label { font-weight: 600; color: #e1e4e8; }
|
||||
.hint { font-size: 0.8rem; }
|
||||
</style>
|
||||
25
web/src/lib/blocks/StatsBlock.svelte
Normal file
25
web/src/lib/blocks/StatsBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="icon">📊</span>
|
||||
<p class="label">Stats</p>
|
||||
<p class="hint">Kommer snart</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: #8b92a5;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.icon { font-size: 2rem; }
|
||||
.label { font-weight: 600; color: #e1e4e8; }
|
||||
.hint { font-size: 0.8rem; }
|
||||
</style>
|
||||
50
web/src/lib/blocks/registry.ts
Normal file
50
web/src/lib/blocks/registry.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { Component } from 'svelte';
|
||||
|
||||
export interface BlockMeta {
|
||||
label: string;
|
||||
icon: string;
|
||||
component: () => Promise<{ default: Component }>;
|
||||
}
|
||||
|
||||
export const blockRegistry: Record<string, BlockMeta> = {
|
||||
chat: {
|
||||
label: 'Chat',
|
||||
icon: '💬',
|
||||
component: () => import('./ChatBlock.svelte')
|
||||
},
|
||||
kanban: {
|
||||
label: 'Kanban',
|
||||
icon: '📋',
|
||||
component: () => import('./KanbanBlock.svelte')
|
||||
},
|
||||
stats: {
|
||||
label: 'Statistikk',
|
||||
icon: '📊',
|
||||
component: () => import('./StatsBlock.svelte')
|
||||
},
|
||||
graph: {
|
||||
label: 'Kunnskapsgraf',
|
||||
icon: '🕸️',
|
||||
component: () => import('./GraphBlock.svelte')
|
||||
},
|
||||
research: {
|
||||
label: 'Research',
|
||||
icon: '🔍',
|
||||
component: () => import('./ResearchBlock.svelte')
|
||||
},
|
||||
notes: {
|
||||
label: 'Notater',
|
||||
icon: '📝',
|
||||
component: () => import('./NotesBlock.svelte')
|
||||
},
|
||||
calendar: {
|
||||
label: 'Kalender',
|
||||
icon: '📅',
|
||||
component: () => import('./CalendarBlock.svelte')
|
||||
}
|
||||
};
|
||||
|
||||
export const blockTypes = Object.entries(blockRegistry).map(([value, meta]) => ({
|
||||
value,
|
||||
label: `${meta.icon} ${meta.label}`
|
||||
}));
|
||||
22
web/src/lib/chat/create.svelte.ts
Normal file
22
web/src/lib/chat/create.svelte.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { ChatConnection, ChatUser } from './types';
|
||||
import { createPgChat } from './pg.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { createSpacetimeChat } from './spacetime.svelte';
|
||||
|
||||
/**
|
||||
* Factory som velger chat-adapter basert på konfigurasjon.
|
||||
*
|
||||
* Når VITE_SPACETIMEDB_URL er satt, brukes hybrid-adapter
|
||||
* (PG for historikk + SpacetimeDB for sanntid).
|
||||
* Ellers ren PG-polling.
|
||||
*/
|
||||
export function createChat(channelId: string, user: ChatUser): ChatConnection {
|
||||
if (browser) {
|
||||
const spacetimeUrl = import.meta.env.VITE_SPACETIMEDB_URL;
|
||||
if (spacetimeUrl) {
|
||||
return createSpacetimeChat(channelId, spacetimeUrl, 'sidelinja-realtime', user);
|
||||
}
|
||||
}
|
||||
|
||||
return createPgChat(channelId);
|
||||
}
|
||||
2
web/src/lib/chat/index.ts
Normal file
2
web/src/lib/chat/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type { Message, ChatConnection, ChatUser } from './types';
|
||||
export { createChat } from './create.svelte';
|
||||
23
web/src/lib/chat/module_bindings/chat_message_table.ts
Normal file
23
web/src/lib/chat/module_bindings/chat_message_table.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
id: __t.string().primaryKey(),
|
||||
channelId: __t.string().name("channel_id"),
|
||||
workspaceId: __t.string().name("workspace_id"),
|
||||
authorId: __t.string().name("author_id"),
|
||||
authorName: __t.string().name("author_name"),
|
||||
body: __t.string(),
|
||||
messageType: __t.string().name("message_type"),
|
||||
replyTo: __t.string().name("reply_to"),
|
||||
createdAt: __t.timestamp().name("created_at"),
|
||||
});
|
||||
135
web/src/lib/chat/module_bindings/index.ts
Normal file
135
web/src/lib/chat/module_bindings/index.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.0.5 (commit d60138999206c06c776829072f46b5d1c1101f7e).
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
DbConnectionBuilder as __DbConnectionBuilder,
|
||||
DbConnectionImpl as __DbConnectionImpl,
|
||||
SubscriptionBuilderImpl as __SubscriptionBuilderImpl,
|
||||
TypeBuilder as __TypeBuilder,
|
||||
Uuid as __Uuid,
|
||||
convertToAccessorMap as __convertToAccessorMap,
|
||||
makeQueryBuilder as __makeQueryBuilder,
|
||||
procedureSchema as __procedureSchema,
|
||||
procedures as __procedures,
|
||||
reducerSchema as __reducerSchema,
|
||||
reducers as __reducers,
|
||||
schema as __schema,
|
||||
t as __t,
|
||||
table as __table,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type DbConnectionConfig as __DbConnectionConfig,
|
||||
type ErrorContextInterface as __ErrorContextInterface,
|
||||
type Event as __Event,
|
||||
type EventContextInterface as __EventContextInterface,
|
||||
type Infer as __Infer,
|
||||
type QueryBuilder as __QueryBuilder,
|
||||
type ReducerEventContextInterface as __ReducerEventContextInterface,
|
||||
type RemoteModule as __RemoteModule,
|
||||
type SubscriptionEventContextInterface as __SubscriptionEventContextInterface,
|
||||
type SubscriptionHandleImpl as __SubscriptionHandleImpl,
|
||||
} from "spacetimedb";
|
||||
|
||||
// Import all reducer arg schemas
|
||||
import LoadMessagesReducer from "./load_messages_reducer";
|
||||
import MarkSyncedReducer from "./mark_synced_reducer";
|
||||
import SendMessageReducer from "./send_message_reducer";
|
||||
|
||||
// Import all procedure arg schemas
|
||||
|
||||
// Import all table schema definitions
|
||||
import ChatMessageRow from "./chat_message_table";
|
||||
import SyncOutboxRow from "./sync_outbox_table";
|
||||
|
||||
/** Type-only namespace exports for generated type groups. */
|
||||
|
||||
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
|
||||
const tablesSchema = __schema({
|
||||
chat_message: __table({
|
||||
name: 'chat_message',
|
||||
indexes: [
|
||||
{ accessor: 'id', name: 'chat_message_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
],
|
||||
constraints: [
|
||||
{ name: 'chat_message_id_key', constraint: 'unique', columns: ['id'] },
|
||||
],
|
||||
}, ChatMessageRow),
|
||||
sync_outbox: __table({
|
||||
name: 'sync_outbox',
|
||||
indexes: [
|
||||
{ accessor: 'id', name: 'sync_outbox_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
],
|
||||
constraints: [
|
||||
{ name: 'sync_outbox_id_key', constraint: 'unique', columns: ['id'] },
|
||||
],
|
||||
}, SyncOutboxRow),
|
||||
});
|
||||
|
||||
/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */
|
||||
const reducersSchema = __reducers(
|
||||
__reducerSchema("load_messages", LoadMessagesReducer),
|
||||
__reducerSchema("mark_synced", MarkSyncedReducer),
|
||||
__reducerSchema("send_message", SendMessageReducer),
|
||||
);
|
||||
|
||||
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */
|
||||
const proceduresSchema = __procedures(
|
||||
);
|
||||
|
||||
/** The remote SpacetimeDB module schema, both runtime and type information. */
|
||||
const REMOTE_MODULE = {
|
||||
versionInfo: {
|
||||
cliVersion: "2.0.5" as const,
|
||||
},
|
||||
tables: tablesSchema.schemaType.tables,
|
||||
reducers: reducersSchema.reducersType.reducers,
|
||||
...proceduresSchema,
|
||||
} satisfies __RemoteModule<
|
||||
typeof tablesSchema.schemaType,
|
||||
typeof reducersSchema.reducersType,
|
||||
typeof proceduresSchema
|
||||
>;
|
||||
|
||||
/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */
|
||||
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> = __makeQueryBuilder(tablesSchema.schemaType);
|
||||
|
||||
/** The reducers available in this remote SpacetimeDB module. */
|
||||
export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);
|
||||
|
||||
/** The context type returned in callbacks for all possible events. */
|
||||
export type EventContext = __EventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for reducer events. */
|
||||
export type ReducerEventContext = __ReducerEventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for subscription events. */
|
||||
export type SubscriptionEventContext = __SubscriptionEventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for error events. */
|
||||
export type ErrorContext = __ErrorContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */
|
||||
export type SubscriptionHandle = __SubscriptionHandleImpl<typeof REMOTE_MODULE>;
|
||||
|
||||
/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */
|
||||
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<typeof REMOTE_MODULE> {}
|
||||
|
||||
/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */
|
||||
export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
|
||||
|
||||
/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */
|
||||
export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
|
||||
/** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */
|
||||
static builder = (): DbConnectionBuilder => {
|
||||
return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig<typeof REMOTE_MODULE>) => new DbConnection(config));
|
||||
};
|
||||
|
||||
/** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */
|
||||
override subscriptionBuilder = (): SubscriptionBuilder => {
|
||||
return new SubscriptionBuilder(this);
|
||||
};
|
||||
}
|
||||
|
||||
15
web/src/lib/chat/module_bindings/load_messages_reducer.ts
Normal file
15
web/src/lib/chat/module_bindings/load_messages_reducer.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default {
|
||||
messagesJson: __t.array(__t.string()),
|
||||
};
|
||||
15
web/src/lib/chat/module_bindings/mark_synced_reducer.ts
Normal file
15
web/src/lib/chat/module_bindings/mark_synced_reducer.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default {
|
||||
ids: __t.array(__t.u64()),
|
||||
};
|
||||
20
web/src/lib/chat/module_bindings/send_message_reducer.ts
Normal file
20
web/src/lib/chat/module_bindings/send_message_reducer.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default {
|
||||
id: __t.string(),
|
||||
channelId: __t.string(),
|
||||
workspaceId: __t.string(),
|
||||
authorName: __t.string(),
|
||||
body: __t.string(),
|
||||
replyTo: __t.string(),
|
||||
};
|
||||
21
web/src/lib/chat/module_bindings/sync_outbox_table.ts
Normal file
21
web/src/lib/chat/module_bindings/sync_outbox_table.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
id: __t.u64().primaryKey(),
|
||||
tableName: __t.string().name("table_name"),
|
||||
action: __t.string(),
|
||||
payload: __t.string(),
|
||||
workspaceId: __t.string().name("workspace_id"),
|
||||
createdAt: __t.timestamp().name("created_at"),
|
||||
synced: __t.bool(),
|
||||
});
|
||||
36
web/src/lib/chat/module_bindings/types.ts
Normal file
36
web/src/lib/chat/module_bindings/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export const ChatMessage = __t.object("ChatMessage", {
|
||||
id: __t.string(),
|
||||
channelId: __t.string(),
|
||||
workspaceId: __t.string(),
|
||||
authorId: __t.string(),
|
||||
authorName: __t.string(),
|
||||
body: __t.string(),
|
||||
messageType: __t.string(),
|
||||
replyTo: __t.string(),
|
||||
createdAt: __t.timestamp(),
|
||||
});
|
||||
export type ChatMessage = __Infer<typeof ChatMessage>;
|
||||
|
||||
export const SyncOutbox = __t.object("SyncOutbox", {
|
||||
id: __t.u64(),
|
||||
tableName: __t.string(),
|
||||
action: __t.string(),
|
||||
payload: __t.string(),
|
||||
workspaceId: __t.string(),
|
||||
createdAt: __t.timestamp(),
|
||||
synced: __t.bool(),
|
||||
});
|
||||
export type SyncOutbox = __Infer<typeof SyncOutbox>;
|
||||
|
||||
10
web/src/lib/chat/module_bindings/types/procedures.ts
Normal file
10
web/src/lib/chat/module_bindings/types/procedures.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import { type Infer as __Infer } from "spacetimedb";
|
||||
|
||||
// Import all procedure arg schemas
|
||||
|
||||
|
||||
16
web/src/lib/chat/module_bindings/types/reducers.ts
Normal file
16
web/src/lib/chat/module_bindings/types/reducers.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import { type Infer as __Infer } from "spacetimedb";
|
||||
|
||||
// Import all reducer arg schemas
|
||||
import LoadMessagesReducer from "../load_messages_reducer";
|
||||
import MarkSyncedReducer from "../mark_synced_reducer";
|
||||
import SendMessageReducer from "../send_message_reducer";
|
||||
|
||||
export type LoadMessagesParams = __Infer<typeof LoadMessagesReducer>;
|
||||
export type MarkSyncedParams = __Infer<typeof MarkSyncedReducer>;
|
||||
export type SendMessageParams = __Infer<typeof SendMessageReducer>;
|
||||
|
||||
60
web/src/lib/chat/pg.svelte.ts
Normal file
60
web/src/lib/chat/pg.svelte.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { Message, ChatConnection } from './types';
|
||||
|
||||
/**
|
||||
* Chat-adapter som poller PostgreSQL via REST API.
|
||||
* Brukes som fallback når SpacetimeDB ikke er tilgjengelig,
|
||||
* og som referanseimplementasjon for testing.
|
||||
*/
|
||||
export function createPgChat(channelId: string): ChatConnection {
|
||||
let messages = $state<Message[]>([]);
|
||||
let error = $state('');
|
||||
let connected = $state(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
async function refresh() {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`);
|
||||
if (!res.ok) throw new Error('Feil ved lasting');
|
||||
messages = await res.json();
|
||||
error = '';
|
||||
connected = true;
|
||||
} catch {
|
||||
error = 'Kunne ikke laste meldinger';
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function send(body: string) {
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
if (!res.ok) throw new Error('Feil ved sending');
|
||||
await refresh();
|
||||
} catch {
|
||||
error = 'Kunne ikke sende melding';
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
|
||||
// Start polling
|
||||
refresh();
|
||||
timer = setInterval(refresh, 3000);
|
||||
|
||||
return {
|
||||
get messages() { return messages; },
|
||||
get error() { return error; },
|
||||
get connected() { return connected; },
|
||||
send,
|
||||
destroy
|
||||
};
|
||||
}
|
||||
163
web/src/lib/chat/spacetime.svelte.ts
Normal file
163
web/src/lib/chat/spacetime.svelte.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import type { Message, ChatConnection, ChatUser } from './types';
|
||||
import { DbConnection, type EventContext } from './module_bindings';
|
||||
|
||||
/**
|
||||
* Hybrid chat-adapter:
|
||||
* - Henter eksisterende meldinger fra PostgreSQL via REST (som PG-adapteren)
|
||||
* - Lytter på nye meldinger i sanntid via SpacetimeDB WebSocket
|
||||
* - Sender nye meldinger via SpacetimeDB reducer (→ synkes til PG av worker)
|
||||
*
|
||||
* Ingen oppvarming nødvendig — PG har historikken, SpacetimeDB har sanntid.
|
||||
*/
|
||||
export function createSpacetimeChat(
|
||||
channelId: string,
|
||||
spacetimeUrl: string,
|
||||
moduleName: string,
|
||||
user: ChatUser
|
||||
): ChatConnection {
|
||||
let messages = $state<Message[]>([]);
|
||||
let error = $state('');
|
||||
let connected = $state(false);
|
||||
let conn: InstanceType<typeof DbConnection> | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
// Hent historikk fra PG
|
||||
async function loadFromPg() {
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`);
|
||||
if (!res.ok) throw new Error('Feil ved lasting');
|
||||
messages = await res.json();
|
||||
} catch {
|
||||
error = 'Kunne ikke laste meldinger';
|
||||
}
|
||||
}
|
||||
|
||||
// Koble til SpacetimeDB for sanntidsoppdateringer
|
||||
function connectRealtime() {
|
||||
try {
|
||||
conn = DbConnection.builder()
|
||||
.withUri(spacetimeUrl)
|
||||
.withDatabaseName(moduleName)
|
||||
.onConnect((connection) => {
|
||||
if (destroyed) return;
|
||||
connected = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('spacetime_token', '');
|
||||
} catch { /* SSR-safe */ }
|
||||
|
||||
// Abonner på meldinger for denne kanalen
|
||||
connection.subscriptionBuilder()
|
||||
.onError(() => {
|
||||
console.error('[spacetime] subscription error');
|
||||
})
|
||||
.subscribe([
|
||||
`SELECT * FROM chat_message WHERE channel_id = '${channelId}'`
|
||||
]);
|
||||
})
|
||||
.onDisconnect(() => {
|
||||
connected = false;
|
||||
})
|
||||
.onConnectError((_ctx, err) => {
|
||||
console.warn('[spacetime] connection error, PG-data beholdes:', err);
|
||||
// Beholder PG-data — ingen error til bruker
|
||||
})
|
||||
.withToken(getStoredToken() ?? '')
|
||||
.build();
|
||||
|
||||
// Nye meldinger i sanntid
|
||||
conn.db.chat_message.onInsert((ctx: EventContext, row) => {
|
||||
if (destroyed) return;
|
||||
if (row.channelId !== channelId) return;
|
||||
// Dedupliser mot PG-data
|
||||
if (messages.some(m => m.id === row.id)) return;
|
||||
|
||||
const msg = spacetimeRowToMessage(row);
|
||||
messages = [...messages, msg];
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[spacetime] setup feilet, bruker kun PG:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function spacetimeRowToMessage(row: any): Message {
|
||||
let createdAt: string;
|
||||
try {
|
||||
const micros = row.createdAt?.microsSinceEpoch;
|
||||
const ms = typeof micros === 'bigint' ? Number(micros / 1000n) : Number(micros) / 1000;
|
||||
createdAt = new Date(ms).toISOString();
|
||||
} catch {
|
||||
createdAt = new Date().toISOString();
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
body: row.body,
|
||||
message_type: row.messageType,
|
||||
created_at: createdAt,
|
||||
author_name: row.authorName || null,
|
||||
author_id: row.authorId || null,
|
||||
reply_to: row.replyTo || null
|
||||
};
|
||||
}
|
||||
|
||||
async function send(body: string) {
|
||||
if (conn && connected) {
|
||||
// Send via SpacetimeDB — umiddelbar push til alle klienter
|
||||
try {
|
||||
conn.reducers.sendMessage({
|
||||
id: crypto.randomUUID(),
|
||||
channelId,
|
||||
workspaceId: '',
|
||||
authorName: user.name,
|
||||
body,
|
||||
replyTo: ''
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Fall gjennom til PG
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: send via PG REST API
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
if (!res.ok) throw new Error('Feil ved sending');
|
||||
await loadFromPg();
|
||||
} catch {
|
||||
error = 'Kunne ikke sende melding';
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
if (conn) {
|
||||
conn.disconnect();
|
||||
conn = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredToken(): string | undefined {
|
||||
try {
|
||||
return sessionStorage.getItem('spacetime_token') ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Start begge deler parallelt
|
||||
loadFromPg();
|
||||
connectRealtime();
|
||||
|
||||
return {
|
||||
get messages() { return messages; },
|
||||
get error() { return error; },
|
||||
get connected() { return connected; },
|
||||
send,
|
||||
destroy
|
||||
};
|
||||
}
|
||||
27
web/src/lib/chat/types.ts
Normal file
27
web/src/lib/chat/types.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export interface ChatUser {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
body: string;
|
||||
message_type: string;
|
||||
created_at: string;
|
||||
author_name: string | null;
|
||||
author_id: string | null;
|
||||
reply_to: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Felles kontrakt for chat-tilkoblinger.
|
||||
* Implementeres av PG-polling og SpacetimeDB.
|
||||
* Alle felter er reaktive (Svelte 5 $state).
|
||||
*/
|
||||
export interface ChatConnection {
|
||||
readonly messages: Message[];
|
||||
readonly error: string;
|
||||
readonly connected: boolean;
|
||||
send(body: string): Promise<void>;
|
||||
destroy(): void;
|
||||
}
|
||||
48
web/src/lib/components/BlockShell.svelte
Normal file
48
web/src/lib/components/BlockShell.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { title, children }: { title: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="block-shell">
|
||||
<div class="block-header">
|
||||
<span class="block-title">{title}</span>
|
||||
</div>
|
||||
<div class="block-content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block-shell {
|
||||
background: #161822;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid #2d3148;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #8b92a5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.block-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
61
web/src/lib/components/PageGrid.svelte
Normal file
61
web/src/lib/components/PageGrid.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import type { PageConfig } from '$lib/types/pages';
|
||||
import { getGridColumns } from '$lib/types/pages';
|
||||
import { blockRegistry } from '$lib/blocks/registry';
|
||||
import BlockShell from './BlockShell.svelte';
|
||||
|
||||
let { page }: { page: PageConfig } = $props();
|
||||
|
||||
let resolved = $state<Record<string, Component>>({});
|
||||
|
||||
$effect(() => {
|
||||
for (const block of page.blocks) {
|
||||
const meta = blockRegistry[block.type];
|
||||
if (meta && !resolved[block.id]) {
|
||||
meta.component().then((m) => {
|
||||
resolved[block.id] = m.default;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let gridColumns = $derived(getGridColumns(page.layout));
|
||||
</script>
|
||||
|
||||
<div class="page-grid" style:grid-template-columns={gridColumns}>
|
||||
{#each page.blocks as block (block.id)}
|
||||
<BlockShell title={block.title}>
|
||||
{@const BlockComponent = resolved[block.id]}
|
||||
{#if BlockComponent}
|
||||
<BlockComponent props={block.props ?? {}} />
|
||||
{:else}
|
||||
<div class="loading">Laster...</div>
|
||||
{/if}
|
||||
</BlockShell>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 48px - 3rem);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #8b92a5;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
282
web/src/lib/components/Sidebar.svelte
Normal file
282
web/src/lib/components/Sidebar.svelte
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<script lang="ts">
|
||||
import type { Workspace } from '$lib/server/db';
|
||||
import type { PageConfig } from '$lib/types/pages';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
user,
|
||||
workspace,
|
||||
workspaces,
|
||||
authProvider
|
||||
}: {
|
||||
open: boolean;
|
||||
user: { id: string; name: string; email: string; image?: string };
|
||||
workspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
authProvider: string;
|
||||
} = $props();
|
||||
|
||||
// Swipe-fra-venstre detection
|
||||
let touchStartX = 0;
|
||||
let touchCurrentX = 0;
|
||||
let swiping = $state(false);
|
||||
let swipeOffset = $state(0);
|
||||
|
||||
const SIDEBAR_WIDTH = 280;
|
||||
const EDGE_THRESHOLD = 30;
|
||||
const SWIPE_THRESHOLD = 80;
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
const x = e.touches[0].clientX;
|
||||
// Åpne: swipe fra venstre kant
|
||||
if (!open && x < EDGE_THRESHOLD) {
|
||||
swiping = true;
|
||||
touchStartX = x;
|
||||
touchCurrentX = x;
|
||||
swipeOffset = 0;
|
||||
}
|
||||
// Lukke: swipe tilbake mot venstre
|
||||
if (open) {
|
||||
swiping = true;
|
||||
touchStartX = x;
|
||||
touchCurrentX = x;
|
||||
swipeOffset = SIDEBAR_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!swiping) return;
|
||||
touchCurrentX = e.touches[0].clientX;
|
||||
const delta = touchCurrentX - touchStartX;
|
||||
|
||||
if (!open) {
|
||||
// Åpner: offset 0 → SIDEBAR_WIDTH
|
||||
swipeOffset = Math.max(0, Math.min(SIDEBAR_WIDTH, delta));
|
||||
} else {
|
||||
// Lukker: offset SIDEBAR_WIDTH → 0
|
||||
swipeOffset = Math.max(0, Math.min(SIDEBAR_WIDTH, SIDEBAR_WIDTH + delta));
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!swiping) return;
|
||||
swiping = false;
|
||||
|
||||
if (!open && swipeOffset > SWIPE_THRESHOLD) {
|
||||
open = true;
|
||||
} else if (open && swipeOffset < SIDEBAR_WIDTH - SWIPE_THRESHOLD) {
|
||||
open = false;
|
||||
}
|
||||
swipeOffset = 0;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
let pages = $derived<PageConfig[]>(
|
||||
((workspace?.settings as Record<string, unknown>)?.pages as PageConfig[]) ?? []
|
||||
);
|
||||
|
||||
// Beregn transform basert på state
|
||||
let sidebarTransform = $derived.by(() => {
|
||||
if (swiping) {
|
||||
return `translateX(${swipeOffset - SIDEBAR_WIDTH}px)`;
|
||||
}
|
||||
return open ? 'translateX(0)' : `translateX(-100%)`;
|
||||
});
|
||||
|
||||
let backdropOpacity = $derived.by(() => {
|
||||
if (swiping) {
|
||||
return swipeOffset / SIDEBAR_WIDTH;
|
||||
}
|
||||
return open ? 1 : 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeydown} />
|
||||
<svelte:document
|
||||
on:touchstart={onTouchStart}
|
||||
on:touchmove={onTouchMove}
|
||||
on:touchend={onTouchEnd}
|
||||
/>
|
||||
|
||||
{#if open || swiping}
|
||||
<button
|
||||
class="backdrop"
|
||||
class:no-transition={swiping}
|
||||
style:opacity={backdropOpacity}
|
||||
onclick={() => (open = false)}
|
||||
aria-label="Lukk meny"
|
||||
tabindex="-1"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<nav
|
||||
class="sidebar"
|
||||
class:no-transition={swiping}
|
||||
style:transform={sidebarTransform}
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title-row">
|
||||
<h1>Sidelinja</h1>
|
||||
<button class="close-btn" onclick={() => (open = false)} aria-label="Lukk meny">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<WorkspaceSwitcher {workspaces} active={workspace} />
|
||||
</div>
|
||||
|
||||
<ul class="nav-links">
|
||||
<li><a href="/" onclick={() => (open = false)}>Oversikt</a></li>
|
||||
{#each pages as page}
|
||||
<li>
|
||||
<a href="/p/{page.slug}" onclick={() => (open = false)}>
|
||||
{#if page.icon}<span class="nav-icon">{page.icon}</span>{/if}
|
||||
{page.title}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="nav-divider"></li>
|
||||
<li><a href="/admin/pages" onclick={() => (open = false)}>Rediger sider</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<span class="user-name">{user.name}</span>
|
||||
<form method="POST" action="/auth/signout">
|
||||
<button type="submit">Logg ut</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.backdrop.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
width: 280px;
|
||||
background: #161822;
|
||||
border-right: 1px solid #2d3148;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.25s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-title-row h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #8b92a5;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #1e2235;
|
||||
color: #e1e4e8;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: #8b92a5;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background: #1e2235;
|
||||
color: #e1e4e8;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: #2d3148;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #2d3148;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.85rem;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.sidebar-footer button {
|
||||
background: none;
|
||||
border: 1px solid #2d3148;
|
||||
color: #8b92a5;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebar-footer button:hover {
|
||||
border-color: #8b92a5;
|
||||
color: #e1e4e8;
|
||||
}
|
||||
</style>
|
||||
88
web/src/lib/components/WorkspaceSwitcher.svelte
Normal file
88
web/src/lib/components/WorkspaceSwitcher.svelte
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<script lang="ts">
|
||||
import type { Workspace } from '$lib/server/db';
|
||||
|
||||
let { workspaces, active }: { workspaces: Workspace[]; active: Workspace | null } = $props();
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
{#if workspaces.length > 0}
|
||||
<div class="switcher">
|
||||
<button class="trigger" onclick={() => (open = !open)}>
|
||||
<span class="workspace-name">{active?.name ?? 'Velg workspace'}</span>
|
||||
<span class="chevron">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{#if open && workspaces.length > 1}
|
||||
<ul class="dropdown">
|
||||
{#each workspaces as ws}
|
||||
{#if ws.id !== active?.id}
|
||||
<li>
|
||||
<a
|
||||
href="/?switch_workspace={ws.id}"
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
{ws.name}
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.switcher {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1e2235;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #e1e4e8;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 0.65rem;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1e2235;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
list-style: none;
|
||||
padding: 0.25rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dropdown a {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #8b92a5;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dropdown a:hover {
|
||||
background: #262a3e;
|
||||
color: #e1e4e8;
|
||||
}
|
||||
</style>
|
||||
63
web/src/lib/server/auth.ts
Normal file
63
web/src/lib/server/auth.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||
import Credentials from '@auth/core/providers/credentials';
|
||||
import type { Provider } from '@auth/core/providers';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const isDev = env.NODE_ENV !== 'production' && !env.AUTHENTIK_CLIENT_ID;
|
||||
|
||||
/**
|
||||
* Authentik OIDC-provider (produksjon).
|
||||
*/
|
||||
const authentik: Provider = {
|
||||
id: 'authentik',
|
||||
name: 'Authentik',
|
||||
type: 'oidc',
|
||||
issuer: env.AUTHENTIK_ISSUER,
|
||||
clientId: env.AUTHENTIK_CLIENT_ID,
|
||||
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
email: profile.email,
|
||||
image: profile.picture
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dev-only credentials provider.
|
||||
* Logger inn som dev-user-1 uten OIDC.
|
||||
*/
|
||||
const devCredentials = Credentials({
|
||||
id: 'dev-login',
|
||||
name: 'Dev Login',
|
||||
credentials: {},
|
||||
authorize() {
|
||||
return {
|
||||
id: 'dev-user-1',
|
||||
name: 'Vegard',
|
||||
email: 'vegard@localhost'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||
providers: isDev ? [devCredentials] : [authentik],
|
||||
secret: env.AUTH_SECRET || 'dev-secret-not-for-production',
|
||||
trustHost: true,
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
});
|
||||
46
web/src/lib/server/db.ts
Normal file
46
web/src/lib/server/db.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import postgres from 'postgres';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const sql = postgres(env.DATABASE_URL || 'postgres://sidelinja:localdev@localhost:5432/sidelinja', {
|
||||
max: 10,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10
|
||||
});
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
domain: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Hent alle workspaces brukeren er medlem av */
|
||||
export async function getUserWorkspaces(userId: string): Promise<Workspace[]> {
|
||||
return sql<Workspace[]>`
|
||||
SELECT w.id, w.name, w.slug, w.domain, w.settings
|
||||
FROM workspaces w
|
||||
JOIN workspace_members wm ON wm.workspace_id = w.id
|
||||
WHERE wm.user_id = ${userId}
|
||||
ORDER BY w.name
|
||||
`;
|
||||
}
|
||||
|
||||
/** Hent én workspace, verifiser at brukeren har tilgang */
|
||||
export async function getWorkspaceForUser(workspaceId: string, userId: string): Promise<Workspace | null> {
|
||||
const rows = await sql<Workspace[]>`
|
||||
SELECT w.id, w.name, w.slug, w.domain, w.settings
|
||||
FROM workspaces w
|
||||
JOIN workspace_members wm ON wm.workspace_id = w.id
|
||||
WHERE w.id = ${workspaceId} AND wm.user_id = ${userId}
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sett workspace-kontekst for RLS.
|
||||
* Kall dette før spørringer som trenger workspace-isolasjon.
|
||||
*/
|
||||
export async function setWorkspaceContext(workspaceId: string) {
|
||||
await sql`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
||||
}
|
||||
28
web/src/lib/types/pages.ts
Normal file
28
web/src/lib/types/pages.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export type LayoutTemplate = '1-col' | '2-col' | '2-1' | '1-2' | '3-col';
|
||||
|
||||
export interface BlockConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PageConfig {
|
||||
slug: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
layout: LayoutTemplate;
|
||||
blocks: BlockConfig[];
|
||||
}
|
||||
|
||||
export const layoutOptions: { value: LayoutTemplate; label: string; grid: string }[] = [
|
||||
{ value: '1-col', label: '1 kolonne', grid: '1fr' },
|
||||
{ value: '2-col', label: '2 kolonner', grid: '1fr 1fr' },
|
||||
{ value: '2-1', label: '2/3 + 1/3', grid: '2fr 1fr' },
|
||||
{ value: '1-2', label: '1/3 + 2/3', grid: '1fr 2fr' },
|
||||
{ value: '3-col', label: '3 kolonner', grid: '1fr 1fr 1fr' }
|
||||
];
|
||||
|
||||
export function getGridColumns(layout: LayoutTemplate): string {
|
||||
return layoutOptions.find((o) => o.value === layout)?.grid ?? '1fr';
|
||||
}
|
||||
18
web/src/routes/+layout.server.ts
Normal file
18
web/src/routes/+layout.server.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { LayoutServerLoad } from './$types';
|
||||
import { getUserWorkspaces } from '$lib/server/db';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const isDev = env.NODE_ENV !== 'production' && !env.AUTHENTIK_CLIENT_ID;
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const workspaces = locals.user
|
||||
? await getUserWorkspaces(locals.user.id)
|
||||
: [];
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
workspace: locals.workspace,
|
||||
workspaces,
|
||||
authProvider: isDev ? 'dev-login' : 'authentik'
|
||||
};
|
||||
};
|
||||
152
web/src/routes/+layout.svelte
Normal file
152
web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
import WorkspaceSwitcher from '$lib/components/WorkspaceSwitcher.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
|
||||
let { data, children } = $props<{ data: LayoutData; children: any }>();
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sidelinja</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.user}
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<button class="hamburger" onclick={() => (sidebarOpen = true)} aria-label="Åpne meny">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="topbar-title">{data.workspace?.name ?? 'Sidelinja'}</span>
|
||||
</header>
|
||||
|
||||
<Sidebar
|
||||
bind:open={sidebarOpen}
|
||||
user={data.user}
|
||||
workspace={data.workspace}
|
||||
workspaces={data.workspaces}
|
||||
authProvider={data.authProvider}
|
||||
/>
|
||||
|
||||
<main class="content">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="login-page">
|
||||
<h1>Sidelinja</h1>
|
||||
<p>Redaksjonelt operativsystem for podcast-produksjon</p>
|
||||
<form method="POST" action="/auth/callback/credentials">
|
||||
<input type="hidden" name="csrfToken" value="" />
|
||||
{#if data.authProvider === 'dev-login'}
|
||||
<button type="submit" formaction="/auth/signin/dev-login">Dev Login</button>
|
||||
{:else}
|
||||
<button type="submit" formaction="/auth/signin/authentik">Logg inn med Authentik</button>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f1117;
|
||||
color: #e1e4e8;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
height: 48px;
|
||||
padding: 0 1rem;
|
||||
background: #161822;
|
||||
border-bottom: 1px solid #2d3148;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #8b92a5;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.hamburger:hover {
|
||||
background: #1e2235;
|
||||
color: #e1e4e8;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #e1e4e8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login-page h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.login-page p {
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.login-page button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-page button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
22
web/src/routes/+page.server.ts
Normal file
22
web/src/routes/+page.server.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
const WORKSPACE_COOKIE = 'sidelinja_workspace';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies, locals }) => {
|
||||
const switchTo = url.searchParams.get('switch_workspace');
|
||||
|
||||
if (switchTo && locals.user) {
|
||||
const { getWorkspaceForUser } = await import('$lib/server/db');
|
||||
const ws = await getWorkspaceForUser(switchTo, locals.user.id);
|
||||
if (ws) {
|
||||
cookies.set(WORKSPACE_COOKIE, ws.id, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 365
|
||||
});
|
||||
}
|
||||
redirect(303, '/');
|
||||
}
|
||||
};
|
||||
13
web/src/routes/+page.svelte
Normal file
13
web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
</script>
|
||||
|
||||
{#if data.workspace}
|
||||
<h2>{data.workspace.name}</h2>
|
||||
<p style="color: #8b92a5; margin-top: 0.5rem;">Workspace: {data.workspace.slug}</p>
|
||||
{:else}
|
||||
<h2>Ingen workspace</h2>
|
||||
<p style="color: #8b92a5; margin-top: 0.5rem;">Du er ikke medlem av noen workspace ennå.</p>
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue