Compare commits
3 commits
914598c402
...
50e26e3c48
| Author | SHA1 | Date | |
|---|---|---|---|
| 50e26e3c48 | |||
| 592ebdf1d6 | |||
| 74110e842c |
40 changed files with 4617 additions and 340 deletions
|
|
@ -36,11 +36,11 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
|
||||||
- `live_transkripsjon.md` — Whisper-pipeline (felles motor for studio/møter/fabrikk)
|
- `live_transkripsjon.md` — Whisper-pipeline (felles motor for studio/møter/fabrikk)
|
||||||
- `live_ai.md` — Live AI: faktoid-oppslag (studio) + referent (møter)
|
- `live_ai.md` — Live AI: faktoid-oppslag (studio) + referent (møter)
|
||||||
- `visuell_graf.md` — Interaktiv graf-visning
|
- `visuell_graf.md` — Interaktiv graf-visning
|
||||||
- `ai_research_klipper.md` — AI-drevet research-inntak til kunnskapsgrafen
|
|
||||||
- `lydmeldinger.md` — Lydmeldinger, diktering og tale-til-tekst
|
- `lydmeldinger.md` — Lydmeldinger, diktering og tale-til-tekst
|
||||||
- `podcast_statistikk.md` — IAB-kompatibel lytterstatistikk fra Caddy-logger
|
- `podcast_statistikk.md` — IAB-kompatibel lytterstatistikk fra Caddy-logger
|
||||||
- `kunnskaps_bridge.md` — Cross-workspace discovery via vector embeddings
|
- `kunnskaps_bridge.md` — Cross-workspace discovery via vector embeddings
|
||||||
- `prompt_lab.md` — Internt verktøy for testing og deploy av LLM-prompts
|
- `prompt_lab.md` — Internt verktøy for testing og deploy av LLM-prompts
|
||||||
|
- `brukerinnstillinger.md` — Personlige innstillinger (tema, skrift, editor-preferanser)
|
||||||
- `docs/infra/` — Infrastruktur (ikke brukersynlig):
|
- `docs/infra/` — Infrastruktur (ikke brukersynlig):
|
||||||
- `jobbkø.md` — Felles PostgreSQL-basert køsystem for alle bakgrunnsjobber
|
- `jobbkø.md` — Felles PostgreSQL-basert køsystem for alle bakgrunnsjobber
|
||||||
- `synkronisering.md` — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell
|
- `synkronisering.md` — PostgreSQL ↔ SpacetimeDB dataflyt og eierskapsmodell
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,11 @@ Vi følger et "Best tool for the job"-prinsipp, med en sterk preferanse for minn
|
||||||
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.
|
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
|
#### 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.
|
* **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 workspace-kontekst via `SET LOCAL` i en transaksjon:
|
||||||
|
```sql
|
||||||
|
BEGIN; SET LOCAL app.current_workspace_id = 'uuid'; SELECT ...; COMMIT;
|
||||||
|
```
|
||||||
|
**Viktig:** Bruk alltid `SET LOCAL` (ikke `SET`) — lokal scope forsvinner automatisk når transaksjonen avsluttes. Med vanlig `SET` overlever verdien på tilkoblingen, og ved connection pooling (PgBouncer, driver-pool) kan neste bruker arve feil workspace_id. `SET LOCAL` eliminerer denne risikoen fullstendig.
|
||||||
* **SpacetimeDB:** WebSocket-tilkoblinger bærer et `workspace_id`-token. Modulen partisjonerer minnet og kringkaster kun til klienter i samme workspace.
|
* **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.
|
* **Mediefiler:** Lagres i `/srv/sidelinja/media/{workspace_slug}/`. Caddy ruter basert på domene.
|
||||||
* **Transkripsjoner:** Ett Forgejo-repo per workspace for SRT-filer.
|
* **Transkripsjoner:** Ett Forgejo-repo per workspace for SRT-filer.
|
||||||
|
|
@ -173,7 +177,8 @@ Systemet er bygget rundt **Temaer** og **Aktører**, ikke episoder. Dette bygger
|
||||||
|
|
||||||
## 6. Podcast Hosting og Distribusjon
|
## 6. Podcast Hosting og Distribusjon
|
||||||
* **Lagring:** MP3-filer lagres flatt i `/srv/sidelinja/media/`. Ingen lydfiler i databaser.
|
* **Lagring:** MP3-filer lagres flatt i `/srv/sidelinja/media/`. Ingen lydfiler i databaser.
|
||||||
* **Servering:** Caddy serverer media-mappen. MÅ ha `Accept-Ranges: bytes` aktivert for podcast-streaming.
|
* **Servering (intern):** Caddy serverer media-mappen for intern bruk. MÅ ha `Accept-Ranges: bytes` aktivert for podcast-streaming.
|
||||||
|
* **Servering (publisert):** RSS-feedens `<enclosure>`-URL-er **MÅ** peke på et CDN (Cloudflare eller Hetzner CDN), ikke direkte til Caddy. Ved ny episode vil podcast-aggregatorer (Apple Podcasts, Spotify) og abonnenter laste ned samtidig — 1000 lyttere × 50 MB = 50 GB burst som kveler hele serveren. CDN absorberer trafikken og beskytter den redaksjonelle arbeidsflaten. S3-abstraksjonen (se §2) er grunnlaget — CDN legges foran S3-bucketen.
|
||||||
* **RSS-Feed:** Genereres av SvelteKit og leveres statisk eller dynamisk med aggressiv caching.
|
* **RSS-Feed:** Genereres av SvelteKit og leveres statisk eller dynamisk med aggressiv caching.
|
||||||
|
|
||||||
## 7. Planlagte Funksjoner
|
## 7. Planlagte Funksjoner
|
||||||
|
|
@ -182,14 +187,14 @@ Detaljerte spesifikasjoner ligger i `docs/concepts/` (brukeropplevelser) og `doc
|
||||||
### Konsepter (brukeropplevelser)
|
### Konsepter (brukeropplevelser)
|
||||||
* **Studioet:** Podcast-innspilling med LiveKit, live AI faktoid-oppslag og Aha-markør.
|
* **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.
|
* **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.
|
* **Redaksjonen:** Daglig arbeidsflate med trådet chat (channels), Kanban, show notes og AI-behandling av tekst.
|
||||||
* **Podcastfabrikken:** Automatisert publiseringspipeline — Whisper, AI-metadata, RSS, cache-busting.
|
* **Podcastfabrikken:** Automatisert publiseringspipeline — Whisper, AI-metadata, RSS, cache-busting.
|
||||||
* **Kunnskapsgrafen:** Visuell utforsking og redigering av kunnskapsnettverk (D3.js/Vis.js).
|
* **Kunnskapsgrafen:** Visuell utforsking og redigering av kunnskapsnettverk (D3.js/Vis.js).
|
||||||
* **Valgomaten:** Publikumsrettet, crowdsourced valgomat med PCA og Sidelinja Explorer.
|
* **Valgomaten:** Publikumsrettet, crowdsourced valgomat med PCA og Sidelinja Explorer.
|
||||||
* **Den Asynkrone Gjesten:** Tidsbegrenset lenke til gjester for asynkrone lydopptak som lander i redaksjonens arbeidsflyt.
|
* **Den Asynkrone Gjesten:** Tidsbegrenset lenke til gjester for asynkrone lydopptak som lander i redaksjonens arbeidsflyt.
|
||||||
|
|
||||||
### Features (byggeklosser)
|
### Features (byggeklosser)
|
||||||
Chat (channels), Kanban, Kalender, Notater/Scratchpad, Whiteboard, Live transkripsjon, Live AI (faktoid + referent), Visuell graf, AI Research-Klipper, Lydmeldinger & Diktering, Podcast-statistikk, Kunnskaps-Bridge (cross-workspace), Prompt-Laboratorium, Graf-vedlikehold (nattlig jobb som finner isolerte noder og foreslår koblinger basert på co-occurrence i transkripsjoner).
|
Chat (channels), Kanban, Kalender, Notater/Scratchpad, Whiteboard, Live transkripsjon, Live AI (faktoid + referent), Visuell graf, Lydmeldinger & Diktering, Podcast-statistikk, Kunnskaps-Bridge (cross-workspace), Prompt-Laboratorium, Graf-vedlikehold (nattlig jobb som finner isolerte noder og foreslår koblinger basert på co-occurrence i transkripsjoner). AI-behandling av tekst (research, oppsummering, fakta-uttrekk) er en universell funksjon i editoren — se `docs/proposals/editor.md`.
|
||||||
|
|
||||||
## 8. Bygge-rekkefølge (Avhengighetskart)
|
## 8. Bygge-rekkefølge (Avhengighetskart)
|
||||||
|
|
||||||
|
|
@ -217,13 +222,14 @@ Chat (channels), Kanban, Kalender, Notater/Scratchpad, Whiteboard, Live transkri
|
||||||
- [~] Kanban (PG-adapter ferdig med drag & drop — **refaktoreres til meldingsboks + kanban_card_view**, SpacetimeDB-sync gjenstår)
|
- [~] Kanban (PG-adapter ferdig med drag & drop — **refaktoreres til meldingsboks + kanban_card_view**, SpacetimeDB-sync gjenstår)
|
||||||
- [~] Kalender (PG-adapter ferdig med månedsvisning — **refaktoreres til meldingsboks + calendar_event_view**, SpacetimeDB-sync gjenstår)
|
- [~] Kalender (PG-adapter ferdig med månedsvisning — **refaktoreres til meldingsboks + calendar_event_view**, SpacetimeDB-sync gjenstår)
|
||||||
- [~] Notater/Scratchpad (PG-adapter ferdig — **refaktoreres til meldingsboks**, rich text og SpacetimeDB-sync gjenstår)
|
- [~] Notater/Scratchpad (PG-adapter ferdig — **refaktoreres til meldingsboks**, rich text og SpacetimeDB-sync gjenstår)
|
||||||
|
- [ ] **Merge Entities (admin-verktøy):** Sammenslåing av duplikate entiteter (#Jonas, #Støre, #Jonas Gahr Støre → én node). Omdirigerer alle `graph_edges` og `messages`-mentions, legger til navn som alias, sletter duplikaten. Uten dette fragmenteres kunnskapsgrafen raskt og serendipity-effekten dør.
|
||||||
- [ ] Lydmeldinger & Diktering (opptak + Whisper + AI-opprydding)
|
- [ ] Lydmeldinger & Diktering (opptak + Whisper + AI-opprydding)
|
||||||
- [ ] Prompt-Laboratorium (prompt-testing mot egne data)
|
- [ ] Prompt-Laboratorium (prompt-testing mot egne data)
|
||||||
- [ ] Promptfoo testsett for første jobbtyper (norsk testdata)
|
- [ ] Promptfoo testsett for første jobbtyper (norsk testdata)
|
||||||
|
|
||||||
### Lag 3 — Features (krever Lag 2)
|
### Lag 3 — Features (krever Lag 2)
|
||||||
- [ ] Podcastfabrikken (Whisper SRT → Git → PG-avledede formater + episodeside)
|
- [ ] Podcastfabrikken (Whisper SRT → Git → PG-avledede formater + episodeside)
|
||||||
- [ ] AI Research-Klipper (kunnskapsgraf + jobbkø + AI Gateway)
|
- [ ] Editor med AI-behandling (universell editor + jobbkø + AI Gateway, se `docs/proposals/editor.md`)
|
||||||
- [ ] Podcast-Statistikk (jobbkø + episoder)
|
- [ ] Podcast-Statistikk (jobbkø + episoder)
|
||||||
- [ ] Whiteboard (sanntids frihåndstavle i SpacetimeDB)
|
- [ ] Whiteboard (sanntids frihåndstavle i SpacetimeDB)
|
||||||
- [ ] Den Asynkrone Gjesten (gjeste-tokens + lydmeldinger)
|
- [ ] Den Asynkrone Gjesten (gjeste-tokens + lydmeldinger)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Kunnskapsgrafen er Sidelinjas kjerne — et levende nettverk av Temaer, Aktører
|
||||||
### 2.1 Organisk vekst
|
### 2.1 Organisk vekst
|
||||||
Grafen vokser gjennom daglig bruk av Sidelinja:
|
Grafen vokser gjennom daglig bruk av Sidelinja:
|
||||||
1. **Chat-meldinger** med `#`-tags oppretter automatisk `MENTIONS`-relasjoner i grafen.
|
1. **Chat-meldinger** med `#`-tags oppretter automatisk `MENTIONS`-relasjoner i grafen.
|
||||||
2. **AI Research-Klipperen** trekker ut aktører og faktoider fra innlimt tekst.
|
2. **AI-behandling i editoren** trekker ut aktører og faktoider fra innlimt tekst (se `docs/proposals/editor.md`).
|
||||||
3. **Podcastfabrikken** kobler episode-segmenter til temaer og aktører.
|
3. **Podcastfabrikken** kobler episode-segmenter til temaer og aktører.
|
||||||
4. **Møtereferater** trådes automatisk mot temaer og aktører av AI-referenten.
|
4. **Møtereferater** trådes automatisk mot temaer og aktører av AI-referenten.
|
||||||
|
|
||||||
|
|
@ -30,7 +30,23 @@ Full-text search på norsk (`to_tsvector('norwegian', ...)`) gjør det mulig å
|
||||||
| Kunnskapsgraf datamodell | Nodes/edges i PostgreSQL (se `docs/features/kunnskapsgraf_og_relasjoner.md`) |
|
| 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`) |
|
| Visuell graf | Interaktiv D3.js/Vis.js-visning (se `docs/features/visuell_graf.md`) |
|
||||||
| Chat | Mentions (`#`/`@`) oppretter edges automatisk (se `docs/features/chat.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`) |
|
| Editor (AI-knapp) | Trekker ut aktører/faktoider til grafen (se `docs/proposals/editor.md`) |
|
||||||
|
|
||||||
## 4. Datamodell
|
## 4. Entity Resolution (Merge Entities)
|
||||||
|
|
||||||
|
Grafen vokser organisk via `#`-mentions, men dette skaper uunngåelig fragmentering: `#Jonas`, `#Støre` og `#Jonas Gahr Støre` ender som tre separate noder. Uten en strategi for sammenslåing dør serendipity-effekten — faktoidene spres over duplikater som AI-en tror er ulike konsepter.
|
||||||
|
|
||||||
|
**Løsning: Merge Entities admin-verktøy (Lag 2)**
|
||||||
|
|
||||||
|
1. Velg autoritativ node (Node A) og duplikat(er) (Node B, C, ...)
|
||||||
|
2. Flytt alle `graph_edges` som peker på Node B → Node A (`UPDATE graph_edges SET source_id/target_id = A WHERE ... = B`)
|
||||||
|
3. Flytt alle `messages`-mentions som refererer til Node B → Node A
|
||||||
|
4. Legg til `name` fra Node B som alias i `entities.aliases` på Node A
|
||||||
|
5. Slett Node B (`DELETE FROM nodes` → cascader)
|
||||||
|
|
||||||
|
`aliases`-arrayet i `entities`-tabellen finnes allerede og er indeksert med GIN — autocomplete søker i både `name` og `aliases`, noe som forebygger fremtidige duplikater.
|
||||||
|
|
||||||
|
**Forebygging:** Autocomplete bør vise eksisterende entiteter med matchende aliases *før* brukeren oppretter nye. "Mente du #Jonas Gahr Støre?" ved skriving av `#Støre`.
|
||||||
|
|
||||||
|
## 5. Datamodell
|
||||||
Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, workspace-isolasjon) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`.
|
Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, workspace-isolasjon) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`.
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ Episoder fungerer som containere. Brukerne drar Temaer fra bassenget inn i en ep
|
||||||
### 2.4 Show Notes
|
### 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.
|
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
|
### 2.5 AI-behandling av tekst
|
||||||
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`.
|
Brukere limer inn uformatert tekst fra nettet i editoren, trykker AI-knappen (✨) og velger handling (rens, oppsummer, trekk ut fakta). Resultatet publiseres som en ny melding med foreslåtte graf-koblinger. Se `docs/proposals/editor.md` § "AI-behandling — universell knapp".
|
||||||
|
|
||||||
## 3. Komponenter
|
## 3. Komponenter
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ Brukere limer inn uformatert tekst fra nettet. AI-en renser, oppsummerer og trek
|
||||||
|---|---|
|
|---|---|
|
||||||
| Chat | Trådet diskusjon per Tema (se `docs/features/chat.md`) |
|
| Chat | Trådet diskusjon per Tema (se `docs/features/chat.md`) |
|
||||||
| Kanban | Episodeplanlegging (se `docs/features/kanban.md`) |
|
| Kanban | Episodeplanlegging (se `docs/features/kanban.md`) |
|
||||||
| AI Research-Klipper | Research-inntak (se `docs/features/ai_research_klipper.md`) |
|
| Editor (proposal) | Universell editor med AI-behandling av tekst (se `docs/proposals/editor.md`) |
|
||||||
| Whiteboard | Kan åpnes fra chat for visuell brainstorming (se `docs/features/whiteboard.md`) |
|
| Whiteboard | Kan åpnes fra chat for visuell brainstorming (se `docs/features/whiteboard.md`) |
|
||||||
|
|
||||||
## 4. Instruks for Claude Code
|
## 4. Instruks for Claude Code
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# Feature Spec: AI Research-Klipper ("Ctrl+A Workflow")
|
|
||||||
**Filsti:** `docs/features/ai_research_klipper.md`
|
|
||||||
|
|
||||||
## 1. Konsept
|
|
||||||
Et internt redaksjonelt verktøy for å samle inn research fra nettet. Programlederne limer inn uformatert tekst (ofte med menyer, annonser og støy fra "Ctrl+A"-kopiering), og en AI renser teksten og trekker ut strukturert kunnskap.
|
|
||||||
|
|
||||||
## 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/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*.
|
|
||||||
4. **Broadcast (SpacetimeDB):**
|
|
||||||
Når lagringen er ferdig, sendes et signal via SpacetimeDB slik at chatten/tema-visningen oppdateres hos alle innloggede brukere med et "Kort" som viser det nye sammendraget.
|
|
||||||
|
|
||||||
## 3. Instruks for Claude Code
|
|
||||||
* Sørg for at OpenRouter API-kallet forventer og validerer streng JSON-struktur.
|
|
||||||
* Lagringen i PostgreSQL må håndtere "upserts" for Aktører elegant, slik at vi ikke får duplikater av f.eks. "Arbeiderpartiet".
|
|
||||||
175
docs/features/brukerinnstillinger.md
Normal file
175
docs/features/brukerinnstillinger.md
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
# Feature: Brukerinnstillinger
|
||||||
|
**Filsti:** `docs/features/brukerinnstillinger.md`
|
||||||
|
|
||||||
|
## 1. Konsept
|
||||||
|
Hver bruker har personlige innstillinger som styrer hvordan appen ser ut og oppfører seg. Innstillingene påvirker kun visning og opplevelse — aldri innholdet. Tilgjengelig via et innstillingspanel i appen.
|
||||||
|
|
||||||
|
## 2. Innstillinger
|
||||||
|
|
||||||
|
### 2.1 Utseende
|
||||||
|
|
||||||
|
| Innstilling | Default | Alternativer | Beskrivelse |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Tema | System | Lys / Mørk / System | Følger OS-preferanse som default |
|
||||||
|
| Skriftstørrelse | 16px | 12–24px (slider) | Gjelder all tekst i appen |
|
||||||
|
| Linjehøyde | 1.6 | 1.4–2.0 (slider) | Avstand mellom linjer |
|
||||||
|
| Innholdsbredde | 70ch | 50ch–full (slider eller presets) | Maks bredde på tekstinnhold |
|
||||||
|
| Font | System default | Serif / Sans-serif / Monospace | Brødtekst-font i appen |
|
||||||
|
| Redusert bevegelse | System | Av / På / System | Respekterer `prefers-reduced-motion` |
|
||||||
|
| Kompakt modus | Av | Av / På | Mindre padding, tettere layout |
|
||||||
|
|
||||||
|
### 2.2 Editor
|
||||||
|
|
||||||
|
| Innstilling | Default | Alternativer | Beskrivelse |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Standard visning | Rendered | Raw / Rendered | Foretrukket editor-modus |
|
||||||
|
| Stavekontroll | På | Av / På | Nettleserens innebygde stavekontroll |
|
||||||
|
| Auto-save intervall | 500ms | 500ms–5s | Debounce for auto-save |
|
||||||
|
| Vis tegnteller | Av | Av / På | Antall tegn/ord i bunn av editor |
|
||||||
|
|
||||||
|
### 2.3 Notifikasjoner (fremtidig)
|
||||||
|
|
||||||
|
| Innstilling | Default | Alternativer | Beskrivelse |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Desktop-varsler | Av | Av / På | Push-notifikasjoner i nettleseren |
|
||||||
|
| Lyd | Av | Av / På | Lydvarsling ved nye meldinger |
|
||||||
|
| Varsle ved mention | På | Av / På | Varsle når noen `#`-mentioner noe du følger |
|
||||||
|
|
||||||
|
### 2.4 Tilgjengelighet
|
||||||
|
|
||||||
|
| Innstilling | Default | Alternativer | Beskrivelse |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Høy kontrast | Av | Av / På | Sterkere fargekontraster |
|
||||||
|
| Fokus-synlighet | System | System / Forsterket | Tydeligere fokus-indikatorer |
|
||||||
|
|
||||||
|
## 3. Datamodell
|
||||||
|
|
||||||
|
Innstillingene lagres i en JSONB-kolonne på `users`-tabellen:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE users ADD COLUMN settings JSONB NOT NULL DEFAULT '{}';
|
||||||
|
```
|
||||||
|
|
||||||
|
Eksempel:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"theme": "dark",
|
||||||
|
"font_size": 18,
|
||||||
|
"line_height": 1.7,
|
||||||
|
"content_width": "80ch",
|
||||||
|
"font_family": "serif",
|
||||||
|
"reduced_motion": "system",
|
||||||
|
"compact_mode": false,
|
||||||
|
"editor_default_view": "raw",
|
||||||
|
"spellcheck": true,
|
||||||
|
"autosave_ms": 500,
|
||||||
|
"show_char_count": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hvorfor `users.settings` og ikke en egen tabell?**
|
||||||
|
- Innstillingene er per bruker, ikke per workspace
|
||||||
|
- JSONB er fleksibelt — nye innstillinger legges til uten migrering
|
||||||
|
- Ingen relasjoner, ingen spørringer mot enkeltfelt — bare hent hele objektet
|
||||||
|
|
||||||
|
**Workspace-spesifikke overrides** kan legges i `workspace_members` ved behov:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE workspace_members ADD COLUMN settings JSONB NOT NULL DEFAULT '{}';
|
||||||
|
```
|
||||||
|
Men dette er fase 2. Start med globale brukerinnstillinger.
|
||||||
|
|
||||||
|
## 4. Frontend
|
||||||
|
|
||||||
|
### 4.1 Innstillingspanel
|
||||||
|
Tilgjengelig via brukermenyen (avatar → "Innstillinger") eller hurtigtast.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Innstillinger ✕ │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Utseende │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ Tema [Lys] [Mørk] [Auto]│ │
|
||||||
|
│ │ Skrift ──●────────── 18px │ │
|
||||||
|
│ │ Linjehøyde ────●──────── 1.7 │ │
|
||||||
|
│ │ Bredde ──────●────── 70ch │ │
|
||||||
|
│ │ Font [Sans] [Serif] [Mono]│
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Editor │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ Standard [Raw] [Rendered] │ │
|
||||||
|
│ │ Stavekontroll [●] │ │
|
||||||
|
│ │ Tegnteller [ ] │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Live forhåndsvisning: │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ Dette er en prøvetekst som │ │
|
||||||
|
│ │ viser hvordan innstillingene │ │
|
||||||
|
│ │ påvirker visningen. │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Tilbakestill default] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Endringer appliseres øyeblikkelig (live preview). Ingen "lagre"-knapp — auto-save med debounce.
|
||||||
|
|
||||||
|
### 4.2 CSS Custom Properties
|
||||||
|
|
||||||
|
Innstillingene appliseres via CSS custom properties på `:root`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--user-font-size: 16px;
|
||||||
|
--user-line-height: 1.6;
|
||||||
|
--user-content-width: 70ch;
|
||||||
|
--user-font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle komponenter bruker disse variablene i stedet for hardkodede verdier:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.message-body {
|
||||||
|
font-size: var(--user-font-size);
|
||||||
|
line-height: var(--user-line-height);
|
||||||
|
max-width: var(--user-content-width);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Svelte Store
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// $lib/stores/userSettings.ts
|
||||||
|
export const userSettings = writable<UserSettings>(defaults);
|
||||||
|
|
||||||
|
// Ved innlogging: hent fra API
|
||||||
|
// Ved endring: debounce → PATCH /api/user/settings
|
||||||
|
// CSS variables oppdateres reaktivt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. API
|
||||||
|
|
||||||
|
| Metode | Sti | Beskrivelse |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/user/settings` | Hent brukerens innstillinger |
|
||||||
|
| PATCH | `/api/user/settings` | Oppdater innstillinger (merge med eksisterende) |
|
||||||
|
|
||||||
|
PATCH gjør en shallow merge: `settings = settings || patch`. Kun oppgitte felt endres.
|
||||||
|
|
||||||
|
## 6. Viktige prinsipper
|
||||||
|
|
||||||
|
- **Innstillinger påvirker aldri innhold.** En bruker med 24px skrift og en bruker med 12px skrift ser identisk innhold. Publiserte artikler for eksterne lesere bruker typografi-stacken fra artikkel-publisering, ikke forfatterens personlige innstillinger.
|
||||||
|
- **Defaults er gode.** En bruker som aldri åpner innstillingspanelet skal ha en god opplevelse. Innstillinger er for tilpasning, ikke for at ting skal fungere.
|
||||||
|
- **Ingen overraskelser.** Endringer gjelder kun brukeren som gjør dem. Workspace-admin kan ikke overstyre personlige innstillinger.
|
||||||
|
- **Progressiv avsløring.** Start med de viktigste innstillingene (tema, skriftstørrelse). Legg til flere etter hvert som behov oppstår.
|
||||||
|
|
||||||
|
## 7. Instruks for Claude Code
|
||||||
|
- Innstillinger lagres i `users.settings` JSONB — hent hele objektet, merge ved oppdatering
|
||||||
|
- Bruk CSS custom properties for all visuell tilpasning — aldri hardkodede verdier i komponenter
|
||||||
|
- Innstillingspanelet er en modal/drawer, ikke en egen side
|
||||||
|
- Auto-save med debounce (500ms) — ingen "lagre"-knapp
|
||||||
|
- Ved nye innstillinger: legg til i defaults-objektet, ingen migrering nødvendig
|
||||||
|
|
@ -190,7 +190,21 @@ Channels **opprettes ved behov**, ikke automatisk for alle noder. Når en bruker
|
||||||
|
|
||||||
Channel-config (`threads`, `mentions`, `attachments`, `ttl_days`) arves fra kontekst (se `docs/features/chat.md` §2.2).
|
Channel-config (`threads`, `mentions`, `attachments`, `ttl_days`) arves fra kontekst (se `docs/features/chat.md` §2.2).
|
||||||
|
|
||||||
## 6. Nesting og utskilling
|
## 6. Slette-semantikk: "Fjern" vs "Slett"
|
||||||
|
|
||||||
|
En meldingsboks kan ha flere roller (kanban-kort, kalenderhendelse, diskusjonstråd). UI-et må ha et skarpt, eksplisitt skille:
|
||||||
|
|
||||||
|
| Handling | Hva skjer | Konsekvens |
|
||||||
|
|---|---|---|
|
||||||
|
| **Fjern fra brett/kalender** | `DELETE FROM kanban_card_view` / `calendar_event_view` | Meldingen lever videre i chatten og grafen. Kun visningen forsvinner. |
|
||||||
|
| **Slett innhold** | `DELETE FROM messages` → cascader til `nodes` | Alt borte: diskusjonstråd, view-configs, graf-edges. Irreversibelt. |
|
||||||
|
|
||||||
|
**UI-regler:**
|
||||||
|
- "Fjern fra brett" / "Fjern fra kalender" — standardhandling i kontekstmeny på kanban/kalender. Trygt.
|
||||||
|
- "Slett permanent" — bak en bekreftelsesdialog ("Denne meldingen har 3 svar og er koblet til 2 entiteter. Slett alt?"). Viser konsekvensene eksplisitt.
|
||||||
|
- En melding med aktive roller i andre views bør vise en advarsel: "Denne meldingen er også et kanban-kort i [brett]. Fjern fra brettet først, eller slett alt?"
|
||||||
|
|
||||||
|
## 7. Nesting og utskilling
|
||||||
|
|
||||||
Maks 3 nivåer visuelt (via `reply_to`-kjeding):
|
Maks 3 nivåer visuelt (via `reply_to`-kjeding):
|
||||||
1. Boks (trådstart)
|
1. Boks (trådstart)
|
||||||
|
|
@ -202,18 +216,18 @@ Ved nivå 3 tilbyr systemet: **"Skill ut som egen diskusjon?"**
|
||||||
- Originaltråden får en lenke-melding: "→ Diskusjonen fortsetter her"
|
- Originaltråden får en lenke-melding: "→ Diskusjonen fortsetter her"
|
||||||
- Den nye boksen lever sitt eget liv
|
- Den nye boksen lever sitt eget liv
|
||||||
|
|
||||||
## 7. Eierskap, kurasjon og prominens
|
## 8. Eierskap, kurasjon og prominens
|
||||||
|
|
||||||
### 7.1 Eierskap
|
### 8.1 Eierskap
|
||||||
Trådstarter og workspace-admin deler eierskap over en diskusjonstråd. Eierskap gir tilgang til kurasjonsverktøy (se 7.2).
|
Trådstarter og workspace-admin deler eierskap over en diskusjonstråd. Eierskap gir tilgang til kurasjonsverktøy (se 7.2).
|
||||||
|
|
||||||
### 7.2 Kurasjon (TODO — UI-features, bygges inkrementelt)
|
### 8.2 Kurasjon (TODO — UI-features, bygges inkrementelt)
|
||||||
Datamodellen trenger ikke endres for disse — alt håndteres via `messages.metadata` (JSONB):
|
Datamodellen trenger ikke endres for disse — alt håndteres via `messages.metadata` (JSONB):
|
||||||
- **Absorber svar** — `metadata.absorbed = true`. Svaret kollapses visuelt, ikke slettet.
|
- **Absorber svar** — `metadata.absorbed = true`. Svaret kollapses visuelt, ikke slettet.
|
||||||
- **Kollapser utdaterte svar** — `metadata.collapsed = true`. Alltid tilgjengelig med klikk.
|
- **Kollapser utdaterte svar** — `metadata.collapsed = true`. Alltid tilgjengelig med klikk.
|
||||||
- **Fest viktige svar** — `metadata.featured = true`. Vises prominent i lang tråd.
|
- **Fest viktige svar** — `metadata.featured = true`. Vises prominent i lang tråd.
|
||||||
|
|
||||||
### 7.3 Prominens (avledet, ikke lagret)
|
### 8.3 Prominens (avledet, ikke lagret)
|
||||||
Hvor viktig en meldingsboks er, beregnes fra eksisterende data — aldri lagret som en score:
|
Hvor viktig en meldingsboks er, beregnes fra eksisterende data — aldri lagret som en score:
|
||||||
- Antall svar (`COUNT` på `reply_to`)
|
- Antall svar (`COUNT` på `reply_to`)
|
||||||
- Stemmer (`SUM` fra `message_votes`)
|
- Stemmer (`SUM` fra `message_votes`)
|
||||||
|
|
@ -223,16 +237,16 @@ Hvor viktig en meldingsboks er, beregnes fra eksisterende data — aldri lagret
|
||||||
|
|
||||||
Beregnes ved visning eller caches i materialized view ved behov. Algoritmen kan justeres uten migrasjoner eller skjemaendringer.
|
Beregnes ved visning eller caches i materialized view ved behov. Algoritmen kan justeres uten migrasjoner eller skjemaendringer.
|
||||||
|
|
||||||
## 8. TTL og livsløp
|
## 9. TTL og livsløp
|
||||||
|
|
||||||
### 8.1 To-trinns fading
|
### 9.1 To-trinns fading
|
||||||
1. **Skjult fra visning** — meldingen forsvinner fra default UI etter TTL (arvet fra channel/workspace). `messages.metadata.hidden_at` settes.
|
1. **Skjult fra visning** — meldingen forsvinner fra default UI etter TTL (arvet fra channel/workspace). `messages.metadata.hidden_at` settes.
|
||||||
2. **Slettet** — etter tilleggsperiode (dobbel TTL) fjernes raden permanent.
|
2. **Slettet** — etter tilleggsperiode (dobbel TTL) fjernes raden permanent.
|
||||||
|
|
||||||
### 8.2 Alder som dynamisk faktor
|
### 9.2 Alder som dynamisk faktor
|
||||||
Tid bidrar til utfasing — eldre meldinger uten aktivitet eller koblinger fader naturlig. Prominens-scoren (§7.3) synker med alder, og TTL-jobben bruker den til å avgjøre hva som skjules og slettes.
|
Tid bidrar til utfasing — eldre meldinger uten aktivitet eller koblinger fader naturlig. Prominens-scoren (§8.3) synker med alder, og TTL-jobben bruker den til å avgjøre hva som skjules og slettes.
|
||||||
|
|
||||||
### 8.3 Fritak-regler
|
### 9.3 Fritak-regler
|
||||||
En melding slettes **ikke** hvis:
|
En melding slettes **ikke** hvis:
|
||||||
- Den har **graf-edge(s)** (`ABOUT`, `MENTIONS`, `DISCUSSED_IN`, etc.) — koblet til noe varig i kunnskapsgrafen. Dette er det som gjør faktoider immune: en `ABOUT`-edge til en aktør/tema betyr at informasjonen har verdi utover konteksten den ble skrevet i.
|
- Den har **graf-edge(s)** (`ABOUT`, `MENTIONS`, `DISCUSSED_IN`, etc.) — koblet til noe varig i kunnskapsgrafen. Dette er det som gjør faktoider immune: en `ABOUT`-edge til en aktør/tema betyr at informasjonen har verdi utover konteksten den ble skrevet i.
|
||||||
- Den har `kanban_card_view`-rad i en aktiv kolonne
|
- Den har `kanban_card_view`-rad i en aktiv kolonne
|
||||||
|
|
@ -244,14 +258,14 @@ En melding slettes **ikke** hvis:
|
||||||
|
|
||||||
Lever boksen, lever alt under den — svar beholdes uansett alder.
|
Lever boksen, lever alt under den — svar beholdes uansett alder.
|
||||||
|
|
||||||
### 8.4 Konfigurerbarhet
|
### 9.4 Konfigurerbarhet
|
||||||
```
|
```
|
||||||
Workspace-default TTL: 30 dager (workspaces.settings.default_ttl_days)
|
Workspace-default TTL: 30 dager (workspaces.settings.default_ttl_days)
|
||||||
└── Channel kan overstyre: config.ttl_days
|
└── Channel kan overstyre: config.ttl_days
|
||||||
└── Individuelle meldinger frittes via reglene over
|
└── Individuelle meldinger frittes via reglene over
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. `<MessageBox>` Svelte-komponent
|
## 10. `<MessageBox>` Svelte-komponent
|
||||||
|
|
||||||
Én komponent som rendrer en meldingsboks i alle kontekster:
|
Én komponent som rendrer en meldingsboks i alle kontekster:
|
||||||
- **Kompakt modus** — kanban: tittel + "3 svar" + fargekode
|
- **Kompakt modus** — kanban: tittel + "3 svar" + fargekode
|
||||||
|
|
@ -260,26 +274,26 @@ Workspace-default TTL: 30 dager (workspaces.settings.default_ttl_days)
|
||||||
- Leser kontekst og tilpasser capabilities (stemmer, mentions, vedlegg)
|
- Leser kontekst og tilpasser capabilities (stemmer, mentions, vedlegg)
|
||||||
- Lazy-loader tråd ved expand (ytelse)
|
- Lazy-loader tråd ved expand (ytelse)
|
||||||
|
|
||||||
## 10. Konsekvenser for eksisterende kode
|
## 11. Konsekvenser for eksisterende kode
|
||||||
|
|
||||||
### 10.1 Tabeller som fjernes
|
### 11.1 Tabeller som fjernes
|
||||||
- `kanban_cards` → erstattes av `kanban_card_view`
|
- `kanban_cards` → erstattes av `kanban_card_view`
|
||||||
- `calendar_events` → erstattes av `calendar_event_view`
|
- `calendar_events` → erstattes av `calendar_event_view`
|
||||||
- `factoids` + `factoid_votes` → erstattes av `messages` + `message_reactions`
|
- `factoids` + `factoid_votes` → erstattes av `messages` + `message_reactions`
|
||||||
- `message_votes` → erstattes av `message_reactions`
|
- `message_votes` → erstattes av `message_reactions`
|
||||||
- `notes` → erstattes av `messages` med tittel
|
- `notes` → erstattes av `messages` med tittel
|
||||||
|
|
||||||
### 10.2 Tabeller som forblir uendret
|
### 11.2 Tabeller som forblir uendret
|
||||||
- `kanban_boards`, `kanban_columns` — strukturelle
|
- `kanban_boards`, `kanban_columns` — strukturelle
|
||||||
- `calendars` — strukturell
|
- `calendars` — strukturell
|
||||||
- `channels` — gruppering
|
- `channels` — gruppering
|
||||||
- `message_revisions` — revisjonshistorikk
|
- `message_revisions` — revisjonshistorikk
|
||||||
- `message_attachments`, `media_files` — vedlegg
|
- `message_attachments`, `media_files` — vedlegg
|
||||||
|
|
||||||
### 10.3 API-endringer
|
### 11.3 API-endringer
|
||||||
Eksisterende API-ruter (`/api/kanban/`, `/api/calendar/`, `/api/notes/`) refaktoreres til å bruke den nye datamodellen. Grensesnittet mot frontend kan holdes stabilt — endringene er i datalaget.
|
Eksisterende API-ruter (`/api/kanban/`, `/api/calendar/`, `/api/notes/`) refaktoreres til å bruke den nye datamodellen. Grensesnittet mot frontend kan holdes stabilt — endringene er i datalaget.
|
||||||
|
|
||||||
## 11. Migrering (0005_meldingsboks.sql)
|
## 12. Migrering (0005_meldingsboks.sql)
|
||||||
|
|
||||||
Migrasjonen konverterer eksisterende data:
|
Migrasjonen konverterer eksisterende data:
|
||||||
|
|
||||||
|
|
@ -292,7 +306,7 @@ Migrasjonen konverterer eksisterende data:
|
||||||
|
|
||||||
**Merk:** Migrasjonen bør ha en tilhørende down-migrering som gjenskaper de gamle tabellene og flytter data tilbake.
|
**Merk:** Migrasjonen bør ha en tilhørende down-migrering som gjenskaper de gamle tabellene og flytter data tilbake.
|
||||||
|
|
||||||
## 12. Instruks for Claude Code
|
## 13. Instruks for Claude Code
|
||||||
- **Opprettelse av meldingsboks:** Alltid INSERT i `nodes` (type 'melding') + INSERT i `messages` med samme id. Alt i én transaksjon.
|
- **Opprettelse av meldingsboks:** Alltid INSERT i `nodes` (type 'melding') + INSERT i `messages` med samme id. Alt i én transaksjon.
|
||||||
- **Kanban-kort:** INSERT i `nodes` + `messages` (med tittel) + `kanban_card_view`. Én transaksjon.
|
- **Kanban-kort:** INSERT i `nodes` + `messages` (med tittel) + `kanban_card_view`. Én transaksjon.
|
||||||
- **Kalenderhendelse:** INSERT i `nodes` + `messages` (med tittel) + `calendar_event_view`. Én transaksjon.
|
- **Kalenderhendelse:** INSERT i `nodes` + `messages` (med tittel) + `calendar_event_view`. Én transaksjon.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Fordeler:
|
||||||
* **BYOK (Bring Your Own Key):** Direkte API-nøkler til Anthropic, Google, xAI — ingen markup
|
* **BYOK (Bring Your Own Key):** Direkte API-nøkler til Anthropic, Google, xAI — ingen markup
|
||||||
* **OpenRouter som fallback:** Tilgang til alle modeller vi ikke har direkte nøkler til, og sikkerhetsventil ved nedetid
|
* **OpenRouter som fallback:** Tilgang til alle modeller vi ikke har direkte nøkler til, og sikkerhetsventil ved nedetid
|
||||||
* **Kostnadskontroll:** Rutineoppgaver rutes til gratisnivå (Gemini), dyre modeller kun når det trengs
|
* **Kostnadskontroll:** Rutineoppgaver rutes til gratisnivå (Gemini), dyre modeller kun når det trengs
|
||||||
* **Sentralisert logging:** Token-bruk per funksjon (Podcastfabrikken, Research-Klipper, Live-assistent) på ett sted
|
* **Sentralisert logging:** Token-bruk per funksjon (Podcastfabrikken, Editor AI-behandling, Live-assistent) på ett sted
|
||||||
* **Redundans:** Automatisk failover mellom leverandører — redaksjonen merker ikke nedetid
|
* **Redundans:** Automatisk failover mellom leverandører — redaksjonen merker ikke nedetid
|
||||||
|
|
||||||
## 2. Leverandører og bruksmønster
|
## 2. Leverandører og bruksmønster
|
||||||
|
|
@ -191,7 +191,10 @@ Jobbkøen støtter automatisk modell-nedgradering ved kostnadsmål:
|
||||||
| Promptfoo testsett | Gjenskapbar (Git) | `tests/prompts/` — versjonskontrollert |
|
| Promptfoo testsett | Gjenskapbar (Git) | `tests/prompts/` — versjonskontrollert |
|
||||||
| Promptfoo testresultater | Flyktig (lokal) | Kjøres on-demand, ikke lagret permanent |
|
| Promptfoo testresultater | Flyktig (lokal) | Kjøres on-demand, ikke lagret permanent |
|
||||||
|
|
||||||
## 8. Instruks for Claude Code
|
## 8. Kildevern-modus (proposal)
|
||||||
|
For sensitive redaksjonelle diskusjoner kan en lokal LLM-leverandør (Ollama/vLLM) registreres som `sidelinja/lokal` i config. Channels/møter med `kildevern: true` ruter all AI-prosessering til denne modellen — data forlater aldri serveren. Se `docs/proposals/kildevern_modus.md`.
|
||||||
|
|
||||||
|
## 9. Instruks for Claude Code
|
||||||
* All AI-kode skal peke på `http://ai-gateway:4000/v1` — aldri direkte til leverandør
|
* All AI-kode skal peke på `http://ai-gateway:4000/v1` — aldri direkte til leverandør
|
||||||
* Bruk modellaliaser (`sidelinja/rutine`, `sidelinja/resonering`) — aldri hardkod leverandør-spesifikke modellnavn i applikasjonskode
|
* Bruk modellaliaser (`sidelinja/rutine`, `sidelinja/resonering`) — aldri hardkod leverandør-spesifikke modellnavn i applikasjonskode
|
||||||
* API-nøkler i `.env`, aldri i config-filer eller kode
|
* API-nøkler i `.env`, aldri i config-filer eller kode
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på
|
||||||
### 4.4 Ressursstyring
|
### 4.4 Ressursstyring
|
||||||
* **Concurrency:** `--max-concurrent` begrenser antall samtidige jobber. Default 3 — passer for 8 vCPU der noen slots er Whisper (CPU-tung) og resten er HTTP-kall (ventetid).
|
* **Concurrency:** `--max-concurrent` begrenser antall samtidige jobber. Default 3 — passer for 8 vCPU der noen slots er Whisper (CPU-tung) og resten er HTTP-kall (ventetid).
|
||||||
* **Resource Governor (Whisper):** Når et LiveKit-rom er aktivt, reduserer workeren Whisper-tråder (`--threads 2` i HTTP-kall til faster-whisper) for å beskytte lydkvaliteten. Sjekkes via LiveKit room-status før Whisper-kall.
|
* **Resource Governor (Whisper):** Når et LiveKit-rom er aktivt, reduserer workeren Whisper-tråder (`--threads 2` i HTTP-kall til faster-whisper) for å beskytte lydkvaliteten. Sjekkes via LiveKit room-status før Whisper-kall.
|
||||||
* **Skalering senere:** Dersom volumet øker, kan workeren splittes til to binærer fra samme crate (`worker-heavy`, `worker-light`) via CLI-argument (`--types whisper_transcribe,openrouter_analyze`). Ingen kodeendring nødvendig — kun deploy-konfigurasjon.
|
* **Skalering senere:** To nivåer:
|
||||||
|
1. **Worker-splitting:** Workeren splittes til to binærer fra samme crate (`worker-heavy`, `worker-light`) via CLI-argument (`--types whisper_transcribe,openrouter_analyze`). Ingen kodeendring nødvendig — kun deploy-konfigurasjon.
|
||||||
|
2. **Compute-separasjon:** Flytt Rust-worker + faster-whisper til en separat Hetzner-node (evt. ARM/Ampere for pris/ytelse). LiveKit er ekstremt sensitivt for CPU-stotring — ved samtidig WebRTC og Whisper på samme maskin risikerer vi audio glitches uansett cgroups. Worker-noden poller jobbkøen i PostgreSQL over internt nettverk — arkitekturen støtter dette uten kodeendring.
|
||||||
|
|
||||||
**Backoff-strategi:** Eksponentiell: `30s × 2^(attempts-1)` (30s, 60s, 120s).
|
**Backoff-strategi:** Eksponentiell: `30s × 2^(attempts-1)` (30s, 60s, 120s).
|
||||||
|
|
||||||
|
|
@ -107,7 +109,7 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `whisper_transcribe` | Podcastfabrikken | Transkriber MP3 via faster-whisper |
|
| `whisper_transcribe` | Podcastfabrikken | Transkriber MP3 via faster-whisper |
|
||||||
| `openrouter_analyze` | Podcastfabrikken | Metadata-uttrekk fra transkripsjon |
|
| `openrouter_analyze` | Podcastfabrikken | Metadata-uttrekk fra transkripsjon |
|
||||||
| `research_clip` | AI Research-Klipper | Rens og strukturer innlimt tekst |
|
| `ai_text_process` | Editor (AI-knapp) | Rens, oppsummer, trekk ut fakta, skriv om (se `docs/proposals/editor.md`) |
|
||||||
| `stats_parse` | Podcast-Statistikk | Batch-prosesser Caddy-logger |
|
| `stats_parse` | Podcast-Statistikk | Batch-prosesser Caddy-logger |
|
||||||
| `meeting_summarize` | Møterommet | Generer møtereferat og action points fra transkripsjon |
|
| `meeting_summarize` | Møterommet | Generer møtereferat og action points fra transkripsjon |
|
||||||
| `valgomat_generate_profile` | Valgomat | Generer syntetiske kandidatprofiler fra partiprogrammer |
|
| `valgomat_generate_profile` | Valgomat | Generer syntetiske kandidatprofiler fra partiprogrammer |
|
||||||
|
|
@ -115,6 +117,8 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på
|
||||||
| `dictation_cleanup` | Lydmeldinger | AI-opprydding av diktert transkripsjon til strukturert notat |
|
| `dictation_cleanup` | Lydmeldinger | AI-opprydding av diktert transkripsjon til strukturert notat |
|
||||||
| `generate_embeddings` | Kunnskaps-Bridge | Generer vector embeddings for noder (pgvector) |
|
| `generate_embeddings` | Kunnskaps-Bridge | Generer vector embeddings for noder (pgvector) |
|
||||||
| `prompt_eval` | Prompt-Laboratorium | Batch-evaluering av testsett mot valgte modeller |
|
| `prompt_eval` | Prompt-Laboratorium | Batch-evaluering av testsett mot valgte modeller |
|
||||||
|
| `url_ingest` | Web Clipper (proposal) | Hent URL, oppsummer via AI, opprett research-klipp med graf-koblinger |
|
||||||
|
| `generate_waveform` | Waveforms (proposal) | Generer audio-peaks fra lydfil for visuell bølgeform |
|
||||||
|
|
||||||
## 6. Workspace-isolasjon
|
## 6. Workspace-isolasjon
|
||||||
Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode:
|
Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode:
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,20 @@ Når en idé modnes nok til å bli implementert, skrives en full spec i `docs/fe
|
||||||
| [Guest Prep Simulator](guest_prep_simulator.md) | Middels | Høy | Kunnskapsgraf, AI Gateway |
|
| [Guest Prep Simulator](guest_prep_simulator.md) | Middels | Høy | Kunnskapsgraf, AI Gateway |
|
||||||
| [Debate Club](debate_club.md) | Middels | Middels | Kunnskapsgraf, AI Gateway, jobbkø |
|
| [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 |
|
| [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 |
|
| [Tekst-primitiv](tekst_primitiv.md) | Lav–Middels | Middels–Høy | Meldingsboks, view-configs |
|
||||||
|
| [Editor](editor.md) | Middels–Stor | Høy | Tekst-primitiv, Tiptap/ProseMirror, KaTeX |
|
||||||
|
| [Artikkel-publisering](artikkel_publisering.md) | Middels–Stor | Høy | Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø |
|
||||||
| [Sosial publisering](social_posting.md) | Lav–Middels | Høy | Chat, jobbkø, workspace settings |
|
| [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 |
|
| [Komponerbare sider](komponerbare_sider.md) | Lav (Fase 1) | Middels–Høy | Workspace-modell, SvelteKit, alle feature-komponenter |
|
||||||
| [Contradiction Detector](contradiction_detector.md) | Middels | Høy | Live AI, kunnskapsgraf, pgvector, segmenter |
|
| [Contradiction Detector](contradiction_detector.md) | Middels | Høy | Live AI, kunnskapsgraf, pgvector, segmenter |
|
||||||
| [Auto-Highlight Reel](auto_highlight_reel.md) | Middels | Høy | Podcastfabrikken, jobbkø, AI Gateway, Caddy byte-range |
|
| [Auto-Highlight Reel](auto_highlight_reel.md) | Middels | Høy | Podcastfabrikken, jobbkø, AI Gateway, Caddy byte-range |
|
||||||
| [Audience Voice Memo](audience_voice_memo.md) | Lav | Høy | Den Asynkrone Gjesten, Live transkripsjon, Live AI |
|
| [Audience Voice Memo](audience_voice_memo.md) | Lav | Høy | Den Asynkrone Gjesten, Live transkripsjon, Live AI |
|
||||||
| [Personlig workspace](personlig_workspace.md) | Lav–Middels | Middels | Workspace-modell, meldingsboks |
|
| [Avisvisning](avisvisning.md) | Lav–Middels | Høy | Meldingsboks, kunnskapsgraf, prominens-score |
|
||||||
|
| [Personlig workspace](personlig_workspace.md) | Lav–Middels | Middels–Høy | Workspace-modell, meldingsboks, tekst-primitiv |
|
||||||
|
| [Kildevern-modus](kildevern_modus.md) | Lav–Middels | Høy | AI Gateway, Ollama/vLLM, Møterommet |
|
||||||
|
| [Podcasting 2.0](podcasting_2_0.md) | Lav | Høy | Podcastfabrikken, kunnskapsgraf, RSS |
|
||||||
|
| [Web Clipper](web_clipper.md) | Lav–Middels | Høy | Jobbkø, AI Gateway, meldingsboks, kunnskapsgraf |
|
||||||
|
| [Visuelle Waveforms](waveforms.md) | Lav–Middels | Høy | Podcastfabrikken, jobbkø, editor |
|
||||||
|
|
||||||
**Forfremmet til feature:** [Meldingsboks](../features/meldingsboks.md) — universell diskusjonsprimitiv (erstatter separate modeller for chat, kanban-kort, kalenderhendelser, faktoider, notater).
|
**Forfremmet til feature:** [Meldingsboks](../features/meldingsboks.md) — universell diskusjonsprimitiv (erstatter separate modeller for chat, kanban-kort, kalenderhendelser, faktoider, notater).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +1,144 @@
|
||||||
# Forslag: Artikkel-publisering
|
# Forslag: Artikkel-publisering og publikasjonsmodell
|
||||||
|
|
||||||
## Idé
|
## 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.
|
Utvide Sidelinja til en publiseringsplattform der individuelle skribenter og redaksjonelle team kan skrive, samarbeide på, og publisere tekster. Inspirert av Substack (individuell publisering), men med en kollaborativ og kuratorisk dimensjon: en tekst eies av noen, samarbeides med noen, og publiseres av én eller flere.
|
||||||
|
|
||||||
## Designfilosofi: Vakre tekster
|
## Designfilosofi
|
||||||
|
|
||||||
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.
|
### Vakre tekster
|
||||||
|
Sidelinja-artikler skal føles som noe mellom en Substack-essay og en akademisk publikasjon. Ingen sidebar-rot, ingen widget-helvete. Bare tekst, typografi og innhold.
|
||||||
|
|
||||||
**Prinsipper:**
|
**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.
|
- **Typografi først.** Seriffont for brødtekst (Georgia, Literata), 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.
|
- **Luft.** Generøse marginer. Innholdet puster.
|
||||||
- **Mørk/lys.** Respekterer `prefers-color-scheme`. Begge moduser skal være like gjennomtenkte.
|
- **Mørk/lys.** Respekterer `prefers-color-scheme`. Begge moduser 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.
|
- **Matematikk er førsteklasses.** KaTeX for LaTeX-notasjon, server-side-rendret.
|
||||||
- **Ingen visuelt støy.** Metadata (forfatter, dato, temaer) er diskret plassert. Delingsknappar og navigasjon forsvinner under lesing. Fokus er teksten.
|
- **Ingen visuell støy.** Metadata diskret plassert. Fokus er teksten.
|
||||||
|
|
||||||
**Inspirasjon:** Gwern.net (typografi + fotnoter), Distill.pub (interaktive figurer), Stratechery (ren leseopplevelse), Edward Tufte (informasjonstetthet uten rot).
|
**Inspirasjon:** Gwern.net (typografi + fotnoter), Distill.pub (interaktive figurer), Stratechery (ren leseopplevelse), Edward Tufte (informasjonstetthet uten rot).
|
||||||
|
|
||||||
## Hvorfor er det interessant?
|
### Teksten som primitiv
|
||||||
- **Naturlig forlengelse:** Redaksjonen har allerede chat, research-klipper og kunnskapsgraf. Artikler er neste logiske steg — fra intern diskusjon til publisert innhold.
|
En artikkel er ikke en egen ting — den er en melding med `article_view` (se `tekst_primitiv.md`). Samme meldingsboks-filosofi: én primitiv, flere formål. Det som gjør dette til noe mer enn "bare en editor" er *publikasjonsmodellen*.
|
||||||
- **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.
|
## Hvorfor er dette interessant?
|
||||||
- **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.
|
### Publiseringslandskapet har et hull
|
||||||
|
De fleste plattformer tvinger deg til å velge:
|
||||||
|
- **Individuell blogg** (WordPress, Substack) — du skriver alene, du publiserer alene
|
||||||
|
- **Redaksjonelt fellesprosjekt** (nettavis, magasin) — alt er felles, individet forsvinner
|
||||||
|
|
||||||
|
Virkeligheten er mer nyansert:
|
||||||
|
- Individuelle skribenter skriver sitt eget
|
||||||
|
- Samarbeidende team gjør sin kollaborative greie
|
||||||
|
- Alle publiserer sitt eget (personlig feed/blogg)
|
||||||
|
- En redaktør/publikasjon kan *kuratere* — plukke opp individuelle tekster til en felles utgivelse
|
||||||
|
- Lesere abonnerer fast på noen skribenter, velger selektivt fra andre
|
||||||
|
|
||||||
|
Sidelinja kan modellere alt dette fordi primitiven er riktig: **en tekst eies av en forfatter, kan ha medforfattere, og kan publiseres i flere kontekster**.
|
||||||
|
|
||||||
|
### Naturlig forlengelse av eksisterende features
|
||||||
|
- Redaksjonen har chat, AI-behandling og kunnskapsgraf. Artikler er neste steg — fra intern diskusjon til publisert innhold.
|
||||||
|
- Grafkobling: en artikkel arver automatisk koblinger til temaer, aktører og episoder via `#`-mentions.
|
||||||
|
- SEO: podcast-innhold er vanskelig å søkemotor-indeksere. Artikler gir tekstlig innhold som rangerer.
|
||||||
|
- Show notes 2.0: kan bli fullverdige artikler med egne URL-er.
|
||||||
|
|
||||||
## Hva bygger den på?
|
## Hva bygger den på?
|
||||||
- **Kunnskapsgrafen:** Artikkel som ny `node_type` (`'artikkel'`), med edges til temaer/aktører
|
- **Tekst-primitiv** (proposal) — `article_view`, WYSIWYG-editor, lagringsformat
|
||||||
- **Chat/Channels:** Hver artikkel kan ha en diskusjons-channel (kommentarfelt)
|
- **Kunnskapsgraf** — artikkel er en melding (node) med edges til temaer/aktører
|
||||||
- **Jobbkø:** AI-assistert skriving, faktasjekk mot kunnskapsgrafen, automatisk oppsummering
|
- **Personlig workspace** (proposal) — personlig publisering
|
||||||
- **Caddy:** Servering av publiserte artikler på egne URL-er
|
- **Jobbkø** — AI-assistert skriving, faktasjekk, oppsummering
|
||||||
- **AI Gateway:** Forslag til relaterte temaer, faktoider og episodesegmenter under skriving
|
- **Caddy** — servering av publiserte artikler
|
||||||
|
|
||||||
## Skisse
|
## Skisse
|
||||||
|
|
||||||
### Innholdsformat: Markdown + LaTeX + Embeds
|
### Publikasjonsmodellen
|
||||||
|
|
||||||
Artikler skrives i utvidet Markdown med tre tillegg:
|
#### Publikasjon som node
|
||||||
|
En publikasjon er en ny `node_type` — en kuratert samling tekster med egen identitet:
|
||||||
|
|
||||||
**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
|
```sql
|
||||||
ALTER TYPE node_type ADD VALUE 'artikkel';
|
ALTER TYPE node_type ADD VALUE 'publikasjon';
|
||||||
|
|
||||||
CREATE TABLE articles (
|
CREATE TABLE publications (
|
||||||
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
title TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
slug TEXT NOT NULL, -- URL-vennlig tittel
|
slug TEXT NOT NULL UNIQUE, -- URL-prefiks
|
||||||
body TEXT NOT NULL, -- Markdown + LaTeX + embeds (kildeformat)
|
description TEXT,
|
||||||
body_html TEXT, -- Pre-rendret HTML (KaTeX + Markdown → HTML ved lagring)
|
avatar_url TEXT,
|
||||||
excerpt TEXT, -- Kort oppsummering for RSS/OG-tags
|
owner_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'draft', -- 'draft', 'published', 'archived'
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
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`.
|
Typer publikasjoner:
|
||||||
|
- **Personlig feed** — implisitt, én per bruker, knyttet til personlig workspace
|
||||||
|
- **Redaksjonell publikasjon** — opprettet manuelt, flere kuratorer
|
||||||
|
- **Tematisk feed** — automatisk generert fra graf-spørringer ("alt tagget med #Skolepolitikk")
|
||||||
|
|
||||||
### Publiseringsflyt
|
#### `PUBLISHED_IN`-edge
|
||||||
```
|
En tekst publiseres i en publikasjon via en graf-edge:
|
||||||
Skriving (draft) → Intern review (channel) → Publisering → RSS + sitemap + OG-tags
|
|
||||||
|
```sql
|
||||||
|
-- Ny relation_type: 'PUBLISHED_IN'
|
||||||
|
-- source_id = melding (artikkel)
|
||||||
|
-- target_id = publikasjon
|
||||||
|
-- origin = 'user' (forfatter publiserer selv) eller 'curator' (redaktør plukker opp)
|
||||||
|
-- context_id = NULL (eller referanse til kurateringsforespørsel)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Funksjoner
|
En tekst kan ha flere `PUBLISHED_IN`-edges — publisert i forfatterens personlige feed *og* i redaksjonens magasin. Ingen kopiering — bare flere edges til samme node.
|
||||||
- **Markdown + LaTeX editor** med split-view live preview i SvelteKit
|
|
||||||
- **KaTeX server-side rendering** — ingen JavaScript-avhengighet for lesere
|
#### Samarbeid: medforfattere
|
||||||
- **Sidemerknad-fotnoter** (Tufte-stil) på brede skjermer, popup på mobil
|
En tekst har én `author_id` (eier) i `messages`. Medforfattere modelleres som:
|
||||||
- **#-mentions** i artikkeltekst som automatisk oppretter graf-edges
|
|
||||||
- **Innebygg podcast-klipp:** `{{segment:uuid}}` rendrer innebygd lydspiller
|
```sql
|
||||||
- **Relatert innhold:** Diskret panel under artikkelen med automatisk foreslåtte temaer, aktører og episoder basert på graf-nabolag
|
-- Ny relation_type: 'CONTRIBUTED_BY'
|
||||||
- **RSS-feed for artikler:** Separat fra podcast-RSS (Atom-format med full HTML-innhold)
|
-- source_id = melding (artikkel)
|
||||||
- **OG-tags / deling:** Automatisk generert Open Graph-metadata
|
-- target_id = entitet (person) eller bruker-node
|
||||||
|
-- confidence = NULL
|
||||||
|
-- origin = 'user'
|
||||||
|
```
|
||||||
|
|
||||||
|
Medforfattere kan redigere teksten (tilgangskontroll i appkode). Kreditering vises i publisert artikkel.
|
||||||
|
|
||||||
|
### URL-struktur
|
||||||
|
```
|
||||||
|
sidelinja.org/@vegard/ → Vegards personlige feed
|
||||||
|
sidelinja.org/@vegard/skolepolitikk-2026 → Enkeltartikkel (personlig)
|
||||||
|
sidelinja.org/pub/sidelinja-magasinet/ → Redaksjonell publikasjon
|
||||||
|
sidelinja.org/pub/sidelinja-magasinet/artikkel-slug → Artikkel i publikasjon
|
||||||
|
sidelinja.org/feed/@vegard.xml → Personlig Atom-feed
|
||||||
|
sidelinja.org/feed/pub/sidelinja-magasinet.xml → Publikasjons-feed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kurateringsflyt
|
||||||
|
```
|
||||||
|
Forfatter skriver tekst i sitt workspace
|
||||||
|
→ Publiserer i personlig feed (@vegard/slug)
|
||||||
|
→ Redaktør ser teksten (følger forfatteren, eller finner via graf)
|
||||||
|
→ Redaktør "kuraterer" teksten til sin publikasjon
|
||||||
|
→ Ny PUBLISHED_IN-edge med origin: 'curator'
|
||||||
|
→ Teksten dukker opp i publikasjonens feed
|
||||||
|
→ Forfatter varsles, kan godkjenne/avslå
|
||||||
|
```
|
||||||
|
|
||||||
|
### Abonnementer
|
||||||
|
Lesere kan følge:
|
||||||
|
- En forfatter (personlig feed)
|
||||||
|
- En publikasjon (redaksjonell feed)
|
||||||
|
- Et tema (automatisk feed fra graf)
|
||||||
|
|
||||||
|
**Ekstern distribusjon:** Atom/RSS-feeds for alt. Ingen e-postavhengighet (anti-Substack). Lesere bruker sin egen feed-reader.
|
||||||
|
|
||||||
|
**Intern distribusjon (fremtidig):** Notifikasjoner i appen. Men RSS er minimum viable — funker fra dag 1 uten notifikasjonssystem.
|
||||||
|
|
||||||
|
### Innholdsformat og rendering
|
||||||
|
Se `tekst_primitiv.md` for editor og lagringsformat. Tillegg for publiserte artikler:
|
||||||
|
|
||||||
|
- **KaTeX server-side rendering** — `body_html` i `article_view` inneholder ferdig-rendret HTML med KaTeX. Ingen JavaScript for lesere.
|
||||||
|
- **Sidemerknad-fotnoter** (Tufte-stil) — vises i margen på brede skjermer (>1200px), popup på mobil.
|
||||||
|
- **Podcast-embeds** — `{{segment:uuid}}` rendrer innebygd lydspiller med transkripsjon.
|
||||||
|
- **OG-tags** — automatisk generert Open Graph-metadata per artikkel.
|
||||||
|
|
||||||
### Typografi-stack (CSS)
|
### Typografi-stack (CSS)
|
||||||
```
|
```
|
||||||
|
|
@ -104,25 +148,47 @@ Kode: JetBrains Mono / monospace (med ligaturer)
|
||||||
Matematikk: KaTeX default (skalerer med brødtekst)
|
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
|
## Åpne spørsmål
|
||||||
- Skal artikler være multi-author (wiki-stil) eller single-author?
|
|
||||||
- Kommentarfelt for publikum, eller kun intern diskusjon?
|
### Publikasjonsmodell
|
||||||
- Versjonering av publiserte artikler (à la Wikipedia)?
|
- Bør en forfatter godkjenne at teksten kurateres av en publikasjon, eller er det implisitt tillatt?
|
||||||
- Skal draft-artikler leve i SpacetimeDB for sanntids-samskriving, eller er PG + autosave tilstrekkelig?
|
- Kan en publikasjon ha eksklusivitet (teksten publiseres *bare* der)?
|
||||||
- Bilder: Content-addressable via `media_files`, eller ekstern hosting (CDN)?
|
- Hvem eier kommentarfeltet på en kuratert tekst — forfatteren eller publikasjonen?
|
||||||
- Skal KaTeX-rendering skje ved lagring (raskere serving) eller ved request (enklere oppdatering)?
|
|
||||||
|
### Samarbeid
|
||||||
|
- Er `CONTRIBUTED_BY`-edge nok for medforfatterskap, eller trengs en egen `article_collaborators`-tabell med rolleinfo (forfatter, redaktør, korrekturleser)?
|
||||||
|
- Sanntids samredigering (Yjs) fra dag 1, eller auto-save + manuell koordinering?
|
||||||
|
|
||||||
|
### Kommentarer: to kanaler, én node
|
||||||
|
En publisert artikkel har to separate diskusjonsrom:
|
||||||
|
|
||||||
|
1. **Intern kontekst** — den opprinnelige `reply_to`-kjeden og workspace-channelen artikkelen tilhører. Usynlig utenfra. En artikkel som startet som et svar i #Mediepolitikk beholder hele den interne tråden — men den lekker aldri ut.
|
||||||
|
|
||||||
|
2. **Offentlig kommentarkanal** — en separat channel med `visibility: 'public'`, knyttet til artikkelen via `article_view.comment_channel_id`. Workspace-medlemmer ser begge kanaler. Publikum ser bare den offentlige.
|
||||||
|
|
||||||
|
Åpne spørsmål rundt offentlige kommentarer:
|
||||||
|
- Hvem kan kommentere? Anonymt, autentisert (Authentik), kun inviterte?
|
||||||
|
- Moderasjon: forfatter, workspace-admin, eller begge?
|
||||||
|
- Enkleste start: ingen offentlige kommentarer (read-only for publikum). Arkitekturen tillater det via nullable `comment_channel_id`, men det bygges først når behovet er reelt.
|
||||||
|
|
||||||
|
### Tematiske feeds
|
||||||
|
- Automatisk feed basert på graf-spørringer ("alle artikler med MENTIONS-edge til #Skolepolitikk") — er dette en publikasjon, eller et separat konsept?
|
||||||
|
- Trolig: en publikasjon med `type: 'auto'` og en lagret graf-spørring. Men det er et steg videre.
|
||||||
|
|
||||||
|
### Versjonering
|
||||||
|
- Bør publiserte artikler ha synlig versjonering (à la Wikipedia)? `message_revisions` gir historikk allerede, men det er et spørsmål om det eksponeres til lesere.
|
||||||
|
|
||||||
|
### Bilder og media
|
||||||
|
- Content-addressable via `media_files` (eksisterende), eller CDN?
|
||||||
|
- Bildeoptimalisering (responsive `srcset`) for publiserte artikler?
|
||||||
|
|
||||||
|
## Innsats: Middels-Stor
|
||||||
|
Datamodellen (publikasjoner, `PUBLISHED_IN`-edges) er overkommelig. Typografi og leseopplevelse krever CSS-arbeid. Kurateringsflyten er det mest komplekse — men kan bygges inkrementelt.
|
||||||
|
|
||||||
|
## Wow-faktor: Høy
|
||||||
|
Kombinasjonen av vakker typografi, individuelle forfattere, kollaborativt forfatterskap og kuratoriske publikasjoner — integrert med kunnskapsgraf og podcast-embeds — er noe som ikke finnes andre steder. En publiseringsplattform der en politisk analyse kan ha formler, lydklipp fra intervjuer, og koblinger til kunnskapsgrafen.
|
||||||
|
|
||||||
|
## Relasjon til andre proposals
|
||||||
|
- **Tekst-primitiv** — fundament: editor, `article_view`, lagringsformat
|
||||||
|
- **Personlig workspace** — kontekst: der individet skriver og publiserer fra
|
||||||
|
- Denne proposalen handler om *hva som skjer etter at teksten er skrevet* — publisering, kurasjon, distribusjon, leseopplevelse
|
||||||
|
|
|
||||||
230
docs/proposals/avisvisning.md
Normal file
230
docs/proposals/avisvisning.md
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
# Forslag: Avisvisning
|
||||||
|
|
||||||
|
## Idé
|
||||||
|
En read-only blokk som rendrer workspace-aktivitet som en avis — sortert etter prominens (klikk, svar, reaksjoner, graf-koblinger, alder). Ingen redigering, ingen forvaltning, bare et annet blikk inn på det som allerede finnes. Med et søkefelt som lar deg filtrere på en entitet, slik at du kan få "Jonas Gahr Støre-avisen" eller "Skolepolitikk-avisen" — alt vi har om akkurat det temaet/den personen, rangert etter viktighet.
|
||||||
|
|
||||||
|
## Hvorfor er dette interessant?
|
||||||
|
|
||||||
|
### Et nytt perspektiv uten ny data
|
||||||
|
All data finnes allerede: meldinger, artikler, graf-koblinger, reaksjoner, svar-tråder, kalender-hendelser. Avisvisningen er bare en spørring + et layout. Ingen ny datamodell, ingen ny input — bare en ny måte å lese på.
|
||||||
|
|
||||||
|
### Entitet-filter som superkraft
|
||||||
|
Kunnskapsgrafen kobler alt til entiteter via `MENTIONS`-edges. Et filter på én entitet gir deg øyeblikkelig alt workspace vet om den — som en personlig nyhetsfeed:
|
||||||
|
|
||||||
|
- `#Jonas Gahr Støre` → alle meldinger, artikler, faktoider, episodesegmenter som nevner ham
|
||||||
|
- `#Skolepolitikk` → alt om temaet, på tvers av channels og tidsperioder
|
||||||
|
- `#Episode 42` → alt knyttet til den episoden: research, diskusjon, show notes, publiserte artikler
|
||||||
|
- Ingen filter → hele workspacets aktivitet, rangert etter prominens
|
||||||
|
|
||||||
|
### Arkiv
|
||||||
|
Innhold som overlever TTL-opprydding (har graf-koblinger, er pinned, har mange svar) er per definisjon det viktige. Avisvisningen har to moduser:
|
||||||
|
|
||||||
|
**Dagsaktuelt** (default): Rangert med alders-boost — nytt innhold scorer høyere. Viser det som skjer *nå*.
|
||||||
|
|
||||||
|
**Arkiv**: Samme spørring uten alders-boost. Alt som noensinne har hatt tyngde for denne entiteten, kronologisk eller rangert etter total prominens. En historisk oversikt: "Hva har vi sagt og skrevet om skolepolitikk gjennom tidene?"
|
||||||
|
|
||||||
|
## Hva bygger den på?
|
||||||
|
- **Meldingsboks** — alt innhold er meldinger med view-configs
|
||||||
|
- **Prominens-score** (meldingsboks §7.3) — beregnes fra svar, stemmer, roller, graf-koblinger, alder
|
||||||
|
- **Kunnskapsgraf** — `MENTIONS`-edges gir entitets-filtrering
|
||||||
|
- **Komponerbare sider** — avisvisningen er en blokk som kan plasseres på en side
|
||||||
|
|
||||||
|
## Skisse
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
**Header med søk:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 📰 Avis [# Søk entitet... ▼] │
|
||||||
|
│ Skolepolitikk │
|
||||||
|
│ [Dagsaktuelt] [Arkiv] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
```
|
||||||
|
|
||||||
|
Søkefeltet er `#`-autocomplete mot entities-tabellen — samme mekanisme som i editoren og chatten. Velg en entitet, avisen filtrerer seg.
|
||||||
|
|
||||||
|
**Avis-layout (dagsaktuelt):**
|
||||||
|
```
|
||||||
|
┌──────────────────────┬──────────────────────────┐
|
||||||
|
│ │ Artikkel: Ny rapport fra │
|
||||||
|
│ TOPPSAK │ Utdanningsforbundet │
|
||||||
|
│ (høyest prominens) │ 12 reaksjoner · 2t siden │
|
||||||
|
│ ├──────────────────────────┤
|
||||||
|
│ Tittel, ingress, │ Chat: Interessant tråd i │
|
||||||
|
│ forfatter, bilde │ #Mediepolitikk │
|
||||||
|
│ 23 svar · 45 min │ 8 svar · 5t siden │
|
||||||
|
│ ├──────────────────────────┤
|
||||||
|
│ │ 📅 I morgen: Intervju med │
|
||||||
|
│ │ Kunnskapsministeren │
|
||||||
|
├──────┬───────┬───────┴──────┬───────────────────┤
|
||||||
|
│Fakto-│Segment│ Kanban-kort │ Notat: Research │
|
||||||
|
│ide │Ep. 42 │ "Skriv intro"│ om PISA-tall │
|
||||||
|
│⬆12 │14:23 │ → In Progress│ Oppdatert i går │
|
||||||
|
└──────┴───────┴──────────────┴───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Størrelsen på hver "sak" bestemmes av prominens-scoren. Toppsaken er størst. Lavere score = mindre kort. Ren CSS grid med dynamisk `grid-row: span N` basert på score-kvantiler.
|
||||||
|
|
||||||
|
**Arkiv-layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 📰 Arkiv: Skolepolitikk [Dagsaktuelt] [Arkiv]│
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 2026 │
|
||||||
|
│ ├── Mars: "Ny PISA-rapport" (artikkel, 45 ⬆) │
|
||||||
|
│ ├── Mars: Diskusjon om budsjettkutt (23 svar) │
|
||||||
|
│ ├── Feb: Episode 42 segment om skolepolitikk │
|
||||||
|
│ 2025 │
|
||||||
|
│ ├── Nov: "Lærermangel i distriktene" (artikkel) │
|
||||||
|
│ ├── Sep: Faktoide: Antall lærerstudenter 2024 │
|
||||||
|
│ └── ... │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Arkivet er en kronologisk liste gruppert etter tidsperiode. Enklere layout, tyngre på metadata (type, score, alder). Fungerer som et oppslagsverk.
|
||||||
|
|
||||||
|
### Rangering: Alder først, prominens som tiebreaker
|
||||||
|
|
||||||
|
Dette er en **avis**, ikke et oppslagsverk. Nytt innhold skal alltid dominere. En fersk melding med 2 svar slår en gammel med 50. Prominens avgjør bare *innenfor omtrent samme tidsperiode* — hvilken av dagens saker er toppsaken.
|
||||||
|
|
||||||
|
**Modell: Tidsvinduer med intern prominens-rangering**
|
||||||
|
|
||||||
|
Innholdet deles i tidsvinduer. Innenfor hvert vindu rangeres det etter prominens. Vinduer vises i rekkefølge — nyeste først.
|
||||||
|
|
||||||
|
```
|
||||||
|
Siste 24 timer → rangert etter prominens (dette er "forsiden")
|
||||||
|
1–3 dager siden → rangert etter prominens (side 2 / bla-ned)
|
||||||
|
3–7 dager siden → rangert etter prominens (eldre nyheter)
|
||||||
|
7–30 dager siden → bare det mest prominente overlever (highlights)
|
||||||
|
```
|
||||||
|
|
||||||
|
Brukeren blar nedover for å gå bakover i tid, men innenfor hver tidsperiode ser de det viktigste først.
|
||||||
|
|
||||||
|
**SQL-tilnærming:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH scored AS (
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.title,
|
||||||
|
m.body,
|
||||||
|
m.created_at,
|
||||||
|
-- Prominens (tiebreaker innenfor tidsvindu)
|
||||||
|
(SELECT COUNT(*) FROM messages r WHERE r.reply_to = m.id) * 2
|
||||||
|
+ (SELECT COUNT(*) FILTER (WHERE reaction = 'upvote')
|
||||||
|
- COUNT(*) FILTER (WHERE reaction = 'downvote')
|
||||||
|
FROM message_reactions mr WHERE mr.message_id = m.id) * 3
|
||||||
|
+ (SELECT COUNT(*) FROM graph_edges ge WHERE ge.source_id = m.id) * 5
|
||||||
|
+ (CASE WHEN EXISTS (SELECT 1 FROM article_view a WHERE a.message_id = m.id) THEN 10 ELSE 0 END)
|
||||||
|
AS prominence,
|
||||||
|
-- Tidsvindu-bucket
|
||||||
|
CASE
|
||||||
|
WHEN m.created_at > now() - interval '1 day' THEN 1
|
||||||
|
WHEN m.created_at > now() - interval '3 days' THEN 2
|
||||||
|
WHEN m.created_at > now() - interval '7 days' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END AS time_bucket
|
||||||
|
FROM messages m
|
||||||
|
JOIN nodes n ON n.id = m.id
|
||||||
|
WHERE n.workspace_id = $workspace_id
|
||||||
|
AND ($entity_id IS NULL OR EXISTS (
|
||||||
|
SELECT 1 FROM graph_edges ge
|
||||||
|
WHERE ge.source_id = m.id
|
||||||
|
AND ge.target_id = $entity_id
|
||||||
|
AND ge.relation_type = 'MENTIONS'
|
||||||
|
))
|
||||||
|
-- Minimumsterskel: ikke vis støy
|
||||||
|
AND (
|
||||||
|
m.pinned = true
|
||||||
|
OR m.title IS NOT NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM messages r WHERE r.reply_to = m.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM graph_edges ge WHERE ge.source_id = m.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM message_reactions mr WHERE mr.message_id = m.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM scored
|
||||||
|
ORDER BY time_bucket ASC, prominence DESC
|
||||||
|
LIMIT 30;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hvorfor tidsvinduer og ikke en multiplikativ formel?**
|
||||||
|
|
||||||
|
En `score * recency_decay`-formel er vanskelig å balansere universelt. Med høy decay dominerer alltid det nyeste — prominens betyr ingenting. Med lav decay kryper gamle tungvektere opp og skyver bort nyhetene. Tidsvinduer unngår dette: nytt er alltid først, men innenfor "i dag" får prominens avgjøre hva som er toppsaken.
|
||||||
|
|
||||||
|
**Minimumsterskel:** Ikke alt hører hjemme i avisen. En enkel "hei" uten svar, reaksjoner eller graf-koblinger er støy. Spørringen filtrerer bort meldinger som ikke har *noen* form for tyngde (tittel, svar, reaksjoner, edges, pinned). Dette er avisen — ikke firehosen.
|
||||||
|
|
||||||
|
**Tuning over tid:** Vektene (2, 3, 5, 10) og tidsvindu-grensene (1d, 3d, 7d) er konfigurerbare uten migrasjoner. Kan lagres i `workspaces.settings` og justeres per workspace etter behov. Men vi starter med én universell default og justerer basert på faktisk bruk.
|
||||||
|
|
||||||
|
**Arkiv-modus:** Ingen tidsvinduer. Sortert etter `created_at DESC` (kronologisk) med prominens som sekundær sortering. Alt som har overlevd TTL-opprydding vises — gruppert etter måned/år.
|
||||||
|
|
||||||
|
### Kort-typer
|
||||||
|
|
||||||
|
Ulike meldingstyper rendres med ulike kort i avisen:
|
||||||
|
|
||||||
|
| Kilde | Kort-layout |
|
||||||
|
|---|---|
|
||||||
|
| Melding med mange svar | Tittel/ingress + svar-tall + siste aktivitet |
|
||||||
|
| Artikkel (article_view) | Tittel + excerpt + forfatter + bilde |
|
||||||
|
| Kanban-kort | Tittel + kolonne + assignee |
|
||||||
|
| Kalenderhendelse | Tittel + dato/tid + "om 2 dager" |
|
||||||
|
| Faktoide (ABOUT-edge) | Innhold + stemme-score + tilknyttet entitet |
|
||||||
|
| Episodesegment | Episode-tittel + tidsrom + transkripsjon-utdrag |
|
||||||
|
| Notat med tittel | Tittel + oppdatert-dato |
|
||||||
|
|
||||||
|
Alle er lenker — klikk fører deg til meldingen i sin naturlige kontekst (chatten, kanban-brettet, kalenderen, etc.).
|
||||||
|
|
||||||
|
### Blokk i komponerbare sider
|
||||||
|
|
||||||
|
Avisvisningen er en blokk-komponent (`<NewsView>`) som kan plasseres på en komponerbar side:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"type": "news",
|
||||||
|
"config": {
|
||||||
|
"entity_id": null, // null = hele workspacet, UUID = filtrert
|
||||||
|
"mode": "current", // "current" eller "archive"
|
||||||
|
"limit": 20
|
||||||
|
},
|
||||||
|
"span": 2 // tar gjerne full bredde
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kan også stå alene som en dedikert side i workspacet — "Avisen" i navigasjonen.
|
||||||
|
|
||||||
|
## Åpne spørsmål
|
||||||
|
|
||||||
|
### Flere entiteter
|
||||||
|
- Filtrere på flere entiteter samtidig? "Alt om #Skolepolitikk OG #Støre" (AND) vs "Alt om #Skolepolitikk ELLER #Støre" (OR)?
|
||||||
|
- Trolig: start med én entitet. Kombiner-logikk er et steg videre.
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
- Prominens-scoren er en tung spørring med subqueries. For en aktiv workspace med mange meldinger bør den caches.
|
||||||
|
- Materialized view som refreshes periodisk (hvert 5. minutt)? Eller beregnes on-demand med cache-TTL?
|
||||||
|
- For arkiv-modus er caching enklere — dataen endres sjelden.
|
||||||
|
|
||||||
|
### Personalisering
|
||||||
|
- Ser alle samme avis, eller kan den vektes mot brukerens interesser?
|
||||||
|
- Start: alle ser det samme. Personalisering er et stort steg (og potensielt en filterboble-felle).
|
||||||
|
|
||||||
|
### Layout-algoritme
|
||||||
|
- Hvor mange "størrelser" av kort? Tre nivåer (stor/medium/liten) er trolig nok.
|
||||||
|
- Hvordan fordeles plass? Kvantiler: topp-10% er store, neste 30% medium, resten små?
|
||||||
|
- Responsivt: på mobil stacker alt vertikalt, største sak øverst.
|
||||||
|
|
||||||
|
### Oppdatering
|
||||||
|
- Sanntid (nytt innhold dukker opp)? Eller "pull to refresh" / periodisk?
|
||||||
|
- Trolig: periodisk (hvert minutt) + manuell refresh-knapp. Sanntid gir en "flimrende" opplevelse i et avis-layout.
|
||||||
|
|
||||||
|
## Innsats: Lav–Middels
|
||||||
|
Spørringen er rett frem (prominens-faktorer finnes allerede). Layout er CSS grid. Kort-komponentene gjenbruker eksisterende `<MessageBox>`-varianter. Den tyngste delen er å tuning av rangering og layout-algoritme for at det skal *føles* som en avis.
|
||||||
|
|
||||||
|
## Wow-faktor: Høy
|
||||||
|
"Skriv #Støre i søkefeltet og få en hel avis med alt vi vet om ham" er en øyeblikkelig aha-opplevelse. Kombinasjonen av graf-filtrering og avis-layout gjør kunnskapsgrafen *synlig* på en måte som graf-visualisering aldri klarer for folk flest.
|
||||||
|
|
||||||
|
## Relasjon til andre proposals
|
||||||
|
- **Komponerbare sider** — avisvisningen er en blokk
|
||||||
|
- **Tekst-primitiv / Meldingsboks** — all data som vises er meldinger med view-configs
|
||||||
|
- **Kunnskapsgraf** — entitets-filtrering er en graf-spørring
|
||||||
|
- **Artikkel-publisering** — publiserte artikler får prominente kort i avisen
|
||||||
385
docs/proposals/editor.md
Normal file
385
docs/proposals/editor.md
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
# Forslag: Universell editor
|
||||||
|
|
||||||
|
## Idé
|
||||||
|
Én editor-komponent som brukes overalt i Sidelinja — chat, notater, artikler, kanban-kort, show notes. Editoren autodetekterer format (plaintext, markdown, LaTeX) og rendrer riktig uten at brukeren velger modus. Avansert funksjonalitet er alltid tilgjengelig, aldri påtvunget.
|
||||||
|
|
||||||
|
## Kjerneprinsipp: Brukeren bare skriver
|
||||||
|
|
||||||
|
Editoren forstår hva brukeren skriver og rendrer det riktig — live, uten konfigurasjon:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bruker skriver Autodetektert Rendres som
|
||||||
|
────────────── ───────────── ───────────
|
||||||
|
hei plaintext tekst
|
||||||
|
**viktig** markdown bold
|
||||||
|
$E = mc^2$ LaTeX inline formel
|
||||||
|
$$\int_0^1 f(x)dx$$ LaTeX blokk sentrert formel
|
||||||
|
```python\n... kodeblokk syntax highlight
|
||||||
|
# Overskrift markdown heading H1
|
||||||
|
#Skolepolitikk mention graf-edge + lenke
|
||||||
|
{{segment:uuid}} podcast-embed lydspiller
|
||||||
|
bilde dratt inn media inline bilde med bildetekst
|
||||||
|
https://youtu.be/xyz YouTube-embed innebygd videospiller
|
||||||
|
https://example.com lenke rik forhåndsvisning (OG-kort)
|
||||||
|
```
|
||||||
|
|
||||||
|
En bruker som kan markdown bruker markdown. En bruker som kan LaTeX bruker LaTeX. En bruker som bare skriver vanlig tekst får vanlig tekst. Ingen modusvelger, ingen forhåndsvalg.
|
||||||
|
|
||||||
|
## Hvorfor er dette et eget prosjekt?
|
||||||
|
|
||||||
|
Editoren er det mest komplekse enkeltkomponenten i Sidelinja:
|
||||||
|
- Autodeteksjon av formater (markdown, LaTeX, mentions, embeds)
|
||||||
|
- Progressiv toolbar (fra usynlig til fullverdig)
|
||||||
|
- Live rendering av alt innhold
|
||||||
|
- `#`-mention med autocomplete og graf-integrasjon
|
||||||
|
- Podcast-embeds, bilder, vedlegg
|
||||||
|
- Versjonering og auto-save
|
||||||
|
- Format-kontekstsensitivitet (emoji i chat vs sirlig typografi i artikler)
|
||||||
|
- Fremtidig: collaborative editing (Yjs)
|
||||||
|
- Mobilopplevelse
|
||||||
|
|
||||||
|
Alt dette fortjener sin egen spec, sin egen utviklingssyklus, og sin egen iterasjon — uavhengig av tekst-primitiven (arkitektur) og artikkel-publisering (distribusjon).
|
||||||
|
|
||||||
|
## Hva bygger den på?
|
||||||
|
- **Tekst-primitiv** — filosofien om at enhver melding kan vokse
|
||||||
|
- **Meldingsboks** — datamodellen editoren skriver til (`messages.body`)
|
||||||
|
- **Kunnskapsgraf** — `#`-mentions oppretter graf-edges
|
||||||
|
- **Message revisions** — editoren trigge lagring av revisjoner
|
||||||
|
|
||||||
|
## Skisse
|
||||||
|
|
||||||
|
### Teknologivalg: Tiptap (ProseMirror)
|
||||||
|
|
||||||
|
Tiptap er det naturlige valget for SvelteKit:
|
||||||
|
- ProseMirror-basert, modular, godt vedlikeholdt
|
||||||
|
- Headless — full kontroll over UI og toolbar
|
||||||
|
- Lagrer som JSON (strukturert, transformerbart)
|
||||||
|
- Utvidbar med custom nodes og marks
|
||||||
|
- Svelte-kompatibelt (`@tiptap/core` headless + egen Svelte-wrapper)
|
||||||
|
- Collaborative editing via Yjs-plugin (fase 2)
|
||||||
|
|
||||||
|
### Progressiv toolbar
|
||||||
|
|
||||||
|
Editoren er **én komponent** (`<Editor>`) med ulik toolbar-konfigurasjon basert på kontekst:
|
||||||
|
|
||||||
|
**Kompakt** (default i chat, kanban-kort, quick reply):
|
||||||
|
```
|
||||||
|
[tekst-input .......................... ↑] [Send]
|
||||||
|
```
|
||||||
|
Ingen synlig toolbar. `#`-mentions og inline-formatering (bold, italic, lenker) via keyboard shortcuts. Enter = send. `↑`-knappen utvider til full modus.
|
||||||
|
|
||||||
|
**Utvidet** (notater, lengre tekster, "↑" fra kompakt):
|
||||||
|
```
|
||||||
|
[Tittel ]
|
||||||
|
[B I S ~ | H1 H2 H3 | • — ✓ | 🔗 📎 # | ↓ ]
|
||||||
|
[ ]
|
||||||
|
[ editor-innhold med live-rendering ]
|
||||||
|
[ ]
|
||||||
|
```
|
||||||
|
Full toolbar. Enter = ny linje. Auto-save. `↓` kollapser tilbake.
|
||||||
|
|
||||||
|
**Publisering** (artikler med `article_view`):
|
||||||
|
```
|
||||||
|
[Tittel ]
|
||||||
|
[B I S ~ | H1 H2 H3 | • — ✓ | 🔗 📎 # | ƒ 🎙️ |📝]
|
||||||
|
[ ]
|
||||||
|
[ editor-innhold med sidemerknad-støtte ]
|
||||||
|
[ ]
|
||||||
|
[Slug: ________] [Status: Utkast ▼] [Forhåndsvis] [Publiser]
|
||||||
|
```
|
||||||
|
Alt fra utvidet + LaTeX-toolbar, podcast-embeds, fotnoter, publiseringskontroller. `📝`-knappen åpner sidemerknad-panel.
|
||||||
|
|
||||||
|
**Modusene er ikke låst.** Brukeren kan alltid utvide eller kollapse. Toolbar-nivå er en UI-preferanse per kontekst, ikke en datadistinksjon.
|
||||||
|
|
||||||
|
### Raw / Rendered — brukeren bestemmer
|
||||||
|
|
||||||
|
Editoren har to visninger, alltid tilgjengelig via en enkel toggle (Ctrl+/ eller knapp i toolbar):
|
||||||
|
|
||||||
|
**Raw:** Viser kildekoden. Markdown som markdown, LaTeX som LaTeX, mentions som `#Skolepolitikk`. En monospace-editor der du ser nøyaktig hva som er lagret. Ingen magi, ingen overraskelser. Syntax highlighting for lesbarhet, men ingen transformasjon av det du skriver.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Min analyse av skolepolitikken
|
||||||
|
|
||||||
|
Gini-koeffisienten $G = \frac{1}{2n^2\bar{x}}$ viser at...
|
||||||
|
|
||||||
|
**Viktig:** Se også #Utdanningsforbundet sin rapport.
|
||||||
|
|
||||||
|
{{segment:550e8400-e29b-41d4-a716-446655440000}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rendered:** WYSIWYG-visning. Overskrifter er store, bold er bold, LaTeX er rendret som formler, mentions er klikkbare lenker, podcast-embeds er lydspillere. Du kan skrive direkte i denne visningen — toolbar-knapper og formatering fungerer som i et vanlig tekstbehandlingsverktøy.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Min analyse av skolepolitikken [Raw/Rendered]
|
||||||
|
│ │
|
||||||
|
│ Gini-koeffisienten G = ½n²x̄ viser at... │
|
||||||
|
│ │
|
||||||
|
│ Viktig: Se også Utdanningsforbundet sin rapport. │
|
||||||
|
│ │
|
||||||
|
│ ▶ [Episode 42, 14:23–21:07] ░░░░░░░░ │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prinsippet:** Rendered er standard for de fleste. Raw er alltid ett tastetrykk unna for de som vil ha kontroll. Begge visningene redigerer samme data — switch er øyeblikkelig og tapsfri.
|
||||||
|
|
||||||
|
**Teknisk:** Raw-modus er en CodeMirror-instans (eller enkel textarea med syntax highlighting) som opererer på serialisert markdown/LaTeX. Rendered-modus er Tiptap WYSIWYG. Begge skriver til samme Tiptap JSON — raw via en markdown→JSON parser, rendered direkte. Switch mellom dem re-serialiserer.
|
||||||
|
|
||||||
|
**Viktig detalj:** I rendered-modus forsvinner ikke kildekoden. Klikker du på en rendret formel, ser du LaTeX-kilden inline (som Obsidian gjør med markdown). Klikker du utenfor, rendres den igjen. Rendered-modus er altså ikke et "preview" — det er en visuell editor der kildekoden er tilgjengelig ved klikk.
|
||||||
|
|
||||||
|
### Autodeteksjon
|
||||||
|
|
||||||
|
Editoren forstår hva brukeren skriver — i begge moduser:
|
||||||
|
|
||||||
|
**Markdown:** `**bold**`, `# Heading`, `- item`, `> quote`, `` `code` ``. I raw: syntax highlighted. I rendered: rendret som formatering.
|
||||||
|
|
||||||
|
**LaTeX:** `$...$` inline, `$$...$$` blokk. I raw: syntax highlighted. I rendered: rendret med KaTeX.
|
||||||
|
|
||||||
|
**Mentions:** `#` trigger autocomplete i begge moduser. Ved valg opprettes en mention-node som rendres som lenke (rendered) eller `#Navn` (raw), og oppretter `MENTIONS`-edge ved lagring.
|
||||||
|
|
||||||
|
**Podcast-embeds:** `{{segment:uuid}}`. I raw: syntax highlighted. I rendered: mini-lydspiller.
|
||||||
|
|
||||||
|
**Kodeblokker:** Triple backtick med språk-hint. Syntax highlighting i begge moduser.
|
||||||
|
|
||||||
|
### Media, lenker og embeds
|
||||||
|
|
||||||
|
Editoren håndterer bilder, lenker og eksterne embeds som førsteklasses innhold — ikke som vedlegg på siden, men inline i teksten.
|
||||||
|
|
||||||
|
**Bilder:**
|
||||||
|
- Drag-and-drop, paste fra utklippstavle, eller opplastingsknapp i toolbar
|
||||||
|
- Lastes opp til `media_files` (eksisterende tabell) via API
|
||||||
|
- Inline i teksten med valgfri bildetekst og alt-tekst
|
||||||
|
- Resize ved å dra i hjørnet (rendered-modus)
|
||||||
|
- I raw: `` eller standard markdown bildesyntaks
|
||||||
|
- Responsiv `srcset` genereres ved opplasting (jobbkø) for publiserte artikler
|
||||||
|
|
||||||
|
**Lenker:**
|
||||||
|
- Paste en URL → autodetekteres som lenke
|
||||||
|
- Rik forhåndsvisning (Open Graph): tittel, beskrivelse, miniatyrbilde — hentes asynkront ved paste, caches
|
||||||
|
- Brukeren kan velge mellom rik forhåndsvisning (kort) og enkel tekstlenke
|
||||||
|
- I raw: standard markdown `[tekst](url)` eller bare URL
|
||||||
|
|
||||||
|
**Externe embeds:**
|
||||||
|
- YouTube, Vimeo, Twitter/X, o.l. — paste en URL, editoren gjenkjenner domenet og rendrer innebygd spiller/visning
|
||||||
|
- Basert på oEmbed-protokollen der tilgjengelig, ellers sandboxed iframe
|
||||||
|
- I raw: bare URL-en på egen linje. I rendered: innebygd player med riktig aspekt-ratio
|
||||||
|
- Whitelist-basert: kun kjente domener får embed-behandling. Ukjente URL-er vises som rik lenke-forhåndsvisning
|
||||||
|
|
||||||
|
**Filvedlegg:**
|
||||||
|
- PDF, dokumenter, andre filer — lastes opp til `media_files`, vises som nedlastbar lenke med filtype-ikon og størrelse
|
||||||
|
|
||||||
|
**Teknisk:**
|
||||||
|
- Alle opplastinger går via `POST /api/media` → lagres content-addressable i `media_files`
|
||||||
|
- Referanser i Tiptap JSON er `media:uuid` — aldri direkte fil-URL-er. Gjør det mulig å flytte lagring (lokal → CDN) uten å endre innhold
|
||||||
|
- Bildeoptimalisering (resize, WebP-konvertering) som jobbkø-oppgave ved opplasting
|
||||||
|
- oEmbed/OG-metadata caches i en enkel tabell for å unngå gjentatte oppslag
|
||||||
|
|
||||||
|
### AI-behandling — universell knapp
|
||||||
|
|
||||||
|
Editoren har en AI-knapp (✨) som behandler innholdet i boksen. Originalteksten bevares alltid som revisjon (`message_revisions`), og AI-resultatet tar over som nytt innhold — klart for videre redigering av brukeren.
|
||||||
|
|
||||||
|
Det som opprinnelig var tenkt som en separat "AI Research-Klipper"-modal er nå bare én av handlingene her: paste inn hva som helst → trykk ✨ → AI-en behandler det.
|
||||||
|
|
||||||
|
**Flyten:**
|
||||||
|
```
|
||||||
|
Bruker limer inn rotete Ctrl+A-tekst fra en nettavis
|
||||||
|
→ Trykker ✨ (standard: "Fiks tekst")
|
||||||
|
→ Originalen lagres som revisjon (alltid tilgjengelig)
|
||||||
|
→ AI-resultatet erstatter innholdet i editoren
|
||||||
|
→ Brukeren redigerer videre, legger til tittel, justerer
|
||||||
|
→ AI-en foreslår #-mentions basert på innholdet → graf-edges opprettes
|
||||||
|
→ Meldingen lever videre: kan få prominens i avisen, bli et kanban-kort, publiseres
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternativt:** Brukeren kan velge at resultatet publiseres som *ny melding* (svar på originalen) i stedet for å erstatte innholdet. Nyttig for "trekk ut fakta" der originalen og resultatet er to ulike ting.
|
||||||
|
|
||||||
|
### Standard-prompt: "Fiks tekst" (✨)
|
||||||
|
|
||||||
|
Standardhandlingen — den brukeren får ved å trykke ✨ uten å åpne menyen — er en generisk "magi"-prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Fiks denne teksten. Output på norsk.
|
||||||
|
- Fiks skrivefeil og grammatikk
|
||||||
|
- Start med en kort oppsummering av det viktigste (2–3 setninger)
|
||||||
|
- Fjern metainformasjon, navigasjon, annonser og annen støy fra innlimt webinnhold
|
||||||
|
- Dersom det er tydelig hva kilden er, oppgi den etter innledende oppsummering
|
||||||
|
- Behold saklig innhold og fakta intakt
|
||||||
|
- Bruk markdown-formatering der det gir bedre lesbarhet
|
||||||
|
```
|
||||||
|
|
||||||
|
Denne prompten kan justeres per workspace i Prompt Lab (se `docs/features/prompt_lab.md`). Men standarden skal være god nok til at brukeren bare kan lime inn og trykke ✨ uten å tenke.
|
||||||
|
|
||||||
|
### Handlingsmeny (lang-trykk eller ▼ ved siden av ✨)
|
||||||
|
|
||||||
|
For mer spesifikke behov åpnes en meny:
|
||||||
|
|
||||||
|
| Handling | Hva AI-en gjør |
|
||||||
|
|---|---|
|
||||||
|
| ✨ Fiks tekst (standard) | Rens, oppsummer, fiks feil, identifiser kilde |
|
||||||
|
| Trekk ut fakta | Identifiserer påstander, tall, sitater — som separate faktoider (ny melding) |
|
||||||
|
| Skriv om for publisering | Omskriver til artikkelformat med tittel, ingress, struktur |
|
||||||
|
| Oversett | Oversetter til valgt språk |
|
||||||
|
| Custom | Brukerens egne prompts fra Prompt Lab |
|
||||||
|
|
||||||
|
### Revisjon og sporbarhet
|
||||||
|
|
||||||
|
Når ✨ brukes:
|
||||||
|
1. Nåværende innhold lagres i `message_revisions` (originalen er alltid tilgjengelig)
|
||||||
|
2. AI-resultatet erstatter `messages.body`
|
||||||
|
3. `messages.metadata` oppdateres med `{ ai_processed: true, ai_action: 'fix_text', ai_prompt_id: '...' }`
|
||||||
|
4. Brukeren ser resultatet i editoren og kan redigere videre, angre (gå tilbake til revisjon), eller kjøre ✨ igjen
|
||||||
|
|
||||||
|
Revisjonshistorikken viser tydelig hva som var original og hva som er AI-behandlet. Brukeren kan alltid gå tilbake.
|
||||||
|
|
||||||
|
### Teknisk
|
||||||
|
- `POST /api/ai/process` med `{ message_id, action, prompt_id? }`
|
||||||
|
- Oppretter jobbkø-oppgave (`ai_text_process`)
|
||||||
|
- Rust-worker sender til AI Gateway (`http://ai-gateway:4000/v1`)
|
||||||
|
- Ved "erstatt innhold": lagre revisjon + oppdater `messages.body`
|
||||||
|
- Ved "ny melding": opprett ny melding med `reply_to = original_message_id`
|
||||||
|
- AI-foreslåtte `#`-mentions vises for bruker-godkjenning før edges opprettes
|
||||||
|
|
||||||
|
### Kontekst-bevisst (Agentic RAG)
|
||||||
|
AI-en kjenner konteksten meldingen lever i. Hvis den er i en channel knyttet til #Skolepolitikk, brukes det som hint for å identifisere relevante entiteter og fakta. Workspace-kontekst + graf-nabolag gir bedre resultater enn en kontekstløs prompt.
|
||||||
|
|
||||||
|
**RAG-berikelse via kunnskapsgrafen:** Når brukeren skriver et utkast og nevner `#Skolepolitikk`, gjør SvelteKit et usynlig vektorsøk (pgvector) i bakgrunnen mot kunnskapsgrafen *før* prompten sendes til AI Gateway. System-prompten oppdateres dynamisk:
|
||||||
|
|
||||||
|
```
|
||||||
|
Sidelinja har tidligere etablert disse faktaene om Skolepolitikk:
|
||||||
|
- [Faktoide 1 fra grafen]
|
||||||
|
- [Faktoide 2 fra grafen]
|
||||||
|
- [Relatert segment fra Episode 42]
|
||||||
|
Ta hensyn til dette i behandlingen.
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultatet: AI-en ikke bare retter skrivefeil, men fyller inn kontekst spesifikk for redaksjonens kunnskapsbase. Krever pgvector-migrasjon (0006) og `generate_embeddings`-jobbtype.
|
||||||
|
|
||||||
|
### Format-kontekst
|
||||||
|
|
||||||
|
Ikke alt passer overalt. En emoji-rik chatmelding og en sirlig publisert artikkel har ulike estetiske forventninger:
|
||||||
|
|
||||||
|
**Chat-kontekst:** Alt tillatt. Emojis, GIFs, korte meldinger, uformelt.
|
||||||
|
|
||||||
|
**Publiserings-kontekst:** Editoren tilbyr et "publiseringsfilter" — en valgfri siste-sjekk som flagger potensielle stilbrudd (emojis i overskrifter, manglende alt-tekst på bilder, etc.). Aldri blokkerende — bare forslag.
|
||||||
|
|
||||||
|
Publiseringskonteksten tilbyr en "forhåndsvisning som leser" der teksten rendres i den ferdige typografi-stacken (Literata, marginer, sidemerknad-fotnoter) slik at forfatteren ser hvordan det blir. Dette er en tredje visning — read-only, ren leseopplevelse — tilgjengelig via [Forhåndsvis]-knappen i publiserings-modus.
|
||||||
|
|
||||||
|
### Brukerinnstillinger
|
||||||
|
|
||||||
|
Skriftstørrelse, linjehøyde, font, tema og andre visuelle preferanser styres per bruker. Se `docs/features/brukerinnstillinger.md` for full spec — inkludert datamodell (`users.settings` JSONB), CSS custom properties, innstillingspanel og editor-spesifikke preferanser (standard Raw/Rendered, stavekontroll, tegnteller).
|
||||||
|
|
||||||
|
### Lagringsformat
|
||||||
|
|
||||||
|
**Tiptap JSON** som universelt format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"content": [
|
||||||
|
{ "type": "paragraph", "content": [
|
||||||
|
{ "type": "text", "text": "Gini-koeffisienten: " },
|
||||||
|
{ "type": "math_inline", "attrs": { "latex": "G = \\frac{1}{2n^2\\bar{x}}" } }
|
||||||
|
]},
|
||||||
|
{ "type": "mention", "attrs": { "id": "uuid", "label": "Skolepolitikk" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
En enkel "hei" og en 3000-ords artikkel med LaTeX bruker samme format.
|
||||||
|
|
||||||
|
**Body-strategi:**
|
||||||
|
```
|
||||||
|
messages.body → Tiptap JSON (universelt kildeformat)
|
||||||
|
messages.metadata → { body_html: '...', body_format: 'tiptap' }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bakoverkompatibilitet:** Eksisterende ren-tekst-meldinger (der `body` ikke er gyldig JSON) tolkes som plaintext. Editoren wrapper dem i Tiptap-paragraph ved redigering. Ingen migrering — bare fallback i lesekoden.
|
||||||
|
|
||||||
|
**Pre-rendret HTML:** `body_html` beregnes ved lagring. Brukes for:
|
||||||
|
- Rask visning i feeds og lister (ingen klient-parsing)
|
||||||
|
- Publiserte artikler (KaTeX ferdig-rendret, ingen JS for lesere)
|
||||||
|
- RSS/Atom-feeds
|
||||||
|
- Søkeindeksering
|
||||||
|
|
||||||
|
### Auto-save og versjonering
|
||||||
|
|
||||||
|
**Auto-save:** 500ms debounce etter siste tastetrykk (identisk med dagens notat-mønster). Visuell feedback: "Lagrer..." → "Lagret [tidspunkt]".
|
||||||
|
|
||||||
|
**Versjonshistorikk:** `message_revisions` lagrer `body` ved hver lagring (eller ved signifikant endring — delta-basert for å unngå å lagre hvert tastetrykk). Brukeren kan bla gjennom tidligere versjoner og tilbakestille.
|
||||||
|
|
||||||
|
**Fremtidig:** Navngitte snapshots ("Kladd 1", "Sendt til review", etc.) via `metadata` på revisjonen. Ikke dag 1.
|
||||||
|
|
||||||
|
### Keyboard shortcuts
|
||||||
|
|
||||||
|
Konsistente overalt:
|
||||||
|
```
|
||||||
|
Ctrl+B → bold
|
||||||
|
Ctrl+I → italic
|
||||||
|
Ctrl+K → lenke
|
||||||
|
Ctrl+Shift+M → math (LaTeX-blokk)
|
||||||
|
# → mention-autocomplete
|
||||||
|
Tab/Shift+Tab → innrykk i lister
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enter-oppførsel:**
|
||||||
|
- Kompakt modus: Enter = send. Shift+Enter = linjeskift.
|
||||||
|
- Utvidet/publisering: Enter = ny linje. Ctrl+Enter = eksplisitt lagre (auto-save gjør det uansett).
|
||||||
|
|
||||||
|
**Toggle:**
|
||||||
|
- Ctrl+/ → switch mellom Raw og Rendered.
|
||||||
|
|
||||||
|
### Lazy loading av extensions
|
||||||
|
|
||||||
|
Kompakt modus laster bare:
|
||||||
|
- Plaintext
|
||||||
|
- Mentions (`#`-autocomplete)
|
||||||
|
- Inline formatting (bold, italic, lenke)
|
||||||
|
|
||||||
|
Ved Expand lastes:
|
||||||
|
- Overskrifter, lister, blokk-quotes
|
||||||
|
- Bilder, vedlegg
|
||||||
|
- Kodeblokker med syntax highlighting
|
||||||
|
|
||||||
|
Ved publiserings-modus lastes:
|
||||||
|
- KaTeX (LaTeX-rendering)
|
||||||
|
- Podcast-embeds
|
||||||
|
- Sidemerknad-fotnoter
|
||||||
|
|
||||||
|
God for bundle-størrelse og oppstartstid.
|
||||||
|
|
||||||
|
## Åpne spørsmål
|
||||||
|
|
||||||
|
### Tekniske
|
||||||
|
- **Tiptap JSON vs Markdown som kildeformat?** JSON er editor-vennlig. Markdown er portabelt. Anbefaling: JSON som primær, Markdown-import/-eksport som transformasjon.
|
||||||
|
- **Ytelse:** Tiptap JSON for millioner av chatmeldinger? ~60 bytes overhead per melding. Trolig neglisjerbart, men verdt å måle.
|
||||||
|
- **KaTeX i editoren:** Live-rendering av LaTeX krever KaTeX lastet i editoren. ~300KB gzipped. Akseptabelt for utvidet/publisering, for mye for kompakt? Lazy load løser det.
|
||||||
|
- **Collaborative editing:** Tiptap + Yjs er veletablert. Ikke dag 1. Auto-save + `message_revisions` + optimistic locking (`updated_at`) er nok initialt.
|
||||||
|
|
||||||
|
### UX
|
||||||
|
- **Overgangen kompakt → utvidet:** Hvordan føles det? Smooth animasjon? Teksten forblir, toolbar glir inn? Eller instant switch?
|
||||||
|
- **Autodeteksjon av LaTeX i kompakt modus:** Rendres `$E=mc^2$` i en kort chatmelding? Ja — rendring er universell, toolbar er kontekstbetinget.
|
||||||
|
- **Mobile:** Toolbar på liten skjerm? Trolig: floating toolbar som dukker opp ved tekstseleksjon (Medium-stil) i stedet for fast toolbar.
|
||||||
|
- **Paste fra eksterne kilder:** Paste av HTML (fra nettside), Markdown (fra Obsidian), ren tekst? Tiptap håndterer HTML-paste. Markdown-paste krever custom paste handler.
|
||||||
|
|
||||||
|
### Format-kontekst
|
||||||
|
- **Emoji-filtrering i publisering:** For rigid? Brukeren bør ha full frihet. Kanskje bare en visuell advarsel i forhåndsvisning, aldri blokkering.
|
||||||
|
- **Ulike typografi-profiler?** En personlig blogg kan ha annen estetikk enn et magasin. Tema per publikasjon (se artikkel-publisering)?
|
||||||
|
|
||||||
|
## Innsats: Middels–Stor
|
||||||
|
Tiptap-integrasjon er rett frem. Autodeteksjon, progressiv toolbar, mentions, LaTeX, podcast-embeds, auto-save, versjonering, mobilopplevelse — summen er betydelig. Bør bygges inkrementelt:
|
||||||
|
|
||||||
|
1. **Fase 1:** Tiptap med plaintext + mentions + markdown formatting. Kompakt og utvidet modus. Auto-save.
|
||||||
|
2. **Fase 2:** LaTeX (KaTeX), kodeblokker, bilder/vedlegg. Publiserings-modus.
|
||||||
|
3. **Fase 3:** Podcast-embeds, sidemerknad-fotnoter, collaborative editing (Yjs).
|
||||||
|
|
||||||
|
## Wow-faktor: Høy
|
||||||
|
En editor der du bare skriver — markdown rendres automatisk, LaTeX rendres automatisk, mentions oppretter graf-koblinger, og alt kan vokse til en publisert artikkel — er en opplevelse de fleste verktøy ikke tilbyr. Det nærmeste er Notion, men uten graf-integrasjon og uten podcast-embeds.
|
||||||
|
|
||||||
|
## Relasjon til andre proposals og features
|
||||||
|
- **Tekst-primitiv** — filosofien editoren realiserer
|
||||||
|
- **Artikkel-publisering** — publiseringslaget som bruker editoren
|
||||||
|
- **Personlig workspace** — konteksten der editoren brukes daglig
|
||||||
|
- **Meldingsboks** (feature) — datamodellen editoren skriver til
|
||||||
|
- **Komponerbare sider** — maximize gir editoren plass til å gå fra chatboks til fullskjerm skriveverksted
|
||||||
|
- **Chat** (feature) — kompakt modus erstatter dagens chat-input
|
||||||
|
- **Notater** (feature) — utvidet modus erstatter dagens textarea
|
||||||
34
docs/proposals/kildevern_modus.md
Normal file
34
docs/proposals/kildevern_modus.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Forslag: Kildevern-modus (100% lokal LLM)
|
||||||
|
|
||||||
|
## Idé
|
||||||
|
Når Møterommet eller en channel brukes til sensitive, upubliserte redaksjonelle diskusjoner, bryter det med kildevernet å sende transkripsjoner til Claude/Gemini — selv via LiteLLM. En toggle for "kildevern-modus" ruter all AI-prosessering til en lokal modell. Data forlater aldri serveren.
|
||||||
|
|
||||||
|
## Hvorfor er dette interessant?
|
||||||
|
- Presseetikk og kildevern er ikke-forhandlbart for seriøse redaksjoner
|
||||||
|
- Kan være et differensierende salgspunkt for plattformen
|
||||||
|
- LiteLLM støtter allerede Ollama/vLLM som leverandør — arkitekturen er klar
|
||||||
|
|
||||||
|
## Hva bygger den på?
|
||||||
|
- **AI Gateway** — Ollama/vLLM som ny leverandør i `config.yaml`
|
||||||
|
- **Møterommet** — kildevern-toggle på channel/rom-nivå
|
||||||
|
- **Jobbkø** — ruting basert på `kildevern`-flagg
|
||||||
|
|
||||||
|
## Gjennomføring
|
||||||
|
1. Sett opp Ollama eller vLLM som egen Docker-container med en lett, lokal modell (f.eks. Llama-3-8B eller Gemma-2-9B)
|
||||||
|
2. Registrer som `sidelinja/lokal` i LiteLLM config
|
||||||
|
3. Channels/møter får en toggle: `kildevern: true` (lagres i channel-config eller `workspaces.settings`)
|
||||||
|
4. Når flagget er satt, ruter AI Gateway til `sidelinja/lokal` i stedet for eksterne modeller
|
||||||
|
5. UI viser tydelig "Kildevern aktiv — all AI-prosessering skjer lokalt" med visuell indikator
|
||||||
|
|
||||||
|
## Ressurskrav
|
||||||
|
- Lokal 8B-modell krever ~6 GB VRAM (GPU) eller ~8 GB RAM (CPU, saktere)
|
||||||
|
- På nåværende server (16 GB RAM) er dette mulig men trangt — compute-separasjon (se `docs/infra/jobbkø.md` §4.4) gjør det mer komfortabelt
|
||||||
|
- Kvaliteten på norsk tekst med 8B-modeller er merkbart lavere enn Claude/Gemini — akseptabelt for oppsummering, ikke for kompleks analyse
|
||||||
|
|
||||||
|
## Åpne spørsmål
|
||||||
|
- Hvor granulært skal kildevern-toggle være? Per channel, per melding, per workspace?
|
||||||
|
- Trenger vi et visuelt "sikkerhetsnivå" (grønt/rødt skjold) i UI?
|
||||||
|
- Bør kildevern-modus også blokkere ekstern embedding-generering (pgvector)?
|
||||||
|
|
||||||
|
## Innsats: Lav–Middels
|
||||||
|
## Wow-faktor: Høy
|
||||||
|
|
@ -39,31 +39,98 @@ Ulike redaksjoner jobber ulikt. Noen vil ha chat + kanban side-om-side, andre vi
|
||||||
- Ekstremt høy kompleksitet, lav marginalverdi vs. Fase 2
|
- Ekstremt høy kompleksitet, lav marginalverdi vs. Fase 2
|
||||||
- Unngå med mindre det er et demonstrert behov
|
- Unngå med mindre det er et demonstrert behov
|
||||||
|
|
||||||
|
## Blokker som resizable containere
|
||||||
|
|
||||||
|
Hver blokk på en side er et selvstendig, resizable panel. Brukeren kan:
|
||||||
|
|
||||||
|
### Resize
|
||||||
|
Dra i kanten mellom blokker for å justere proporsjoner. Midlertidig (session) eller persistent (lagres i brukerens `dashboard_config`).
|
||||||
|
|
||||||
|
```
|
||||||
|
Standard layout: Etter resize:
|
||||||
|
┌──────┬──────┐ ┌───┬─────────┐
|
||||||
|
│ Chat │Kanban│ │ │ │
|
||||||
|
│ │ │ → │ │ Kanban │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
└──────┴──────┘ └───┴─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maximize
|
||||||
|
Dobbelklikk på tittellinjen, eller en maximize-knapp — blokken utvider seg til hele tilgjengelig skjermflate. Alle andre blokker kollapses. Trykk Escape eller minimize for å gå tilbake.
|
||||||
|
|
||||||
|
```
|
||||||
|
Maksimert chat:
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ Chat — #Mediepolitikk ✕ │
|
||||||
|
│ │
|
||||||
|
│ [full editor, │
|
||||||
|
│ full tråd-oversikt, │
|
||||||
|
│ full funksjonalitet] │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Dette er spesielt verdifullt for:
|
||||||
|
- **Editoren** — en chatmelding som vokser til en artikkel trenger plutselig hele skjermen. Maximize gir deg et reelt skriveverksted i stedet for en bitteliten boks.
|
||||||
|
- **Whiteboard** — trenger plass for å være nyttig
|
||||||
|
- **Graf-visualisering** — uleselig i en liten boks
|
||||||
|
- **Mobil** — der skjermplassen er begrenset er maximize den naturlige interaksjonen. Blokkene stacker vertikalt, og du tapper en blokk for å utvide den til fullskjerm.
|
||||||
|
|
||||||
|
### Mobil-opplevelse
|
||||||
|
På mobil er default-visningen en stacked liste med kollapsede blokker:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐
|
||||||
|
│ ▼ Chat (3 nye) │
|
||||||
|
│ ▼ Kanban │
|
||||||
|
│ ▼ Statistikk │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Tapp en blokk → den ekspanderer til fullskjerm. Swipe ned eller tilbake-knapp → tilbake til oversikten. Editoren i fullskjerm på mobil = et reelt skriveverksted, ikke en liten inputboks nederst.
|
||||||
|
|
||||||
|
### Teknisk implementering
|
||||||
|
- CSS Grid med `grid-template-columns`/`grid-template-rows` som justeres via drag
|
||||||
|
- Resize via pointer events på grid-gapene (eller et lett bibliotek som `svelte-splitpanes`)
|
||||||
|
- Maximize = CSS `position: fixed` + z-index overlay, med smooth transition
|
||||||
|
- Blokk-størrelse lagres i `dashboard_config` JSONB: `{ "blockId": { "span": 1.5 } }` eller lignende
|
||||||
|
- Midlertidig resize (ikke lagret) = Svelte `$state`, forsvinner ved refresh
|
||||||
|
|
||||||
## Arkitektur-krav
|
## Arkitektur-krav
|
||||||
Hver feature-komponent MÅ bygges som en **selvstendig Svelte-komponent** som:
|
Hver feature-komponent MÅ bygges som en **selvstendig Svelte-komponent** som:
|
||||||
- Tar imot `workspaceId` (og evt. config-props som `channelId`, `boardId`)
|
- Tar imot `workspaceId` (og evt. config-props som `channelId`, `boardId`)
|
||||||
- Håndterer sin egen datahenting og tilstand
|
- Håndterer sin egen datahenting og tilstand
|
||||||
- Respekterer container-størrelse (responsiv innenfor sin blokk)
|
- Respekterer container-størrelse (responsiv innenfor sin blokk, `container queries` i CSS)
|
||||||
- Eksponerer en `blockMeta`-descriptor (tittel, min-bredde, ikon) for katalogen
|
- Eksponerer en `blockMeta`-descriptor (tittel, min-bredde, min-høyde, ikon) for katalogen
|
||||||
|
- Har en `maximized`-prop som tilpasser layout (f.eks. editoren viser full toolbar i maximized)
|
||||||
|
|
||||||
Dette koster ingenting å gjøre fra start og gir full fleksibilitet senere.
|
Dette koster ingenting å gjøre fra start og gir full fleksibilitet senere.
|
||||||
|
|
||||||
## Bygger på
|
## Bygger på
|
||||||
- Workspace-modell, SvelteKit layout
|
- Workspace-modell, SvelteKit layout
|
||||||
- Alle feature-komponenter (chat, kanban, whiteboard, statistikk, etc.)
|
- Alle feature-komponenter (chat, kanban, whiteboard, statistikk, etc.)
|
||||||
|
- Editor (proposal) — maximize gir editoren plass til å være et reelt skriveverksted
|
||||||
|
|
||||||
## Innsats
|
## Innsats
|
||||||
- Fase 1: **Lav** (grid-layout + JSON-config, ingen drag-and-drop)
|
- Fase 1: **Lav** (grid-layout + JSON-config, ingen drag-and-drop)
|
||||||
- Fase 2: **Middels** (svelte-grid, bruker-lagring)
|
- Fase 1.5: **Lav** (maximize per blokk — relativt enkelt med CSS overlay)
|
||||||
- Fase 3: **Stor** (custom tiling engine)
|
- Fase 2: **Middels** (resize, svelte-splitpanes, bruker-lagring)
|
||||||
|
- Fase 3: **Stor** (custom tiling engine — trolig unødvendig)
|
||||||
|
|
||||||
## Wow-faktor
|
## Wow-faktor
|
||||||
Middels–Høy. Gir en "dette er MITT verktøy"-følelse som skiller Sidelinja fra rigide alternativer.
|
Middels–Høy. Gir en "dette er MITT verktøy"-følelse. Maximize alene er en stor forbedring — spesielt på mobil der det transformerer opplevelsen fra "begrenset app" til "fullverdig arbeidsflate".
|
||||||
|
|
||||||
## Åpne spørsmål
|
## Åpne spørsmål
|
||||||
- Skal sider være workspace-globale (alle ser samme oppsett) eller per-bruker?
|
- 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.
|
→ 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?
|
- Midlertidig vs persistent resize: bør default være at resize forsvinner ved refresh (session), eller lagres automatisk?
|
||||||
→ Fullskjerm-modus per blokk som fallback.
|
→ Trolig: auto-lagre med debounce. Brukeren forventer at ting "huskes". En "tilbakestill layout"-knapp for å gå tilbake til admin-default.
|
||||||
- Bør det finnes et "standard-oppsett" per workspace-type (podcast, nyhetsredaksjon)?
|
- Bør det finnes et "standard-oppsett" per workspace-type (podcast, nyhetsredaksjon)?
|
||||||
→ Ja, som templates admin kan velge som utgangspunkt.
|
→ Ja, som templates admin kan velge som utgangspunkt.
|
||||||
|
- Keyboard shortcut for maximize? F11 er konvensjon for fullskjerm, men kolliderer med nettleser. Kanskje Ctrl+Shift+F eller dobbelklikk.
|
||||||
|
|
||||||
|
## Relasjon til andre proposals
|
||||||
|
- **Editor** — maximize gir editoren rom til å gå fra chatinput til fullverdig skriveverksted
|
||||||
|
- **Personlig workspace** — personlige dashboards (fase 2) henger tett sammen med personlig workspace
|
||||||
|
- **Tekst-primitiv** — en melding som vokser trenger plass; maximize er mekanismen som gir den det
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,117 @@
|
||||||
# Forslag: Personlig workspace
|
# Forslag: Personlig workspace
|
||||||
|
|
||||||
## Ide
|
## Idé
|
||||||
Hver bruker får et implisitt, privat workspace med alle verktøyene et vanlig workspace har — kanban, kalender, notater, graf-koblinger. Alt kan kladdes privat og flyttes til et delt workspace når det er klart.
|
Hver bruker får et personlig workspace som fungerer som en individuell produktivitetssuite. Alle verktøyene et delt workspace har — kanban, kalender, notater, graf-koblinger — men privat og selvorganisert. I tillegg: en personlig publiseringskanal ("blogg") der tekster kan deles med omverdenen.
|
||||||
|
|
||||||
## Hvorfor er dette interessant?
|
## Hvorfor er dette interessant?
|
||||||
- Redaksjonsmedlemmer trenger et sted å jobbe uforstyrret — research, kladder, oppgavelister
|
|
||||||
- `visibility = 'private'` på meldingsbokser løser private notater *innenfor* et workspace, men gir ikke en egen arbeidsflate med egne kanban-brett, kalendere og graf
|
|
||||||
- Et personlig workspace gir full fleksibilitet: private kanban-brett for personlige oppgaver, privat kalender, private research-notater med graf-koblinger — alt med eksisterende infrastruktur
|
|
||||||
|
|
||||||
## Hva det bygger på
|
### Individuell produktivitet
|
||||||
- Workspace-modellen (RLS, workspace_members)
|
Redaksjonsmedlemmer trenger et sted å jobbe uforstyrret:
|
||||||
- Meldingsboks (alt er allerede workspace-scopet)
|
- Personlige oppgavelister (kanban)
|
||||||
|
- Egen kalender (deadlines, påminnelser)
|
||||||
|
- Kladder og research-notater
|
||||||
|
- Graf-koblinger til temaer og aktører de følger
|
||||||
|
|
||||||
|
`visibility = 'private'` på meldingsbokser innenfor delte workspaces dekker noe av dette, men gir ikke en *egen arbeidsflate*. Et personlig workspace gir:
|
||||||
|
- Eget kanban-brett for personlige oppgaver (ikke synlig for andre)
|
||||||
|
- Egen kalender (kan overlappes med delt kalender i UI)
|
||||||
|
- Egne notater uten støy fra fellesrommet
|
||||||
|
- Egne graf-koblinger og research
|
||||||
|
|
||||||
|
### Personlig publisering
|
||||||
|
Med tekst-primitiven (se `tekst_primitiv.md`) og publiseringsmodellen (se `artikkel_publisering.md`) kan personlig workspace også være utgangspunkt for en personlig blogg/feed:
|
||||||
|
- Skriv en tekst i personlig workspace
|
||||||
|
- Publiser den → tilgjengelig på en personlig URL (`sidelinja.org/@vegard/...`)
|
||||||
|
- Teksten kan også plukkes opp av en felles publikasjon (se artikkel-publisering)
|
||||||
|
|
||||||
|
## Hva bygger den på?
|
||||||
|
- **Workspace-modellen** (RLS, workspace_members) — et personlig workspace er bare et vanlig workspace med én member
|
||||||
|
- **Meldingsboks** — alt er allerede workspace-scopet
|
||||||
|
- **Tekst-primitiv** (proposal) — gir notater en skikkelig editor
|
||||||
|
- **Artikkel-publisering** (proposal) — gir publiseringskanalen
|
||||||
|
|
||||||
|
## Skisse
|
||||||
|
|
||||||
|
### Verktøy i personlig workspace
|
||||||
|
|
||||||
|
| Verktøy | Hva det er | Bygger på |
|
||||||
|
|---|---|---|
|
||||||
|
| Oppgaver | Personlig kanban-brett | `kanban_card_view` |
|
||||||
|
| Kalender | Personlig kalender | `calendar_event_view` |
|
||||||
|
| Notater/kladder | Meldinger med rich text editor | Tekst-primitiv |
|
||||||
|
| Research | Editor AI-knapp + graf-koblinger | Kunnskapsgraf, AI gateway |
|
||||||
|
| Personlig feed | Publiserte tekster med egen URL | Artikkel-publisering |
|
||||||
|
|
||||||
|
Alle disse er eksisterende features brukt i en personlig kontekst. Ingen ny funksjonalitet — bare et eget workspace å bruke dem i.
|
||||||
|
|
||||||
|
### Opprettelse
|
||||||
|
Automatisk ved brukerregistrering. Workspacet er implisitt — det dukker opp i workspace-switcheren med et visuelt skille (ikon, farge, eller plassering).
|
||||||
|
|
||||||
|
Slug: `personal-{authentik_id}` (intern), visningsnavn: brukerens display_name.
|
||||||
|
|
||||||
|
### Workspace-switcher
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 👤 Mitt workspace │ ← alltid øverst, visuelt adskilt
|
||||||
|
├─────────────────────┤
|
||||||
|
│ 📻 Sidelinja │
|
||||||
|
│ 🏛️ Foreningen │
|
||||||
|
│ ... │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flytt mellom workspaces
|
||||||
|
Tre strategier, rangert etter pragmatisme:
|
||||||
|
|
||||||
|
1. **Del, ikke flytt** (enklest) — endre `visibility` fra `'private'` til `'workspace'`. Krever at meldingen allerede bor i mål-workspacet. Fungerer for "jobbe privat i fellesrommet", men ikke for å flytte fra personlig workspace til et delt.
|
||||||
|
|
||||||
|
2. **Kopier, ikke flytt** (anbefalt) — opprett ny node i mål-workspace, behold original i personlig. Lenke mellom dem med `COPIED_FROM`-edge. Enkelt, trygt, ingen referanseproblemer.
|
||||||
|
|
||||||
|
3. **Flytt atomisk** — endre `workspace_id` på node + alle avhengigheter i én transaksjon. Komplekst: `graph_edges`, `reply_to`-kjeder, `kanban_card_view`-referanser til kolonner i kilde-workspace. Ikke verdt kompleksiteten initialt.
|
||||||
|
|
||||||
|
**Anbefaling:** Start med (2). "Kopier til fellesrom" er en tydelig handling. Originalen forblir i personlig workspace som referanse.
|
||||||
|
|
||||||
|
### Personlig publisering (avhenger av artikkel-publisering)
|
||||||
|
Hvert personlig workspace har en implisitt publikasjon (feed). Når en tekst publiseres fra personlig workspace:
|
||||||
|
- Den får en `article_view` med slug og status
|
||||||
|
- Den blir tilgjengelig på `sidelinja.org/@brukernavn/slug`
|
||||||
|
- Den dukker opp i brukerens personlige Atom-feed
|
||||||
|
- En redaktør i en felles publikasjon kan kuratere den derfra (se `artikkel_publisering.md`)
|
||||||
|
|
||||||
## Åpne spørsmål
|
## Åpne spørsmål
|
||||||
|
|
||||||
### Opprettelse
|
### Grense mot delte workspaces
|
||||||
- Automatisk ved brukerregistrering, eller on-demand?
|
- Kan et personlig workspace ha flere medlemmer (f.eks. invitere en kollega til å se kanban-brettet)? Eller er det strengt personlig?
|
||||||
- Navnekonvensjon for slug: `user-{authentik_id}` eller `personal-{display_name}`?
|
- Pragmatisk: start strengt personlig (1 member). Utvid later hvis behov oppstår.
|
||||||
|
|
||||||
### Flytt mellom workspaces
|
### Kvoter og vekst
|
||||||
Hovedutfordringen. En meldingsboks som flyttes fra personlig til delt workspace krever:
|
- Eget lagringsbudsjett per personlig workspace?
|
||||||
- `nodes.workspace_id` endres
|
- TTL-policy: samme som delte workspaces, eller mer liberal (personlig innhold slettes ikke automatisk)?
|
||||||
- `graph_edges` som refererer til noden — flyttes med, eller brytes?
|
- Trolig: ingen TTL på personlig workspace som default. Brukeren styrer selv.
|
||||||
- Svar (`reply_to`-kjeden) — følger med, eller forblir i kilde?
|
|
||||||
- `kanban_card_view` / `calendar_event_view` — peker på strukturer (kolonner, kalendere) i kilde-workspacet
|
|
||||||
|
|
||||||
Mulige strategier:
|
### Dashboard / startside
|
||||||
1. **Kopier, ikke flytt** — opprett ny node i mål-workspace, behold original i personlig. Lenke mellom dem med edge.
|
- Bør personlig workspace ha et dashboard? F.eks.:
|
||||||
2. **Flytt atomisk** — flytt noden og alle avhengigheter i én transaksjon. Komplekst men rent.
|
- Siste notater
|
||||||
3. **Del, ikke flytt** — endre `visibility` fra `'private'` til `'workspace'` uten å bytte workspace. Krever at personlige og delte meldinger kan sameksistere i samme workspace (allerede støttet).
|
- Kommende kalenderhendelser
|
||||||
|
- Kanban-kort med deadline
|
||||||
Strategi 3 er enklest og allerede implementert via `visibility`-kolonnen. Et personlig workspace trengs da bare hvis brukeren vil ha *helt separerte* verktøy (eget kanban-brett, egen kalender).
|
- Siste aktivitet i delte workspaces brukeren er med i
|
||||||
|
- Eller er det overkill — bare vis verktøyene?
|
||||||
### Workspace-switcher
|
|
||||||
- Vises personlig workspace i workspace-switcheren?
|
|
||||||
- Visuelt skille mellom personlige og delte workspaces?
|
|
||||||
|
|
||||||
### Kvoter og TTL
|
|
||||||
- Eget disk-/lagringsbudsjett per personlig workspace?
|
|
||||||
- Strengere TTL for å unngå at personlige workspaces vokser ubegrenset?
|
|
||||||
|
|
||||||
### Alternativ: "Visibility er nok"
|
### Alternativ: "Visibility er nok"
|
||||||
Det kan hende at `visibility = 'private'` på meldingsbokser innenfor delte workspaces dekker 90% av behovet. Et personlig workspace er da overkill — brukeren jobber bare privat i det delte workspacet og deler når klar. Verdt å evaluere etter at visibility er i bruk en stund.
|
Det kan fortsatt hende at `visibility = 'private'` i delte workspaces dekker 80% av behovet. Et personlig workspace er da mest relevant for:
|
||||||
|
- Innhold som ikke hører til noe delt workspace
|
||||||
|
- Personlig publisering
|
||||||
|
- Et "hjem" i appen
|
||||||
|
|
||||||
## Innsats: Lav (workspace-opprettelse) / Middels (flytt-mellom-workspaces)
|
Verdt å evaluere etter at visibility og tekst-primitiven er på plass.
|
||||||
## Wow-faktor: Middels
|
|
||||||
|
## Innsats: Lav (opprettelse) / Middels (med publisering og dashboard)
|
||||||
|
Workspace-opprettelse ved registrering er trivielt. Publiseringslaget avhenger av tekst-primitiv og artikkel-publisering. Dashboard er eget arbeid.
|
||||||
|
|
||||||
|
## Wow-faktor: Middels-Høy
|
||||||
|
Alene er det "et privat workspace". Med publisering blir det en personlig plattform — Substack-aktig, men integrert i redaksjonsverktøyet.
|
||||||
|
|
||||||
|
## Relasjon til andre proposals
|
||||||
|
- **Tekst-primitiv** — gir notater og kladder en skikkelig editor
|
||||||
|
- **Artikkel-publisering** — gir publiseringsmodellen (publikasjoner, kuratorer, feeds)
|
||||||
|
- Personlig workspace er *konteksten* der tekst-primitiven og publisering møtes for individet
|
||||||
|
|
|
||||||
37
docs/proposals/podcasting_2_0.md
Normal file
37
docs/proposals/podcasting_2_0.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Forslag: Podcasting 2.0 — strukturert RSS
|
||||||
|
|
||||||
|
## Idé
|
||||||
|
Sidelinja har allerede strukturert data for transkripsjoner (segmenter), kapittelinndeling og personer (aktører i grafen). Mate dette direkte inn i RSS-feeden via Podcasting 2.0-standarden — zero ekstra arbeid for redaksjonen, maks wow i lytterappen.
|
||||||
|
|
||||||
|
## Hvorfor er dette interessant?
|
||||||
|
- Apper som Apple Podcasts og Pocket Casts viser automatisk live-synkronisert teksting
|
||||||
|
- Lytteren kan klikke på gjestens navn for profilbilde (fra `entities.avatar_url`)
|
||||||
|
- Kapitlene genereres allerede fra segmenter — bare å eksponere dem i riktig format
|
||||||
|
- Nesten null implementeringskostnad — dataen finnes, bare RSS-generatoren mangler tags
|
||||||
|
|
||||||
|
## Hva bygger den på?
|
||||||
|
- **Podcastfabrikken** — episoder, segmenter, transkripsjoner
|
||||||
|
- **Kunnskapsgraf** — aktører med `avatar_url`, relasjoner til segmenter
|
||||||
|
- **RSS-feed** — SvelteKit-generert (se `docs/arkitektur.md` §6)
|
||||||
|
|
||||||
|
## Podcasting 2.0 tags
|
||||||
|
|
||||||
|
| Tag | Sidelinja-kilde | Resultat i lytterapp |
|
||||||
|
|---|---|---|
|
||||||
|
| `<podcast:transcript>` | SRT fra Git (eller VTT-konvertert) | Live tekstssynkronisert teksting |
|
||||||
|
| `<podcast:person>` | `entities` med `type = 'person'` + `avatar_url` | Gjeste-/vertsprofiler med bilde |
|
||||||
|
| `<podcast:chapters>` | Segmenter (tidsstemplet) | Klikkbare kapitler |
|
||||||
|
| `<podcast:soundbite>` | Aha-markører (hvis implementert) | Utvalgte høydepunkter |
|
||||||
|
|
||||||
|
## Gjennomføring
|
||||||
|
1. Utvid SvelteKit RSS-generatoren med Podcasting 2.0 namespace: `xmlns:podcast="https://podcastindex.org/namespace/1.0"`
|
||||||
|
2. Per episode: generer `<podcast:transcript>` med URL til SRT/VTT-fil
|
||||||
|
3. Per episode: generer `<podcast:person>` for aktører koblet til episoden via `DISCUSSED_IN`/`MENTIONS`-edges
|
||||||
|
4. Per episode: generer `<podcast:chapters>` fra segmenter
|
||||||
|
|
||||||
|
## Åpne spørsmål
|
||||||
|
- VTT vs SRT for transkripsjoner? VTT er standarden for web, men SRT er vår master. Konvertering er triviell.
|
||||||
|
- Hvor mange apper støtter dette faktisk i dag? Nok til at det er verdt det.
|
||||||
|
|
||||||
|
## Innsats: Lav
|
||||||
|
## Wow-faktor: Høy
|
||||||
122
docs/proposals/tekst_primitiv.md
Normal file
122
docs/proposals/tekst_primitiv.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Forslag: Tekst-primitiv
|
||||||
|
|
||||||
|
## Idé
|
||||||
|
Det finnes ingen forskjell mellom en chatmelding og en artikkel — bare ulike stadier av samme ting. Enhver tekst starter som det enkleste ("hei") og kan vokse til hva som helst: få en tittel, bli rik-formatert, dras inn i en kalender, publiseres på web. Alt er samme node, samme primitiv. Brukeren bestemmer aldri "type" på forhånd — de bare skriver, og utvider når det føles naturlig.
|
||||||
|
|
||||||
|
## Kjerneprinsipp: Enhver melding kan vokse
|
||||||
|
|
||||||
|
```
|
||||||
|
"hei" → ren tekst
|
||||||
|
"hei, her er mine tanker..." → brukeren utvider → toolbar dukker opp
|
||||||
|
+ tittel → den har nå et navn
|
||||||
|
+ overskrifter, lister → strukturert innhold
|
||||||
|
+ bilder, LaTeX, embeds → rikt innhold
|
||||||
|
+ kanban_card_view → oppgave
|
||||||
|
+ calendar_event_view → kalenderhendelse
|
||||||
|
+ article_view → publiserbar artikkel
|
||||||
|
```
|
||||||
|
|
||||||
|
Ingen "oppgradering", ingen "konvertering", ingen modusskifte i data. Noden er den samme hele veien. View-configs legges til og fjernes fritt — de er additive, aldri destruktive.
|
||||||
|
|
||||||
|
### Hva dette betyr i praksis
|
||||||
|
- En chatmelding i #Mediepolitikk kan få en tittel → den er nå et notat
|
||||||
|
- Det notatet kan dras til kanban → det er nå også en oppgave
|
||||||
|
- Samme notat kan få `article_view` → det er nå publiserbart
|
||||||
|
- Alt uten å kopiere, flytte, eller miste kontekst (reply_to-kjeden, graf-edges, reaksjoner)
|
||||||
|
|
||||||
|
Dette er meldingsboks-filosofien tatt til sin logiske konklusjon: ikke bare "én primitiv, flere views", men "én primitiv som vokser organisk med brukerens intensjon".
|
||||||
|
|
||||||
|
## Hvorfor er dette interessant?
|
||||||
|
|
||||||
|
### Rekkefølgeproblemet
|
||||||
|
Brukeren vet sjelden på forhånd hva en tekst skal bli. Man skriver, tenker, og innser: "dette bør bli noe mer". Ved å la enhver melding vokse forsvinner problemet — veien fra tanke til publisering er bare å legge til, aldri å starte på nytt.
|
||||||
|
|
||||||
|
### Konsistens
|
||||||
|
- Én primitiv i hele systemet (meldingsboksen)
|
||||||
|
- `#`-mentions fungerer overalt — chat, notater, artikler — samme mekanisme
|
||||||
|
- Graf-koblinger er universelle — en mention i chat og en mention i en publisert artikkel er identisk
|
||||||
|
- Versjonshistorikk (`message_revisions`) følger med uansett hvor teksten ender opp
|
||||||
|
|
||||||
|
## Hva bygger den på?
|
||||||
|
- **Meldingsboks** — enhver tekst er en melding (node i grafen)
|
||||||
|
- **Kunnskapsgraf** — `#`-mentions oppretter graf-edges uansett kontekst
|
||||||
|
- **Message revisions** — revisjonshistorikk allerede på plass
|
||||||
|
- **View-configs** — kanban_card_view, calendar_event_view, article_view er bare pekere
|
||||||
|
|
||||||
|
## Skisse
|
||||||
|
|
||||||
|
### Visibility-trappen
|
||||||
|
En melding kan bevege seg gjennom synlighetsnivåer uten å endre identitet:
|
||||||
|
|
||||||
|
```
|
||||||
|
'private' → kun forfatter (kladd, personlig notat)
|
||||||
|
'workspace' → alle i workspacet (delt internt)
|
||||||
|
'link' → hvem som helst med URL-en (Google Docs-modell, ingen indeksering)
|
||||||
|
'public' → publisert (indeksert, i feeds, full SEO)
|
||||||
|
```
|
||||||
|
|
||||||
|
Hvert steg er reversibelt. En publisert artikkel kan trekkes tilbake til `'workspace'`. En privat kladd kan deles med én lenke uten å bli offentlig.
|
||||||
|
|
||||||
|
### Delbare URL-er
|
||||||
|
Enhver melding med `visibility: 'link'` eller `'public'` får en URL:
|
||||||
|
```
|
||||||
|
sidelinja.org/delt/<uuid> → enkel deling (lesbar med lenke, ingen SEO)
|
||||||
|
sidelinja.org/@vegard/<slug> → publisert artikkel (SEO, feed, OG-tags)
|
||||||
|
sidelinja.org/pub/<publikasjon>/<slug> → kuratert artikkel (se artikkel-publisering)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `article_view` — publiseringslaget
|
||||||
|
Når en tekst skal publiseres, legges en view-config til:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE article_view (
|
||||||
|
message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft', -- 'draft', 'review', 'published', 'archived'
|
||||||
|
excerpt TEXT,
|
||||||
|
body_html TEXT, -- Pre-rendret HTML (KaTeX + editor → HTML)
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT unique_slug_per_workspace UNIQUE (message_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offentlig vs intern kontekst
|
||||||
|
En publisert artikkel har to ansikter:
|
||||||
|
|
||||||
|
**Inne i workspacet:** Artikkelen er en melding i en tråd. Den har `reply_to` til meldingen som startet diskusjonen, interne svar, graf-koblinger. Full kontekst.
|
||||||
|
|
||||||
|
**Ute på web:** Artikkelen er en frittstående tekst. Den interne konteksten er usynlig. Offentlige kommentarer lever i en separat kanal (`visibility: 'public'`) som workspace-medlemmer kan se, men som ikke blander seg med den interne diskusjonen.
|
||||||
|
|
||||||
|
```
|
||||||
|
article_node (melding i workspace)
|
||||||
|
├── reply_to → intern forelder (usynlig utenfra)
|
||||||
|
├── channel: #Mediepolitikk (intern diskusjon)
|
||||||
|
└── public_comment_channel (offentlige kommentarer, separat)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Åpne spørsmål
|
||||||
|
|
||||||
|
### Versjoner og kladder
|
||||||
|
- Er det nok med `message_revisions` (lineær historikk), eller trengs navngitte versjoner / snapshots?
|
||||||
|
- Bør ulike versjoner kunne ha ulik visibility? ("kladd 1 er privat, kladd 2 er delt med lenke, publisert versjon er offentlig")
|
||||||
|
- Eller er det enklere: én tekst, én visibility, revisjonshistorikk under panseret?
|
||||||
|
|
||||||
|
### Grensen melding ↔ artikkel
|
||||||
|
- Teknisk: ingen grense. En melding med `article_view` er en artikkel.
|
||||||
|
- UX-messig: når tilbyr systemet "vil du publisere dette?" Manuelt via meny? Automatisk forslag når teksten når en viss lengde/kompleksitet?
|
||||||
|
|
||||||
|
### Offentlige kommentarer
|
||||||
|
- Hvem kan kommentere? Anonymt, autentisert, inviterte?
|
||||||
|
- Enkleste start: ingen offentlige kommentarer. Artikler er read-only for publikum. Diskusjon skjer internt eller via eksterne kanaler.
|
||||||
|
|
||||||
|
## Innsats: Lav (prinsipp) / Middels (article_view + visibility)
|
||||||
|
Prinsippet krever ingen ny kode — meldingsboksen støtter det allerede. `article_view`-tabell og visibility-utvidelse er overkommelig. Den tunge delen er editoren (se `editor.md`).
|
||||||
|
|
||||||
|
## Wow-faktor: Middels–Høy
|
||||||
|
Filosofien — at enhver tekst kan bli hva som helst — er enableren for personlig workspace, artikkel-publisering, samarbeid og kurasjon.
|
||||||
|
|
||||||
|
## Relasjon til andre proposals
|
||||||
|
- **Editor** — det tekniske skriveverktøyet som gjør alt dette mulig i UI
|
||||||
|
- **Personlig workspace** — konteksten der brukeren kladder og publiserer
|
||||||
|
- **Artikkel-publisering** — publiseringsmodellen (publikasjoner, kuratorer, feeds) bygger på tekst-primitiven
|
||||||
|
- **Meldingsboks** (feature) — tekst-primitiven er den naturlige utviklingen: "én primitiv som vokser med brukerens intensjon"
|
||||||
53
docs/proposals/waveforms.md
Normal file
53
docs/proposals/waveforms.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Forslag: Visuelle Waveforms som UI-primitiv
|
||||||
|
|
||||||
|
## Idé
|
||||||
|
Podcast handler om lyd, men i Sidelinja-editoren er lyd foreløpig kun tekst eller en usynlig boks. Generer komprimerte audio-peaks fra lydfiler og rendrer dem som visuelle bølgeformer (SoundCloud-stil) overalt der lyd refereres — i editoren, i segment-embeds, i Podcastfabrikken.
|
||||||
|
|
||||||
|
## Hvorfor er dette interessant?
|
||||||
|
- Gjør manuell redigering og verifisering av transkripsjoner mye mer intuitivt
|
||||||
|
- Tidsstempler og Aha-markører kan lyse opp oppå bølgene
|
||||||
|
- Visuell representasjon av lyd er forventet i 2026 — ren tekst føles utdatert
|
||||||
|
- `{{segment:uuid}}` i editoren kan rendres som en interaktiv bølgeform i stedet for en flat lydspiller
|
||||||
|
|
||||||
|
## Hva bygger den på?
|
||||||
|
- **Podcastfabrikken** — lydfilene og segmentene
|
||||||
|
- **Jobbkø** — ny jobbtype `generate_waveform`
|
||||||
|
- **Editor** — `{{segment:uuid}}`-embeds rendrer waveform i stedet for enkel player
|
||||||
|
- **Studioet** — Aha-markører vises oppå bølgeformen
|
||||||
|
|
||||||
|
## Gjennomføring
|
||||||
|
|
||||||
|
### 1. Generere peaks
|
||||||
|
Utvid `whisper_transcribe`-jobben (eller opprett separat `generate_waveform`-jobb) til å generere en komprimert array med audio-peaks:
|
||||||
|
|
||||||
|
```
|
||||||
|
Verktøy: audiowaveform (BBC, C++, rask) eller ffmpeg
|
||||||
|
Input: MP3/WAV
|
||||||
|
Output: JSON-array med peaks (f.eks. 800 datapunkter per minutt)
|
||||||
|
Lagring: media_files.metadata (JSONB) eller separat fil
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Svelte-komponent: `<Waveform>`
|
||||||
|
- Rendrer peaks som SVG eller Canvas
|
||||||
|
- Klikkbar: hopp til tidspunkt i lyden
|
||||||
|
- Overlay: Aha-markører, segment-grenser, talerskifter
|
||||||
|
- Responsiv: tilpasser seg containerbredde
|
||||||
|
- Brukes i: editor-embeds, Podcastfabrikken, segment-visning
|
||||||
|
|
||||||
|
### 3. Editor-integrasjon
|
||||||
|
`{{segment:uuid}}` i rendered-modus viser:
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ ▶ Episode 42, 14:23–21:07 │
|
||||||
|
│ ░▓▓░▓▓▓░░▓▓░▓░░▓▓▓▓░░▓░░▓▓░▓▓▓░░▓▓░▓ │
|
||||||
|
│ ^Aha ^Støre nevnt │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Åpne spørsmål
|
||||||
|
- Peaks per minutt: 800 er nok for de fleste visninger. Trenger vi flere nivåer (zoom)?
|
||||||
|
- Farger: Mono-farge eller fargekode per taler (krever diarisering)?
|
||||||
|
- Lagring: Inline i JSONB eller som separat `.json`-fil i media/?
|
||||||
|
|
||||||
|
## Innsats: Lav–Middels
|
||||||
|
## Wow-faktor: Høy
|
||||||
43
docs/proposals/web_clipper.md
Normal file
43
docs/proposals/web_clipper.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Forslag: Web Clipper / "Send til Sidelinja"
|
||||||
|
|
||||||
|
## Idé
|
||||||
|
Redaksjonell research skjer oftest utenfor Sidelinja — når man leser VG, Aftenposten, eller PDF-er. En minimal "Send til Sidelinja"-mekanisme lar brukeren sende en URL til sin personlige innboks, der AI-en oppsummerer, trekker ut aktører og lager et ferdig research-klipp.
|
||||||
|
|
||||||
|
## Hvorfor er dette interessant?
|
||||||
|
- Fjerner friksjonen mellom "leste noe interessant" og "la det inn i systemet"
|
||||||
|
- Utnytter eksisterende infrastruktur: jobbkø, AI Gateway, kunnskapsgraf
|
||||||
|
- Research-klipp lander som meldingsbokser — kan bli kanban-kort, faktoider, artikkel-grunnlag
|
||||||
|
|
||||||
|
## Hva bygger den på?
|
||||||
|
- **Jobbkø** — ny jobbtype `url_ingest`
|
||||||
|
- **AI Gateway** — oppsummering og entity-uttrekk
|
||||||
|
- **Meldingsboks** — resultatet er en vanlig melding med `#`-mentions og graf-edges
|
||||||
|
- **Kunnskapsgraf** — kobler research til eksisterende entiteter
|
||||||
|
- **Personlig workspace** — innboks for ubehandlet research
|
||||||
|
|
||||||
|
## Gjennomføring
|
||||||
|
|
||||||
|
### Innsendingsmetoder (velg én eller flere)
|
||||||
|
1. **Share Target (PWA):** SvelteKit PWA registrerer seg som share target på mobil. Brukeren trykker "Del" i nettleseren → Sidelinja mottar URL-en. Krever kun en `share_target`-entry i `manifest.json` + et API-endepunkt.
|
||||||
|
2. **Chrome-utvidelse:** Minimal popup med "Send til Sidelinja" + workspace-velger. POST til `/api/clip`.
|
||||||
|
3. **Bookmarklet:** JavaScript-bookmarklet som sender `window.location.href` til API-et. Zero install.
|
||||||
|
|
||||||
|
### Pipeline
|
||||||
|
```
|
||||||
|
URL mottatt via /api/clip
|
||||||
|
→ Opprett melding i personlig innboks (umiddelbart synlig for bruker)
|
||||||
|
→ Legg jobb i køen: url_ingest (prioritet 5)
|
||||||
|
→ Rust-worker:
|
||||||
|
1. Hent HTML (eller bruk readability-parser for ren tekst)
|
||||||
|
2. Send til AI Gateway (sidelinja/rutine): "Oppsummer, identifiser aktører"
|
||||||
|
3. Oppdater meldingen med oppsummering, kilde-URL, foreslåtte #-mentions
|
||||||
|
4. Brukeren godkjenner mentions → graf-edges opprettes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Åpne spørsmål
|
||||||
|
- Paywall-innhold? Brukeren ser det i nettleseren, men workeren kan ikke hente det. Løsning: Send full tekst fra klienten i stedet for bare URL?
|
||||||
|
- Batching? "Legg til 5 artikler i kø" eller én-og-én?
|
||||||
|
- Automatisk duplikat-deteksjon? Sjekk om URL-en allerede er klippet.
|
||||||
|
|
||||||
|
## Innsats: Lav–Middels
|
||||||
|
## Wow-faktor: Høy
|
||||||
751
web/package-lock.json
generated
751
web/package-lock.json
generated
|
|
@ -13,6 +13,11 @@
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.55.0",
|
"@sveltejs/kit": "^2.55.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/extension-mention": "^3.20.1",
|
||||||
|
"@tiptap/extension-placeholder": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1",
|
||||||
|
"@tiptap/starter-kit": "^3.20.1",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"spacetimedb": "^2.0.4",
|
"spacetimedb": "^2.0.4",
|
||||||
"svelte": "^5.53.12",
|
"svelte": "^5.53.12",
|
||||||
|
|
@ -211,6 +216,12 @@
|
||||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@remirror/core-constants": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.9",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
||||||
|
|
@ -964,6 +975,422 @@
|
||||||
"vite": "^8.0.0-beta.7 || ^8.0.0"
|
"vite": "^8.0.0-beta.7 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-WzNXk/63PQI2fav4Ta6P0GmYRyu8Gap1pV3VUqaVK829iJ6Zt1T21xayATHEHWMK27VT1GLPJkx9Ycr2jfDyQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-fz++Qv6Rk/Hov0IYG/r7TJ1Y4zWkuGONe0UN5g0KY32NIMg3HeOHicbi4xsNWTm9uAOl3eawWDkezEMrleObMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-mbrlvOZo5OF3vLhp+3fk9KuL/6J/wsN0QxF6ZFRAHzQ9NkJdtdfARcBeBnkWXGN8inB6YxbTGY1/E4lmBkOpOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-509DHINIA/Gg+eTG7TEkfsS8RUiPLH5xZNyLRT0A1oaoaJmECKfrV6aAm05IdfTyqDqz6LW5pbnX6DdUC4keug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-vKejwBq+Nlj4Ybd3qOyDxIQKzYymdNH+8eXkKwGShk2nfLJIxq69DCyGvmuHgipIO1qcYPJ149UNpGN+YGcdmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-9vrqdGmRV7bQCSY3NLgu7UhIwgOCDp4sKqMNsoNRX0aZ021QQMTvBQDPkiRkCf7MNsnWrNNnr52PVnULEn3vFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-K18L9FX4znn+ViPSIbTLOGcIaXMx/gLNwAPE8wPLwswbHhQqdiY1zzdBw6drgOc1Hicvebo2dIoUlSXOZsOEcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-kZOtttV6Ai8VUAgEng3h4WKFbtdSNJ6ps7r0cRPY+FctWhVmgNb/JJwwyC+vSilR7nRENAhrA/Cv/RxVlvLw+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-9sKpmg/IIdlLXimYWUZ3PplIRcehv4Oc7V1miTqlnAthMzjMqigDkjjgte4JZV67RdnDJTQkRw8TklCAU28Emg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-unudyfQP6FxnyWinxvPqe/51DG91J6AaJm666RnAubgYMCgym+33kBftx4j4A6qf+ddWYbD00thMNKOnVLjAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-rjFKFXNntdl0jay8oIGFvvykHlpyQTLmrH3Ag2fj3i8yh6MVvqhtaDomYQbw5sxECd5hBkL+T4n2d2DRuVw/QQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-ZYRX13Kt8tR8JOzSXirH3pRpi8x30o7LHxZY58uXBdUvr3tFzOkh03qbN523+diidSVeHP/aMd/+IrplHRkQug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-oYTTIgsQMqpkSnJAuAc+UtIKMuI4lv9e1y4LfI1iYm6NkEUHhONppU59smhxHLzb3Ww7YpDffbp5IgDTAiJztA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-euBRAn0mkV7R2VEE+AuOt3R0j9RHEMFXamPFmtvTo8IInxDClusrm6mJoDjS8gCGAXsQCRiAe1SCQBPgGbOOwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-tzgnyTW82lYJkUnadYbatwkI9dLz/OWRSWuFpQPRje/ItmFMWuQ9c9NDD8qLbXPdEYnvrgSAA+ipCD/1G0qA0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-Dr0xsQKx0XPOgDg7xqoWwfv7FFwZ3WeF3eOjqh3rDXlNHMj1v+UW5cj1HLphrsAZHTrVTn2C+VWPJkMZrSbpvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-mention": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-KOGokj7oH1QpcM8P02V+o6wHsVE0g7XEtdIy2vtq2vlFE3npNNNFkMa8F8VWX6qyC+VeVrNU6SIzS5MFY2TORA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1",
|
||||||
|
"@tiptap/suggestion": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-Y+3Ad7OwAdagqdYwCnbqf7/to5ypD4NnUNHA0TXRCs7cAHRA8AdgPoIcGFpaaSpV86oosNU3yfeJouYeroffog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-QFrAtXNyv7JSnomMQc1nx5AnG9mMznfbYJAbdOQYVdbLtAzTfiTuNPNbQrufy5ZGtGaHxDCoaygu2QEfzaKG+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-placeholder": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-k+jfbCugYGuIFBdojukgEopGazIMOgHrw46FnyN2X/6ICOIjQP2rh2ObslrsUOsJYoEevxCsNF9hZl1HvWX66g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-EYgyma10lpsY+rwbVQL9u+gA7hBlKLSMFH7Zgd37FSxukOjr+HE8iKPQQ+SwbGejyDsPlLT8Z5Jnuxo5Ng90Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-7PlIbYW8UenV6NPOXHmv8IcmPGlGx6HFq66RmkJAOJRPXPkTLAiX0N8rQtzUJ6jDEHqoJpaHFEHJw0xzW1yF+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-fmHvDKzwCgnZUwRreq8tYkb1YyEwgzZ6QQkAQ0CsCRtvRMqzerr3Duz0Als4i8voZTuGDEL3VR6nAJbLAb/wPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-JRc/v+OBH0qLTdvQ7HvHWTxGJH73QOf1MC0R8NhOX2QnAbg2mPFv1h+FjGa2gfLGuCXBdWQomjekWkUKbC4e5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-markdown": "^1.13.1",
|
||||||
|
"prosemirror-menu": "^1.2.4",
|
||||||
|
"prosemirror-model": "^1.24.1",
|
||||||
|
"prosemirror-schema-basic": "^1.2.3",
|
||||||
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.6.4",
|
||||||
|
"prosemirror-trailing-node": "^3.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2",
|
||||||
|
"prosemirror-view": "^1.38.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-opqWxL/4OTEiqmVC0wsU4o3JhAf6LycJ2G/gRIZVAIFLljI9uHfpPMTFGxZ5w9IVVJaP5PJysfwW/635kKqkrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/extension-blockquote": "^3.20.1",
|
||||||
|
"@tiptap/extension-bold": "^3.20.1",
|
||||||
|
"@tiptap/extension-bullet-list": "^3.20.1",
|
||||||
|
"@tiptap/extension-code": "^3.20.1",
|
||||||
|
"@tiptap/extension-code-block": "^3.20.1",
|
||||||
|
"@tiptap/extension-document": "^3.20.1",
|
||||||
|
"@tiptap/extension-dropcursor": "^3.20.1",
|
||||||
|
"@tiptap/extension-gapcursor": "^3.20.1",
|
||||||
|
"@tiptap/extension-hard-break": "^3.20.1",
|
||||||
|
"@tiptap/extension-heading": "^3.20.1",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^3.20.1",
|
||||||
|
"@tiptap/extension-italic": "^3.20.1",
|
||||||
|
"@tiptap/extension-link": "^3.20.1",
|
||||||
|
"@tiptap/extension-list": "^3.20.1",
|
||||||
|
"@tiptap/extension-list-item": "^3.20.1",
|
||||||
|
"@tiptap/extension-list-keymap": "^3.20.1",
|
||||||
|
"@tiptap/extension-ordered-list": "^3.20.1",
|
||||||
|
"@tiptap/extension-paragraph": "^3.20.1",
|
||||||
|
"@tiptap/extension-strike": "^3.20.1",
|
||||||
|
"@tiptap/extension-text": "^3.20.1",
|
||||||
|
"@tiptap/extension-underline": "^3.20.1",
|
||||||
|
"@tiptap/extensions": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/suggestion": {
|
||||||
|
"version": "3.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.20.1.tgz",
|
||||||
|
"integrity": "sha512-ng7olbzgZhWvPJVJygNQK5153CjquR2eJXpkLq7bRjHlahvt4TH4tGFYvGdYZcXuzbe2g9RoqT7NaPGL9CUq9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
|
|
@ -986,6 +1413,28 @@
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||||
|
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
|
|
@ -1023,6 +1472,12 @@
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||||
|
|
@ -1101,6 +1556,12 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
|
@ -1125,6 +1586,30 @@
|
||||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-string-regexp": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esm-env": {
|
"node_modules/esm-env": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||||
|
|
@ -1502,6 +1987,21 @@
|
||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/locate-character": {
|
"node_modules/locate-character": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
|
|
@ -1517,6 +2017,29 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
|
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.0",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
|
|
@ -1585,6 +2108,12 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
|
|
@ -1684,6 +2213,210 @@
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-collab": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-inputrules": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.0.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-menu": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crelt": "^1.0.0",
|
||||||
|
"prosemirror-commands": "^1.0.0",
|
||||||
|
"prosemirror-history": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-basic": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-trailing-node": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remirror/core-constants": "3.0.0",
|
||||||
|
"escape-string-regexp": "^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.22.1",
|
||||||
|
"prosemirror-state": "^1.4.2",
|
||||||
|
"prosemirror-view": "^1.33.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
|
||||||
|
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.20.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pure-rand": {
|
"node_modules/pure-rand": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||||
|
|
@ -1811,6 +2544,12 @@
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sade": {
|
"node_modules/sade": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
|
|
@ -2032,6 +2771,12 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/url-polyfill": {
|
"node_modules/url-polyfill": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
||||||
|
|
@ -2135,6 +2880,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/zimmerframe": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.55.0",
|
"@sveltejs/kit": "^2.55.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tiptap/core": "^3.20.1",
|
||||||
|
"@tiptap/extension-mention": "^3.20.1",
|
||||||
|
"@tiptap/extension-placeholder": "^3.20.1",
|
||||||
|
"@tiptap/pm": "^3.20.1",
|
||||||
|
"@tiptap/starter-kit": "^3.20.1",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"spacetimedb": "^2.0.4",
|
"spacetimedb": "^2.0.4",
|
||||||
"svelte": "^5.53.12",
|
"svelte": "^5.53.12",
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,35 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { createChat } from '$lib/chat/create.svelte';
|
import { createChat } from '$lib/chat/create.svelte';
|
||||||
import type { Message, ChatConnection } from '$lib/chat/types';
|
import type { Message, ChatConnection } from '$lib/chat/types';
|
||||||
|
import Editor from '$lib/components/Editor.svelte';
|
||||||
|
|
||||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
|
||||||
const channelId = props.channelId as string | undefined;
|
const channelId = props.channelId as string | undefined;
|
||||||
|
|
||||||
let chat = $state<ChatConnection | null>(null);
|
let chat = $state<ChatConnection | null>(null);
|
||||||
let input = $state('');
|
|
||||||
let sending = $state(false);
|
let sending = $state(false);
|
||||||
let messagesEl: HTMLDivElement | undefined;
|
let messagesEl: HTMLDivElement | undefined;
|
||||||
let inputEl: HTMLTextAreaElement | undefined;
|
|
||||||
|
|
||||||
async function send() {
|
async function handleSubmit(html: string, json: Record<string, unknown>, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) {
|
||||||
if (!chat || !input.trim() || sending) return;
|
if (!chat || sending) return;
|
||||||
const body = input.trim();
|
|
||||||
input = '';
|
|
||||||
sending = true;
|
sending = true;
|
||||||
try {
|
try {
|
||||||
await chat.send(body);
|
await chat.send(html, mentions.length > 0 ? mentions : undefined);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
} finally {
|
} finally {
|
||||||
sending = false;
|
sending = false;
|
||||||
requestAnimationFrame(() => inputEl?.focus());
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessageClick(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.classList.contains('mention') && target.dataset.id) {
|
||||||
|
e.preventDefault();
|
||||||
|
goto(`/entities/${target.dataset.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,13 +39,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
|
return new Date(iso).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +107,9 @@
|
||||||
<span class="author">{msg.author_name ?? 'Ukjent'}</span>
|
<span class="author">{msg.author_name ?? 'Ukjent'}</span>
|
||||||
<span class="time">{formatTime(msg.created_at)}</span>
|
<span class="time">{formatTime(msg.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-body">{msg.body}</div>
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="message-body" onclick={handleMessageClick}>{@html msg.body}</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -124,23 +124,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<textarea
|
<Editor
|
||||||
bind:this={inputEl}
|
mode="compact"
|
||||||
bind:value={input}
|
|
||||||
onkeydown={onKeydown}
|
|
||||||
placeholder="Skriv en melding..."
|
placeholder="Skriv en melding..."
|
||||||
rows="1"
|
onSubmit={handleSubmit}
|
||||||
></textarea>
|
autofocus
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -207,10 +196,48 @@
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #e1e4e8;
|
color: #e1e4e8;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Render HTML from Tiptap */
|
||||||
|
.message-body :global(p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :global(p + p) {
|
||||||
|
margin-top: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :global(strong) {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :global(code) {
|
||||||
|
background: #1e2235;
|
||||||
|
padding: 0.1em 0.25em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :global(.mention) {
|
||||||
|
color: #8b5cf6;
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
padding: 0.05em 0.25em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :global(.mention:hover) {
|
||||||
|
background: rgba(139, 92, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body :global(.mention::before) {
|
||||||
|
content: '#';
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -227,58 +254,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-row {
|
.input-row {
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
border-top: 1px solid #2d3148;
|
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 {
|
.no-channel {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { createNote } from '$lib/notes/create.svelte';
|
import { createNote } from '$lib/notes/create.svelte';
|
||||||
import type { NoteConnection } from '$lib/notes/types';
|
import type { NoteConnection } from '$lib/notes/types';
|
||||||
|
import Editor from '$lib/components/Editor.svelte';
|
||||||
|
|
||||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@
|
||||||
conn?.save({ title });
|
conn?.save({ title });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContentInput() {
|
function handleContentUpdate() {
|
||||||
conn?.save({ content });
|
conn?.save({ content });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,12 +62,14 @@
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
oninput={handleTitleInput}
|
oninput={handleTitleInput}
|
||||||
/>
|
/>
|
||||||
<textarea
|
|
||||||
class="note-content"
|
<Editor
|
||||||
|
mode="extended"
|
||||||
placeholder="Skriv her..."
|
placeholder="Skriv her..."
|
||||||
bind:value={content}
|
bind:content={content}
|
||||||
oninput={handleContentInput}
|
onUpdate={handleContentUpdate}
|
||||||
></textarea>
|
/>
|
||||||
|
|
||||||
<div class="note-footer">
|
<div class="note-footer">
|
||||||
{#if conn?.saving}
|
{#if conn?.saving}
|
||||||
<span class="status saving">Lagrer...</span>
|
<span class="status saving">Lagrer...</span>
|
||||||
|
|
@ -110,27 +113,6 @@
|
||||||
color: #8b92a5;
|
color: #8b92a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-content {
|
|
||||||
flex: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #e1e4e8;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: 1.6;
|
|
||||||
resize: none;
|
|
||||||
padding: 0;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-content:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-content::placeholder {
|
|
||||||
color: #8b92a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-footer {
|
.note-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Message, ChatConnection } from './types';
|
import type { Message, ChatConnection, MentionRef } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat-adapter som poller PostgreSQL via REST API.
|
* Chat-adapter som poller PostgreSQL via REST API.
|
||||||
|
|
@ -26,13 +26,13 @@ export function createPgChat(channelId: string): ChatConnection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(body: string) {
|
async function send(body: string, mentions?: MentionRef[]) {
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ body })
|
body: JSON.stringify({ body, mentions })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Feil ved sending');
|
if (!res.ok) throw new Error('Feil ved sending');
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Message, ChatConnection, ChatUser } from './types';
|
import type { Message, ChatConnection, ChatUser, MentionRef } from './types';
|
||||||
import { DbConnection, type EventContext } from './module_bindings';
|
import { DbConnection, type EventContext } from './module_bindings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -101,32 +101,34 @@ export function createSpacetimeChat(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(body: string) {
|
async function send(body: string, mentions?: MentionRef[]) {
|
||||||
if (conn && connected) {
|
// Alltid send via PG API — dette oppretter noden og MENTIONS-edges atomisk.
|
||||||
// Send via SpacetimeDB — umiddelbar push til alle klienter
|
// SpacetimeDB brukes kun for real-time push til andre klienter.
|
||||||
try {
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body, mentions })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Feil ved sending');
|
||||||
|
|
||||||
|
// Push til SpacetimeDB for sanntidsvisning hos andre klienter
|
||||||
|
if (conn && connected) {
|
||||||
|
try {
|
||||||
|
const msg = await res.clone().json();
|
||||||
conn.reducers.sendMessage({
|
conn.reducers.sendMessage({
|
||||||
id: crypto.randomUUID(),
|
id: msg.id,
|
||||||
channelId,
|
channelId,
|
||||||
workspaceId: '',
|
workspaceId: '',
|
||||||
authorName: user.name,
|
authorName: user.name,
|
||||||
body,
|
body,
|
||||||
replyTo: ''
|
replyTo: ''
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fall gjennom til PG
|
// Ikke kritisk — PG er allerede oppdatert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
await loadFromPg();
|
||||||
} catch {
|
} catch {
|
||||||
error = 'Kunne ikke sende melding';
|
error = 'Kunne ikke sende melding';
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@ export interface Message {
|
||||||
reply_to: string | null;
|
reply_to: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MentionRef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Felles kontrakt for chat-tilkoblinger.
|
* Felles kontrakt for chat-tilkoblinger.
|
||||||
* Implementeres av PG-polling og SpacetimeDB.
|
* Implementeres av PG-polling og SpacetimeDB.
|
||||||
|
|
@ -22,6 +29,6 @@ export interface ChatConnection {
|
||||||
readonly messages: Message[];
|
readonly messages: Message[];
|
||||||
readonly error: string;
|
readonly error: string;
|
||||||
readonly connected: boolean;
|
readonly connected: boolean;
|
||||||
send(body: string): Promise<void>;
|
send(body: string, mentions?: MentionRef[]): Promise<void>;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
720
web/src/lib/components/Editor.svelte
Normal file
720
web/src/lib/components/Editor.svelte
Normal file
|
|
@ -0,0 +1,720 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Universell editor-komponent.
|
||||||
|
* Fase 1: Tiptap med plaintext, #-mentions, markdown formatting.
|
||||||
|
*
|
||||||
|
* Modi:
|
||||||
|
* compact — chat-input (Enter = submit, ingen synlig toolbar)
|
||||||
|
* extended — notater/lengre tekst (Enter = newline, toolbar synlig)
|
||||||
|
*
|
||||||
|
* Bruk:
|
||||||
|
* <Editor mode="compact" onSubmit={(content) => ...} placeholder="Skriv en melding..." />
|
||||||
|
* <Editor mode="extended" bind:content onUpdate={() => ...} placeholder="Skriv her..." />
|
||||||
|
*/
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import Mention from '@tiptap/extension-mention';
|
||||||
|
|
||||||
|
interface Entity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
mode = 'compact',
|
||||||
|
placeholder = '',
|
||||||
|
content = $bindable(''),
|
||||||
|
autofocus = false,
|
||||||
|
onSubmit = (_html: string, _json: Record<string, unknown>, _mentions: Entity[]) => {},
|
||||||
|
onUpdate = () => {},
|
||||||
|
onExpand = () => {}
|
||||||
|
}: {
|
||||||
|
mode?: 'compact' | 'extended';
|
||||||
|
placeholder?: string;
|
||||||
|
content?: string;
|
||||||
|
autofocus?: boolean;
|
||||||
|
onSubmit?: (html: string, json: Record<string, unknown>, mentions: Entity[]) => void;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
onExpand?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let editorEl: HTMLDivElement;
|
||||||
|
let editor = $state<Editor | null>(null);
|
||||||
|
let mentions: Entity[] = [];
|
||||||
|
let expanded = $state(false);
|
||||||
|
let hasContent = $state(false);
|
||||||
|
let rawMode = $state(false);
|
||||||
|
let rawContent = $state('');
|
||||||
|
|
||||||
|
// Mention suggestions state
|
||||||
|
let suggestions = $state<Entity[]>([]);
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
let suggestionsEl: HTMLDivElement | undefined;
|
||||||
|
let mentionPopupPos = $state<{ top: number; left: number } | null>(null);
|
||||||
|
let mentionCommandFn: ((item: any) => void) | null = null;
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
person: '#3b82f6',
|
||||||
|
organisasjon: '#f59e0b',
|
||||||
|
sted: '#10b981',
|
||||||
|
tema: '#8b5cf6',
|
||||||
|
konsept: '#ec4899'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEditorContent() {
|
||||||
|
if (!editor) return { html: '', json: {} };
|
||||||
|
return {
|
||||||
|
html: editor.getHTML(),
|
||||||
|
json: editor.getJSON()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEditor() {
|
||||||
|
editor?.commands.clearContent();
|
||||||
|
mentions = [];
|
||||||
|
hasContent = false;
|
||||||
|
expanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchEntities(query: string): Promise<Entity[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/entities?q=${encodeURIComponent(query)}&limit=8`);
|
||||||
|
if (res.ok) return await res.json();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!editor || !hasContent) return;
|
||||||
|
const { html, json } = getEditorContent();
|
||||||
|
onSubmit(html, json, [...mentions]);
|
||||||
|
clearEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
editor = new Editor({
|
||||||
|
element: editorEl,
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder
|
||||||
|
}),
|
||||||
|
Mention.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'mention'
|
||||||
|
},
|
||||||
|
suggestion: {
|
||||||
|
char: '#',
|
||||||
|
items: async ({ query }: { query: string }) => {
|
||||||
|
if (query.length < 1) return [];
|
||||||
|
return await searchEntities(query);
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
return {
|
||||||
|
onStart: (props: any) => {
|
||||||
|
mentionCommandFn = props.command;
|
||||||
|
suggestions = props.items;
|
||||||
|
selectedIndex = 0;
|
||||||
|
updatePopupPosition(props.clientRect);
|
||||||
|
},
|
||||||
|
onUpdate: (props: any) => {
|
||||||
|
mentionCommandFn = props.command;
|
||||||
|
suggestions = props.items;
|
||||||
|
selectedIndex = 0;
|
||||||
|
updatePopupPosition(props.clientRect);
|
||||||
|
},
|
||||||
|
onKeyDown: (props: any) => {
|
||||||
|
if (props.event.key === 'ArrowDown') {
|
||||||
|
selectedIndex = (selectedIndex + 1) % suggestions.length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (props.event.key === 'ArrowUp') {
|
||||||
|
selectedIndex = (selectedIndex - 1 + suggestions.length) % suggestions.length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (props.event.key === 'Enter' || props.event.key === 'Tab') {
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
mentionCommandFn?.(suggestions[selectedIndex]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
suggestions = [];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
mentionCommandFn = null;
|
||||||
|
suggestions = [];
|
||||||
|
mentionPopupPos = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
command: ({ editor: ed, range, props: item }: any) => {
|
||||||
|
ed.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(range, [
|
||||||
|
{ type: 'mention', attrs: { id: item.id, label: item.name } },
|
||||||
|
{ type: 'text', text: ' ' }
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
mentions.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
content: content || '',
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: `editor-content ${mode}`,
|
||||||
|
'data-mode': mode
|
||||||
|
},
|
||||||
|
handleKeyDown: (_view, event) => {
|
||||||
|
// Compact mode: Enter = submit (unless shift held or suggestions open)
|
||||||
|
if (mode === 'compact' && event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
if (suggestions.length > 0) return false; // Let mention handler deal with it
|
||||||
|
event.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor: ed }) => {
|
||||||
|
content = ed.getHTML();
|
||||||
|
hasContent = !ed.isEmpty;
|
||||||
|
onUpdate();
|
||||||
|
},
|
||||||
|
autofocus: autofocus ? 'end' : false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
editor?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updatePopupPosition(clientRect: (() => DOMRect | null) | null) {
|
||||||
|
if (!clientRect) return;
|
||||||
|
const rect = clientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const editorRect = editorEl.getBoundingClientRect();
|
||||||
|
mentionPopupPos = {
|
||||||
|
left: rect.left - editorRect.left,
|
||||||
|
top: rect.top - editorRect.top - 4
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand() {
|
||||||
|
expanded = !expanded;
|
||||||
|
if (expanded) onExpand();
|
||||||
|
if (!expanded) rawMode = false;
|
||||||
|
editor?.commands.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRawMode() {
|
||||||
|
if (!editor) return;
|
||||||
|
if (rawMode) {
|
||||||
|
// Switching from raw → rendered: push textarea content into Tiptap
|
||||||
|
editor.commands.setContent(rawContent);
|
||||||
|
content = rawContent;
|
||||||
|
hasContent = !editor.isEmpty;
|
||||||
|
rawMode = false;
|
||||||
|
editor.commands.focus();
|
||||||
|
} else {
|
||||||
|
// Switching from rendered → raw: snapshot HTML into textarea
|
||||||
|
rawContent = editor.getHTML();
|
||||||
|
rawMode = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRawInput(e: Event) {
|
||||||
|
rawContent = (e.target as HTMLTextAreaElement).value;
|
||||||
|
content = rawContent;
|
||||||
|
hasContent = rawContent.replace(/<[^>]*>/g, '').trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||||
|
// Ctrl+/ or Cmd+/ toggles raw mode
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleRawMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="editor-wrapper" class:compact={mode === 'compact'} class:extended={mode === 'extended'} class:expanded onkeydown={handleGlobalKeydown}>
|
||||||
|
{#if mode === 'extended' || expanded}
|
||||||
|
<div class="toolbar">
|
||||||
|
{#if !rawMode}
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('bold')}
|
||||||
|
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||||
|
title="Bold (Ctrl+B)">B</button>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('italic')}
|
||||||
|
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||||
|
title="Italic (Ctrl+I)"><em>I</em></button>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('strike')}
|
||||||
|
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||||
|
title="Strikethrough">S̶</button>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('code')}
|
||||||
|
onclick={() => editor?.chain().focus().toggleCode().run()}
|
||||||
|
title="Inline code">{'<>'}</button>
|
||||||
|
<span class="separator"></span>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 1 })}
|
||||||
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
title="Heading 1">H1</button>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 2 })}
|
||||||
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
title="Heading 2">H2</button>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 3 })}
|
||||||
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
title="Heading 3">H3</button>
|
||||||
|
<span class="separator"></span>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('bulletList')}
|
||||||
|
onclick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||||
|
title="Bullet list">•</button>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('orderedList')}
|
||||||
|
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||||
|
title="Numbered list">1.</button>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('blockquote')}
|
||||||
|
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||||
|
title="Quote">❝</button>
|
||||||
|
<button type="button" class="tool" class:active={editor?.isActive('codeBlock')}
|
||||||
|
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||||
|
title="Code block">{'{ }'}</button>
|
||||||
|
<span class="separator"></span>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="tool raw-toggle" class:active={rawMode}
|
||||||
|
onclick={toggleRawMode}
|
||||||
|
title="Bytt raw/rendered (Ctrl+/)">{'</>'}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
{#if rawMode}
|
||||||
|
<textarea
|
||||||
|
class="raw-editor"
|
||||||
|
value={rawContent}
|
||||||
|
oninput={handleRawInput}
|
||||||
|
spellcheck={false}
|
||||||
|
></textarea>
|
||||||
|
{:else}
|
||||||
|
<div bind:this={editorEl} class="editor-mount"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === 'compact'}
|
||||||
|
<button type="button" class="expand-btn" onclick={toggleExpand} title={expanded ? 'Minimer' : 'Utvid editor'}>
|
||||||
|
{#if expanded}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="4 14 10 14 10 20"></polyline>
|
||||||
|
<polyline points="20 10 14 10 14 4"></polyline>
|
||||||
|
<line x1="14" y1="10" x2="21" y2="3"></line>
|
||||||
|
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="15 3 21 3 21 9"></polyline>
|
||||||
|
<polyline points="9 21 3 21 3 15"></polyline>
|
||||||
|
<line x1="21" y1="3" x2="14" y2="10"></line>
|
||||||
|
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === 'compact'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="send-btn"
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={!hasContent}
|
||||||
|
aria-label="Send"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mention suggestions popup -->
|
||||||
|
{#if suggestions.length > 0 && mentionPopupPos}
|
||||||
|
<div
|
||||||
|
class="mention-popup"
|
||||||
|
bind:this={suggestionsEl}
|
||||||
|
style:left="{mentionPopupPos.left}px"
|
||||||
|
style:bottom="calc(100% - {mentionPopupPos.top}px)"
|
||||||
|
>
|
||||||
|
{#each suggestions as entity, i (entity.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="suggestion"
|
||||||
|
class:selected={i === selectedIndex}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mentionCommandFn?.(entity);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
|
||||||
|
<span class="name">{entity.name}</span>
|
||||||
|
{#if entity.aliases?.length > 0}
|
||||||
|
<span class="alias">{entity.aliases[0]}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="type">{entity.type}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editor-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .editor-container {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extended .editor-container,
|
||||||
|
.expanded .editor-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-mount {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tiptap editor content styling */
|
||||||
|
.editor-wrapper :global(.editor-content) {
|
||||||
|
outline: none;
|
||||||
|
color: #e1e4e8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content.compact) {
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded :global(.editor-content.compact) {
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content.extended) {
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Raw editor (source view) */
|
||||||
|
.raw-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 60vh;
|
||||||
|
background: transparent;
|
||||||
|
color: #a0a8b8;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 0;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .raw-editor {
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content p + p) {
|
||||||
|
margin-top: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heading styles (extended mode) */
|
||||||
|
.editor-wrapper :global(.editor-content h1) {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.8em 0 0.3em;
|
||||||
|
color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content h2) {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.7em 0 0.25em;
|
||||||
|
color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content h3) {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.6em 0 0.2em;
|
||||||
|
color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List styles */
|
||||||
|
.editor-wrapper :global(.editor-content ul),
|
||||||
|
.editor-wrapper :global(.editor-content ol) {
|
||||||
|
padding-left: 1.2em;
|
||||||
|
margin: 0.3em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content li) {
|
||||||
|
margin: 0.1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquote */
|
||||||
|
.editor-wrapper :global(.editor-content blockquote) {
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
padding-left: 0.75em;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
color: #a0a8b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
.editor-wrapper :global(.editor-content code) {
|
||||||
|
background: #1e2235;
|
||||||
|
padding: 0.15em 0.3em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content pre) {
|
||||||
|
background: #1e2235;
|
||||||
|
padding: 0.6em;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline formatting */
|
||||||
|
.editor-wrapper :global(.editor-content strong) {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content em) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.editor-content s) {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mentions */
|
||||||
|
.editor-wrapper :global(.mention) {
|
||||||
|
color: #8b5cf6;
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper :global(.mention::before) {
|
||||||
|
content: '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.editor-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: #8b92a5;
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8b92a5;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool:hover {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool.active {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: #2d3148;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Send button (compact mode) */
|
||||||
|
.send-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #3b82f6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #8b92a5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:not(:disabled):hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand button */
|
||||||
|
.expand-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8b92a5;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn:hover {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mention popup */
|
||||||
|
.mention-popup {
|
||||||
|
position: absolute;
|
||||||
|
background: #161822;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #e1e4e8;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion:hover, .suggestion.selected {
|
||||||
|
background: #1e2235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias {
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -132,7 +132,7 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="suggestion"
|
class="suggestion"
|
||||||
class:selected={i === selectedIndex}
|
class:selected={i === selectedIndex}
|
||||||
onmousedown|preventDefault={() => selectSuggestion(entity)}
|
onmousedown={(e: MouseEvent) => { e.preventDefault(); selectSuggestion(entity); }}
|
||||||
>
|
>
|
||||||
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
|
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
|
||||||
<span class="name">{entity.name}</span>
|
<span class="name">{entity.name}</span>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@ export async function getWorkspaceForUser(workspaceId: string, userId: string):
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sett workspace-kontekst for RLS.
|
* Sett workspace-kontekst for RLS.
|
||||||
* Kall dette før spørringer som trenger workspace-isolasjon.
|
* Bruker set_config med is_local=true som tilsvarer SET LOCAL —
|
||||||
|
* verdien forsvinner automatisk når transaksjonen avsluttes.
|
||||||
|
* Dette forhindrer workspace-lekkasje ved connection pooling.
|
||||||
*/
|
*/
|
||||||
export async function setWorkspaceContext(workspaceId: string) {
|
export async function setWorkspaceContext(workspaceId: string) {
|
||||||
await sql`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
await sql`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
||||||
|
|
|
||||||
19
web/src/routes/admin/entities/+page.server.ts
Normal file
19
web/src/routes/admin/entities/+page.server.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.workspace) error(401);
|
||||||
|
|
||||||
|
// Hent alle entiteter med antall edges (for å identifisere duplikater)
|
||||||
|
const entities = await sql`
|
||||||
|
SELECT e.id, e.name, e.type, e.aliases, e.avatar_url,
|
||||||
|
(SELECT COUNT(*) FROM graph_edges WHERE source_id = e.id OR target_id = e.id) AS edge_count
|
||||||
|
FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE n.workspace_id = ${locals.workspace.id}
|
||||||
|
ORDER BY e.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { entities };
|
||||||
|
};
|
||||||
347
web/src/routes/admin/entities/+page.svelte
Normal file
347
web/src/routes/admin/entities/+page.svelte
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
interface Entity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
aliases: string[];
|
||||||
|
avatar_url: string | null;
|
||||||
|
edge_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entities = $state<Entity[]>(data.entities as Entity[]);
|
||||||
|
let selected = $state<Set<string>>(new Set());
|
||||||
|
let targetId = $state<string | null>(null);
|
||||||
|
let merging = $state(false);
|
||||||
|
let message = $state('');
|
||||||
|
let filter = $state('');
|
||||||
|
|
||||||
|
let filtered = $derived(
|
||||||
|
filter
|
||||||
|
? entities.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
e.aliases?.some((a) => a.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
)
|
||||||
|
: entities
|
||||||
|
);
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
person: '#3b82f6',
|
||||||
|
organisasjon: '#f59e0b',
|
||||||
|
sted: '#10b981',
|
||||||
|
tema: '#8b5cf6',
|
||||||
|
konsept: '#ec4899'
|
||||||
|
};
|
||||||
|
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
if (targetId === id) targetId = null;
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
selected = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTarget(id: string) {
|
||||||
|
targetId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doMerge() {
|
||||||
|
if (!targetId || selected.size < 2) return;
|
||||||
|
const sourceIds = [...selected].filter((id) => id !== targetId);
|
||||||
|
|
||||||
|
merging = true;
|
||||||
|
message = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/entities/merge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_id: targetId, source_ids: sourceIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||||
|
message = `Feil: ${err.message ?? res.statusText}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
message = `Slettet ${result.merged.join(', ')} → beholdt ${result.target.name} (${result.target.aliases?.length ?? 0} aliaser)`;
|
||||||
|
|
||||||
|
// Oppdater listen
|
||||||
|
entities = entities.filter((e) => !sourceIds.includes(e.id));
|
||||||
|
const idx = entities.findIndex((e) => e.id === targetId);
|
||||||
|
if (idx >= 0) entities[idx] = { ...entities[idx], ...result.target };
|
||||||
|
|
||||||
|
selected = new Set();
|
||||||
|
targetId = null;
|
||||||
|
} catch (e) {
|
||||||
|
message = `Feil: ${e instanceof Error ? e.message : 'ukjent'}`;
|
||||||
|
} finally {
|
||||||
|
merging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<h1>Entiteter</h1>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search"
|
||||||
|
placeholder="Filtrer entiteter..."
|
||||||
|
bind:value={filter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if selected.size >= 2}
|
||||||
|
<div class="merge-bar">
|
||||||
|
<span>{selected.size} valgt</span>
|
||||||
|
{#if targetId}
|
||||||
|
<button class="merge-btn" onclick={doMerge} disabled={merging}>
|
||||||
|
{merging ? 'Slår sammen...' : `Slå sammen → ${entities.find((e) => e.id === targetId)?.name}`}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="hint">Klikk "Behold" på entiteten som skal beholdes</span>
|
||||||
|
{/if}
|
||||||
|
<button class="clear-btn" onclick={() => { selected = new Set(); targetId = null; }}>Avbryt</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<div class="message" class:error={message.startsWith('Feil')}>{message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-check"></th>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Aliaser</th>
|
||||||
|
<th class="col-edges">Edges</th>
|
||||||
|
<th class="col-action"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filtered as entity (entity.id)}
|
||||||
|
<tr class:selected={selected.has(entity.id)} class:is-target={targetId === entity.id}>
|
||||||
|
<td class="col-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(entity.id)}
|
||||||
|
onchange={() => toggleSelect(entity.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
|
||||||
|
{entity.name}
|
||||||
|
</td>
|
||||||
|
<td class="type">{entity.type}</td>
|
||||||
|
<td class="aliases">{entity.aliases?.join(', ') || '—'}</td>
|
||||||
|
<td class="col-edges">{entity.edge_count}</td>
|
||||||
|
<td class="col-action">
|
||||||
|
{#if selected.has(entity.id) && selected.size >= 2}
|
||||||
|
<button
|
||||||
|
class="target-btn"
|
||||||
|
class:active={targetId === entity.id}
|
||||||
|
onclick={() => setTarget(entity.id)}
|
||||||
|
>
|
||||||
|
{targetId === entity.id ? '✓ Beholdes' : 'Behold'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f3f5;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e1e4e8;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(139, 92, 246, 0.1);
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-btn {
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-btn:hover:not(:disabled) {
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8b92a5;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #6ee7b7;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: rgba(248, 113, 113, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8b92a5;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #e1e4e8;
|
||||||
|
border-bottom: 1px solid rgba(45, 49, 72, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.selected {
|
||||||
|
background: rgba(139, 92, 246, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.is-target {
|
||||||
|
background: rgba(16, 185, 129, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-check { width: 30px; }
|
||||||
|
.col-edges { width: 60px; text-align: center; }
|
||||||
|
.col-action { width: 80px; }
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aliases {
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8b92a5;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-btn:hover {
|
||||||
|
border-color: #10b981;
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-btn.active {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border-color: #10b981;
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
web/src/routes/api/ai/process/+server.ts
Normal file
50
web/src/routes/api/ai/process/+server.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/process — Opprett AI-behandlingsjobb for en melding.
|
||||||
|
*
|
||||||
|
* Body: { message_id, action?, prompt_override?, model? }
|
||||||
|
*
|
||||||
|
* Oppretter en jobb i job_queue som Rust-workeren plukker opp.
|
||||||
|
* Returnerer jobb-ID umiddelbart (asynkront).
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
const workspace = locals.workspace;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { message_id, action, prompt_override, model } = body;
|
||||||
|
|
||||||
|
if (!message_id || typeof message_id !== 'string') {
|
||||||
|
error(400, 'message_id er påkrevd');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifiser at meldingen finnes og tilhører workspace
|
||||||
|
const [msg] = await sql`
|
||||||
|
SELECT m.id FROM messages m
|
||||||
|
JOIN nodes n ON n.id = m.id
|
||||||
|
WHERE m.id = ${message_id} AND n.workspace_id = ${workspace.id}
|
||||||
|
`;
|
||||||
|
if (!msg) error(404, 'Melding ikke funnet');
|
||||||
|
|
||||||
|
// Opprett jobb i køen
|
||||||
|
const [job] = await sql`
|
||||||
|
INSERT INTO job_queue (workspace_id, job_type, payload, priority)
|
||||||
|
VALUES (
|
||||||
|
${workspace.id},
|
||||||
|
'ai_text_process',
|
||||||
|
${JSON.stringify({
|
||||||
|
message_id,
|
||||||
|
action: action ?? 'fix_text',
|
||||||
|
prompt_override: prompt_override ?? null,
|
||||||
|
model: model ?? null
|
||||||
|
})}::jsonb,
|
||||||
|
10
|
||||||
|
)
|
||||||
|
RETURNING id, status, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json({ job_id: job.id, status: job.status }, { status: 202 });
|
||||||
|
};
|
||||||
|
|
@ -42,9 +42,11 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.workspace || !locals.user) error(401);
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
const workspace = locals.workspace;
|
||||||
|
const user = locals.user;
|
||||||
|
|
||||||
const channelId = params.id;
|
const channelId = params.id;
|
||||||
const { body, replyTo } = await request.json();
|
const { body, replyTo, mentions } = await request.json();
|
||||||
|
|
||||||
if (!body || typeof body !== 'string' || body.trim().length === 0) {
|
if (!body || typeof body !== 'string' || body.trim().length === 0) {
|
||||||
error(400, 'Melding kan ikke være tom');
|
error(400, 'Melding kan ikke være tom');
|
||||||
|
|
@ -54,26 +56,51 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const [channel] = await sql`
|
const [channel] = await sql`
|
||||||
SELECT c.id FROM channels c
|
SELECT c.id FROM channels c
|
||||||
JOIN nodes n ON n.id = c.id
|
JOIN nodes n ON n.id = c.id
|
||||||
WHERE c.id = ${channelId} AND n.workspace_id = ${locals.workspace.id}
|
WHERE c.id = ${channelId} AND n.workspace_id = ${workspace.id}
|
||||||
`;
|
`;
|
||||||
if (!channel) error(404, 'Kanal ikke funnet');
|
if (!channel) error(404, 'Kanal ikke funnet');
|
||||||
|
|
||||||
// Opprett node + melding i én transaksjon
|
// Opprett node + melding i PG
|
||||||
const [message] = await sql`
|
const [message] = await sql`
|
||||||
WITH new_node AS (
|
WITH new_node AS (
|
||||||
INSERT INTO nodes (workspace_id, node_type)
|
INSERT INTO nodes (workspace_id, node_type)
|
||||||
VALUES (${locals.workspace.id}, 'melding')
|
VALUES (${workspace.id}, 'melding')
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
INSERT INTO messages (id, channel_id, author_id, body, reply_to)
|
INSERT INTO messages (id, channel_id, author_id, body, reply_to)
|
||||||
SELECT new_node.id, ${channelId}, ${locals.user.id}, ${body.trim()}, ${replyTo ?? null}
|
SELECT new_node.id, ${channelId}, ${user.id}, ${body.trim()}, ${replyTo ?? null}
|
||||||
FROM new_node
|
FROM new_node
|
||||||
RETURNING id, body, message_type, created_at, reply_to
|
RETURNING id, body, message_type, created_at, reply_to
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Opprett MENTIONS-edges for hver #-mention
|
||||||
|
if (Array.isArray(mentions) && mentions.length > 0) {
|
||||||
|
const entityIds = mentions
|
||||||
|
.map((m: { id?: string }) => m.id)
|
||||||
|
.filter((id): id is string => typeof id === 'string' && id !== message.id);
|
||||||
|
|
||||||
|
if (entityIds.length > 0) {
|
||||||
|
// Verifiser at alle nevnte entiteter tilhører workspace
|
||||||
|
const validEntities = await sql`
|
||||||
|
SELECT id FROM nodes
|
||||||
|
WHERE id = ANY(${entityIds}) AND workspace_id = ${workspace.id}
|
||||||
|
`;
|
||||||
|
const validIds = new Set(validEntities.map((e) => (e as { id: string }).id));
|
||||||
|
|
||||||
|
for (const entityId of entityIds) {
|
||||||
|
if (!validIds.has(entityId)) continue;
|
||||||
|
await sql`
|
||||||
|
INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type, created_by, origin)
|
||||||
|
VALUES (${workspace.id}, ${message.id}, ${entityId}, 'MENTIONS', ${user.id}, 'user')
|
||||||
|
ON CONFLICT (source_id, target_id, relation_type) DO NOTHING
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
...message,
|
...message,
|
||||||
author_name: locals.user.name,
|
author_name: user.name,
|
||||||
author_id: locals.user.id
|
author_id: user.id
|
||||||
}, { status: 201 });
|
}, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
133
web/src/routes/api/entities/merge/+server.ts
Normal file
133
web/src/routes/api/entities/merge/+server.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/entities/merge — Slå sammen duplikate entiteter.
|
||||||
|
*
|
||||||
|
* Body: { target_id: "uuid", source_ids: ["uuid", ...] }
|
||||||
|
*
|
||||||
|
* target_id = autoritativ entitet som beholdes
|
||||||
|
* source_ids = duplikater som absorberes og slettes
|
||||||
|
*
|
||||||
|
* For hver source:
|
||||||
|
* 1. Flytt alle graph_edges (source_id og target_id) til target
|
||||||
|
* 2. Oppdater mentions i messages.body (data-id attributter)
|
||||||
|
* 3. Legg til source.name som alias på target
|
||||||
|
* 4. Slett source-noden (cascader til entities, edges)
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
const workspace = locals.workspace;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { target_id, source_ids } = body;
|
||||||
|
|
||||||
|
if (!target_id || !Array.isArray(source_ids) || source_ids.length === 0) {
|
||||||
|
error(400, 'target_id og source_ids (array) er påkrevd');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source_ids.includes(target_id)) {
|
||||||
|
error(400, 'target_id kan ikke være i source_ids');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifiser at alle entiteter finnes og tilhører workspace
|
||||||
|
const allIds = [target_id, ...source_ids];
|
||||||
|
const entities = await sql`
|
||||||
|
SELECT e.id, e.name, e.aliases
|
||||||
|
FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE e.id = ANY(${allIds}) AND n.workspace_id = ${workspace.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (entities.length !== allIds.length) {
|
||||||
|
error(404, 'En eller flere entiteter ikke funnet i workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetEntity = entities.find((e) => (e as { id: string }).id === target_id) as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
aliases: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged: string[] = [];
|
||||||
|
|
||||||
|
for (const sourceId of source_ids) {
|
||||||
|
const sourceEntity = entities.find((e) => (e as { id: string }).id === sourceId) as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
aliases: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Flytt graph_edges der source er source_id → target
|
||||||
|
// Bruk ON CONFLICT for å unngå duplikate edges
|
||||||
|
await sql`
|
||||||
|
UPDATE graph_edges
|
||||||
|
SET source_id = ${target_id}
|
||||||
|
WHERE source_id = ${sourceId}
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM graph_edges existing
|
||||||
|
WHERE existing.source_id = ${target_id}
|
||||||
|
AND existing.target_id = graph_edges.target_id
|
||||||
|
AND existing.relation_type = graph_edges.relation_type
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
// Slett eventuelle gjenværende (duplikater som ikke ble flyttet)
|
||||||
|
await sql`
|
||||||
|
DELETE FROM graph_edges WHERE source_id = ${sourceId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 2. Flytt graph_edges der source er target_id → target
|
||||||
|
await sql`
|
||||||
|
UPDATE graph_edges
|
||||||
|
SET target_id = ${target_id}
|
||||||
|
WHERE target_id = ${sourceId}
|
||||||
|
AND source_id != ${target_id}
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM graph_edges existing
|
||||||
|
WHERE existing.source_id = graph_edges.source_id
|
||||||
|
AND existing.target_id = ${target_id}
|
||||||
|
AND existing.relation_type = graph_edges.relation_type
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
DELETE FROM graph_edges WHERE target_id = ${sourceId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 3. Oppdater mentions i messages.body (data-id="source" → data-id="target")
|
||||||
|
await sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET body = REPLACE(body, ${`data-id="${sourceId}"`}, ${`data-id="${target_id}"`})
|
||||||
|
WHERE body LIKE ${`%${sourceId}%`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 4. Legg til source.name + aliases som alias på target
|
||||||
|
const newAliases = [sourceEntity.name, ...(sourceEntity.aliases ?? [])].filter(
|
||||||
|
(a) => a !== targetEntity.name && !targetEntity.aliases?.includes(a)
|
||||||
|
);
|
||||||
|
if (newAliases.length > 0) {
|
||||||
|
await sql`
|
||||||
|
UPDATE entities
|
||||||
|
SET aliases = aliases || ${newAliases}::text[]
|
||||||
|
WHERE id = ${target_id}
|
||||||
|
`;
|
||||||
|
targetEntity.aliases = [...(targetEntity.aliases ?? []), ...newAliases];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Slett source-noden (cascader til entities via FK)
|
||||||
|
await sql`DELETE FROM nodes WHERE id = ${sourceId}`;
|
||||||
|
|
||||||
|
merged.push(sourceEntity.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hent oppdatert target
|
||||||
|
const [result] = await sql`
|
||||||
|
SELECT e.id, e.name, e.type, e.aliases, e.avatar_url
|
||||||
|
FROM entities e WHERE e.id = ${target_id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
merged: merged,
|
||||||
|
target: result
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -2,6 +2,17 @@ import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { sql } from '$lib/server/db';
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** Trekk ut entity-UUIDs fra Tiptap HTML mentions (data-id attributter) */
|
||||||
|
function extractMentionIds(html: string): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const regex = /data-id="([0-9a-f-]{36})"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(html)) !== null) {
|
||||||
|
if (!ids.includes(match[1])) ids.push(match[1]);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/notes/:noteId — Hent notat */
|
/** GET /api/notes/:noteId — Hent notat */
|
||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
if (!locals.workspace || !locals.user) error(401);
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
@ -20,13 +31,16 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
/** PATCH /api/notes/:noteId — Oppdater notat */
|
/** PATCH /api/notes/:noteId — Oppdater notat */
|
||||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.workspace || !locals.user) error(401);
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
const workspace = locals.workspace;
|
||||||
|
const user = locals.user;
|
||||||
|
const noteId = params.noteId;
|
||||||
|
|
||||||
const updates = await request.json();
|
const updates = await request.json();
|
||||||
|
|
||||||
const [note] = await sql`
|
const [note] = await sql`
|
||||||
SELECT m.id FROM messages m
|
SELECT m.id FROM messages m
|
||||||
JOIN nodes nd ON nd.id = m.id
|
JOIN nodes nd ON nd.id = m.id
|
||||||
WHERE m.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id}
|
WHERE m.id = ${noteId} AND nd.workspace_id = ${workspace.id}
|
||||||
`;
|
`;
|
||||||
if (!note) error(404, 'Notat ikke funnet');
|
if (!note) error(404, 'Notat ikke funnet');
|
||||||
|
|
||||||
|
|
@ -34,9 +48,40 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
UPDATE messages SET
|
UPDATE messages SET
|
||||||
title = COALESCE(${updates.title ?? null}, title),
|
title = COALESCE(${updates.title ?? null}, title),
|
||||||
body = CASE WHEN ${updates.content !== undefined} THEN ${updates.content ?? ''} ELSE body END
|
body = CASE WHEN ${updates.content !== undefined} THEN ${updates.content ?? ''} ELSE body END
|
||||||
WHERE id = ${params.noteId}
|
WHERE id = ${noteId}
|
||||||
RETURNING id, title, body AS content, updated_at
|
RETURNING id, title, body AS content, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Synkroniser MENTIONS-edges når content endres
|
||||||
|
if (updates.content !== undefined) {
|
||||||
|
const mentionIds = extractMentionIds(updates.content ?? '');
|
||||||
|
|
||||||
|
// Verifiser at nevnte entiteter tilhører workspace
|
||||||
|
let validIds: Set<string> = new Set();
|
||||||
|
if (mentionIds.length > 0) {
|
||||||
|
const validEntities = await sql`
|
||||||
|
SELECT id FROM nodes
|
||||||
|
WHERE id = ANY(${mentionIds}) AND workspace_id = ${workspace.id}
|
||||||
|
`;
|
||||||
|
validIds = new Set(validEntities.map((e) => (e as { id: string }).id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slett gamle MENTIONS-edges fra dette notatet
|
||||||
|
await sql`
|
||||||
|
DELETE FROM graph_edges
|
||||||
|
WHERE source_id = ${noteId} AND relation_type = 'MENTIONS'
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Opprett nye MENTIONS-edges
|
||||||
|
for (const entityId of mentionIds) {
|
||||||
|
if (!validIds.has(entityId)) continue;
|
||||||
|
await sql`
|
||||||
|
INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type, created_by, origin)
|
||||||
|
VALUES (${workspace.id}, ${noteId}, ${entityId}, 'MENTIONS', ${user.id}, 'user')
|
||||||
|
ON CONFLICT (source_id, target_id, relation_type) DO NOTHING
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json(updated);
|
return json(updated);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
71
web/src/routes/entities/[id]/+page.server.ts
Normal file
71
web/src/routes/entities/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
const workspace = locals.workspace;
|
||||||
|
const entityId = params.id;
|
||||||
|
|
||||||
|
// 1. Hent entiteten
|
||||||
|
const [entity] = await sql`
|
||||||
|
SELECT e.id, e.name, e.type, e.aliases, e.avatar_url,
|
||||||
|
n.created_at, n.updated_at
|
||||||
|
FROM entities e
|
||||||
|
JOIN nodes n ON n.id = e.id
|
||||||
|
WHERE e.id = ${entityId} AND n.workspace_id = ${workspace.id}
|
||||||
|
`;
|
||||||
|
if (!entity) error(404, 'Entitet ikke funnet');
|
||||||
|
|
||||||
|
// 2. Hent relasjoner (edges) med info om tilkoblede noder
|
||||||
|
const edges = await sql`
|
||||||
|
SELECT
|
||||||
|
ge.id AS edge_id,
|
||||||
|
ge.source_id,
|
||||||
|
ge.target_id,
|
||||||
|
ge.relation_type,
|
||||||
|
ge.confidence,
|
||||||
|
ge.origin,
|
||||||
|
ge.created_at,
|
||||||
|
CASE
|
||||||
|
WHEN ge.source_id = ${entityId} THEN target_e.name
|
||||||
|
ELSE source_e.name
|
||||||
|
END AS connected_name,
|
||||||
|
CASE
|
||||||
|
WHEN ge.source_id = ${entityId} THEN target_e.type
|
||||||
|
ELSE source_e.type
|
||||||
|
END AS connected_type,
|
||||||
|
CASE
|
||||||
|
WHEN ge.source_id = ${entityId} THEN ge.target_id
|
||||||
|
ELSE ge.source_id
|
||||||
|
END AS connected_id,
|
||||||
|
CASE
|
||||||
|
WHEN ge.source_id = ${entityId} THEN 'outgoing'
|
||||||
|
ELSE 'incoming'
|
||||||
|
END AS direction
|
||||||
|
FROM graph_edges ge
|
||||||
|
LEFT JOIN entities source_e ON source_e.id = ge.source_id
|
||||||
|
LEFT JOIN entities target_e ON target_e.id = ge.target_id
|
||||||
|
WHERE ge.workspace_id = ${workspace.id}
|
||||||
|
AND (ge.source_id = ${entityId} OR ge.target_id = ${entityId})
|
||||||
|
ORDER BY ge.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 3. Hent meldinger som nevner denne entiteten (via MENTIONS-edges)
|
||||||
|
const mentions = await sql`
|
||||||
|
SELECT m.id, m.body, m.title, m.message_type, m.created_at,
|
||||||
|
u.display_name AS author_name,
|
||||||
|
c.id AS channel_id
|
||||||
|
FROM graph_edges ge
|
||||||
|
JOIN messages m ON m.id = ge.source_id
|
||||||
|
LEFT JOIN users u ON u.authentik_id = m.author_id
|
||||||
|
LEFT JOIN channels c ON c.id = m.channel_id
|
||||||
|
WHERE ge.target_id = ${entityId}
|
||||||
|
AND ge.relation_type = 'MENTIONS'
|
||||||
|
AND ge.workspace_id = ${workspace.id}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { entity, edges, mentions };
|
||||||
|
};
|
||||||
560
web/src/routes/entities/[id]/+page.svelte
Normal file
560
web/src/routes/entities/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,560 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
person: '#3b82f6',
|
||||||
|
organisasjon: '#f59e0b',
|
||||||
|
sted: '#10b981',
|
||||||
|
tema: '#8b5cf6',
|
||||||
|
konsept: '#ec4899'
|
||||||
|
};
|
||||||
|
|
||||||
|
const validTypes = ['person', 'organisasjon', 'sted', 'tema', 'konsept'];
|
||||||
|
|
||||||
|
let entity = $state(data.entity as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
aliases: string[];
|
||||||
|
avatar_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
});
|
||||||
|
const edges = data.edges as Array<{
|
||||||
|
edge_id: string;
|
||||||
|
relation_type: string;
|
||||||
|
connected_name: string | null;
|
||||||
|
connected_type: string | null;
|
||||||
|
connected_id: string;
|
||||||
|
direction: string;
|
||||||
|
origin: string;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
const mentions = data.mentions as Array<{
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
title: string | null;
|
||||||
|
message_type: string;
|
||||||
|
created_at: string;
|
||||||
|
author_name: string | null;
|
||||||
|
channel_id: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Redigering
|
||||||
|
let editing = $state(false);
|
||||||
|
let editName = $state(entity.name);
|
||||||
|
let editType = $state(entity.type);
|
||||||
|
let editAliases = $state(entity.aliases?.join(', ') ?? '');
|
||||||
|
let saving = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/entities/${entity.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: editName.trim(),
|
||||||
|
type: editType,
|
||||||
|
aliases: editAliases.split(',').map(a => a.trim()).filter(Boolean)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const updated = await res.json();
|
||||||
|
entity = { ...entity, ...updated };
|
||||||
|
editing = false;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntity() {
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/entities/${entity.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
goto('/admin/entities');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
confirmDelete = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
editName = entity.name;
|
||||||
|
editType = entity.type;
|
||||||
|
editAliases = entity.aliases?.join(', ') ?? '';
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entitet-relasjoner (edges til andre entiteter, ikke meldinger)
|
||||||
|
let entityEdges = $derived(
|
||||||
|
edges.filter((e) => e.connected_name !== null)
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('nb-NO', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('nb-NO', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
return text.slice(0, max) + '...';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<a href="/admin/entities" class="back-link">← Alle entiteter</a>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="entity-header">
|
||||||
|
{#if entity.avatar_url}
|
||||||
|
<img src={entity.avatar_url} alt={entity.name} class="avatar" />
|
||||||
|
{:else}
|
||||||
|
<div class="avatar-placeholder" style:background={typeColors[entity.type] ?? '#8b92a5'}>
|
||||||
|
{entity.name[0]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="header-content">
|
||||||
|
{#if editing}
|
||||||
|
<div class="edit-form">
|
||||||
|
<input class="edit-name" bind:value={editName} placeholder="Navn" />
|
||||||
|
<div class="edit-row">
|
||||||
|
<select class="edit-type" bind:value={editType}>
|
||||||
|
{#each validTypes as t}
|
||||||
|
<option value={t}>{t}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input class="edit-aliases" bind:value={editAliases} placeholder="Aliaser (kommaseparert)" />
|
||||||
|
</div>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="btn-save" onclick={save} disabled={saving || !editName.trim()}>
|
||||||
|
{saving ? 'Lagrer...' : 'Lagre'}
|
||||||
|
</button>
|
||||||
|
<button class="btn-cancel" onclick={() => editing = false}>Avbryt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="header-top">
|
||||||
|
<h1>{entity.name}</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-edit" onclick={startEdit}>Rediger</button>
|
||||||
|
{#if confirmDelete}
|
||||||
|
<button class="btn-delete confirm" onclick={deleteEntity} disabled={deleting}>
|
||||||
|
{deleting ? 'Sletter...' : 'Bekreft slett'}
|
||||||
|
</button>
|
||||||
|
<button class="btn-cancel-sm" onclick={() => confirmDelete = false}>Avbryt</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn-delete" onclick={() => confirmDelete = true}>Slett</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="type-badge" style:background={typeColors[entity.type] ?? '#8b92a5'}>
|
||||||
|
{entity.type}
|
||||||
|
</span>
|
||||||
|
{#if entity.aliases?.length > 0}
|
||||||
|
<span class="aliases">aka {entity.aliases.join(', ')}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="date">Opprettet {formatDate(entity.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="sections">
|
||||||
|
<!-- Relasjoner til andre entiteter -->
|
||||||
|
{#if entityEdges.length > 0}
|
||||||
|
<section>
|
||||||
|
<h2>Relasjoner ({entityEdges.length})</h2>
|
||||||
|
<div class="edge-list">
|
||||||
|
{#each entityEdges as edge (edge.edge_id)}
|
||||||
|
<a href="/entities/{edge.connected_id}" class="edge-item">
|
||||||
|
<span class="dot" style:background={typeColors[edge.connected_type ?? ''] ?? '#8b92a5'}></span>
|
||||||
|
<span class="edge-name">{edge.connected_name}</span>
|
||||||
|
<span class="edge-type">{edge.relation_type}</span>
|
||||||
|
<span class="edge-dir">{edge.direction === 'outgoing' ? '→' : '←'}</span>
|
||||||
|
<span class="edge-origin">{edge.origin}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Mentions (meldinger som nevner denne entiteten) -->
|
||||||
|
<section>
|
||||||
|
<h2>Nevnt i {mentions.length} {mentions.length === 1 ? 'melding' : 'meldinger'}</h2>
|
||||||
|
{#if mentions.length === 0}
|
||||||
|
<p class="empty">Ingen meldinger nevner denne entiteten ennå.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="mention-list">
|
||||||
|
{#each mentions as msg (msg.id)}
|
||||||
|
<a href={msg.channel_id ? `/?channel=${msg.channel_id}` : '#'} class="mention-item" class:no-link={!msg.channel_id}>
|
||||||
|
<div class="mention-header">
|
||||||
|
{#if msg.author_name}
|
||||||
|
<span class="author">{msg.author_name}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="time">{formatTime(msg.created_at)}</span>
|
||||||
|
{#if msg.message_type !== 'chat'}
|
||||||
|
<span class="msg-type">{msg.message_type}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if msg.title}
|
||||||
|
<div class="mention-title">{msg.title}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mention-body">{truncate(stripHtml(msg.body), 200)}</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f3f5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aliases {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit form */
|
||||||
|
.edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-name {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #f1f3f5;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-type {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e1e4e8;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-aliases {
|
||||||
|
flex: 1;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e1e4e8;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-edit, .btn-delete, .btn-cancel-sm {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8b92a5;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover { border-color: #3b82f6; color: #7dd3fc; }
|
||||||
|
.btn-delete:hover { border-color: #f87171; color: #fca5a5; }
|
||||||
|
.btn-delete.confirm { background: rgba(248, 113, 113, 0.15); border-color: #f87171; color: #fca5a5; }
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background: #3b82f6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover:not(:disabled) { background: #2563eb; }
|
||||||
|
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8b92a5;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section h2 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8b92a5;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edge list */
|
||||||
|
.edge-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #e1e4e8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-name { flex: 1; }
|
||||||
|
|
||||||
|
.edge-type {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
background: rgba(139, 146, 165, 0.1);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-dir {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-origin {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mentions */
|
||||||
|
.mention-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(45, 49, 72, 0.5);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item:not(.no-link):hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(45, 49, 72, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item.no-link {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-type {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
background: rgba(139, 146, 165, 0.1);
|
||||||
|
padding: 0.05rem 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f3f5;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-body {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #a0a8b8;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
257
worker/src/handlers/ai_text_process.rs
Normal file
257
worker/src/handlers/ai_text_process.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
use super::JobHandler;
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Handler for AI-behandling av tekst i editoren.
|
||||||
|
///
|
||||||
|
/// Payload:
|
||||||
|
/// {
|
||||||
|
/// "message_id": "uuid",
|
||||||
|
/// "action": "fix_text" | "extract_facts" | "rewrite" | "translate" | "custom",
|
||||||
|
/// "prompt_override": "optional custom prompt",
|
||||||
|
/// "model": "sidelinja/rutine" (optional, default basert på action)
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// Flyten:
|
||||||
|
/// 1. Hent meldingens nåværende body fra PG
|
||||||
|
/// 2. Lagre originalen som revisjon i message_revisions
|
||||||
|
/// 3. Send til AI Gateway med riktig prompt
|
||||||
|
/// 4. Oppdater messages.body med AI-resultatet
|
||||||
|
/// 5. Sett metadata.ai_processed = true
|
||||||
|
pub struct AiTextProcessHandler {
|
||||||
|
http: reqwest::Client,
|
||||||
|
ai_gateway_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AiTextProcessHandler {
|
||||||
|
pub fn new(http: reqwest::Client, ai_gateway_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
http,
|
||||||
|
ai_gateway_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl JobHandler for AiTextProcessHandler {
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: &Uuid,
|
||||||
|
payload: &Value,
|
||||||
|
) -> anyhow::Result<Option<Value>> {
|
||||||
|
let message_id: Uuid = payload
|
||||||
|
.get("message_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow!("message_id mangler i payload"))?
|
||||||
|
.parse()
|
||||||
|
.context("Ugyldig message_id UUID")?;
|
||||||
|
|
||||||
|
let action = payload
|
||||||
|
.get("action")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("fix_text");
|
||||||
|
|
||||||
|
let prompt_override = payload.get("prompt_override").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
let model = payload
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("sidelinja/rutine");
|
||||||
|
|
||||||
|
info!(
|
||||||
|
message_id = %message_id,
|
||||||
|
action = action,
|
||||||
|
model = model,
|
||||||
|
workspace_id = %workspace_id,
|
||||||
|
"AI-behandling starter"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Hent meldingens body
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT m.body FROM messages m
|
||||||
|
JOIN nodes n ON n.id = m.id
|
||||||
|
WHERE m.id = $1 AND n.workspace_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.context("Feil ved henting av melding")?
|
||||||
|
.ok_or_else(|| anyhow!("Melding {} ikke funnet i workspace", message_id))?;
|
||||||
|
|
||||||
|
let original_body: String = row.get("body");
|
||||||
|
|
||||||
|
// Strip HTML-tags for å sende ren tekst til LLM
|
||||||
|
let plain_text = strip_html(&original_body);
|
||||||
|
|
||||||
|
if plain_text.trim().is_empty() {
|
||||||
|
return Ok(Some(json!({ "skipped": true, "reason": "tom melding" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lagre original som revisjon
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO message_revisions (id, message_id, body)
|
||||||
|
VALUES (gen_random_uuid(), $1, $2)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(&original_body)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.context("Feil ved lagring av revisjon")?;
|
||||||
|
|
||||||
|
// 3. Bygg system-prompt basert på action
|
||||||
|
let system_prompt = match prompt_override {
|
||||||
|
Some(custom) => custom.to_string(),
|
||||||
|
None => get_system_prompt(action),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Send til AI Gateway
|
||||||
|
let ai_response = self
|
||||||
|
.call_ai_gateway(&system_prompt, &plain_text, model)
|
||||||
|
.await
|
||||||
|
.context("AI Gateway-kall feilet")?;
|
||||||
|
|
||||||
|
// 5. Oppdater meldingens body med AI-resultat
|
||||||
|
let metadata = json!({
|
||||||
|
"ai_processed": true,
|
||||||
|
"ai_action": action
|
||||||
|
});
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE messages
|
||||||
|
SET body = $1,
|
||||||
|
edited_at = now(),
|
||||||
|
metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb
|
||||||
|
WHERE id = $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&ai_response)
|
||||||
|
.bind(metadata)
|
||||||
|
.bind(message_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.context("Feil ved oppdatering av melding")?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
message_id = %message_id,
|
||||||
|
action = action,
|
||||||
|
original_len = original_body.len(),
|
||||||
|
result_len = ai_response.len(),
|
||||||
|
"AI-behandling fullført"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Some(json!({
|
||||||
|
"message_id": message_id.to_string(),
|
||||||
|
"action": action,
|
||||||
|
"original_length": original_body.len(),
|
||||||
|
"result_length": ai_response.len()
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AiTextProcessHandler {
|
||||||
|
async fn call_ai_gateway(
|
||||||
|
&self,
|
||||||
|
system_prompt: &str,
|
||||||
|
user_text: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let request_body = json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{ "role": "system", "content": system_prompt },
|
||||||
|
{ "role": "user", "content": user_text }
|
||||||
|
],
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 4096
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(format!("{}/chat/completions", self.ai_gateway_url))
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("HTTP-kall til AI Gateway feilet")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
warn!(status = %status, body = %body, "AI Gateway returnerte feil");
|
||||||
|
return Err(anyhow!("AI Gateway feil: {} — {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Kunne ikke parse AI Gateway-respons")?;
|
||||||
|
|
||||||
|
json["choices"][0]["message"]["content"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| anyhow!("Ingen content i AI Gateway-respons"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_system_prompt(action: &str) -> String {
|
||||||
|
match action {
|
||||||
|
"fix_text" => r#"Fiks denne teksten. Output på norsk.
|
||||||
|
- Fiks skrivefeil og grammatikk
|
||||||
|
- Start med en kort oppsummering av det viktigste (2–3 setninger)
|
||||||
|
- Fjern metainformasjon, navigasjon, annonser og annen støy fra innlimt webinnhold
|
||||||
|
- Dersom det er tydelig hva kilden er, oppgi den etter innledende oppsummering
|
||||||
|
- Behold saklig innhold og fakta intakt
|
||||||
|
- Bruk markdown-formatering der det gir bedre lesbarhet"#
|
||||||
|
.to_string(),
|
||||||
|
|
||||||
|
"extract_facts" => r#"Analyser denne teksten og trekk ut fakta. Output på norsk.
|
||||||
|
- Identifiser konkrete påstander, tall, sitater og fakta
|
||||||
|
- List dem opp som punktliste
|
||||||
|
- For hver fakta: noter hvilken person eller organisasjon den gjelder (bruk #Navn-format)
|
||||||
|
- Ignorer meninger og spekulasjoner — kun verifiserbare påstander
|
||||||
|
- Behold kildehenvisninger der de finnes"#
|
||||||
|
.to_string(),
|
||||||
|
|
||||||
|
"rewrite" => r#"Skriv om denne teksten til artikkelformat. Output på norsk.
|
||||||
|
- Lag en tittel som fanger essensen
|
||||||
|
- Skriv en ingress på 2–3 setninger
|
||||||
|
- Strukturer resten med mellomtitler der det er naturlig
|
||||||
|
- Hold deg til fakta fra originalteksten — ikke legg til informasjon
|
||||||
|
- Bruk markdown-formatering"#
|
||||||
|
.to_string(),
|
||||||
|
|
||||||
|
"translate" => r#"Oversett denne teksten til norsk.
|
||||||
|
- Behold formatering og struktur
|
||||||
|
- Oversett fagtermer korrekt, behold engelske termer i parentes der det er vanlig
|
||||||
|
- Behold egennavn uoversatt"#
|
||||||
|
.to_string(),
|
||||||
|
|
||||||
|
_ => "Behandle denne teksten. Output på norsk.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enkel HTML-stripping for å sende ren tekst til LLM.
|
||||||
|
fn strip_html(html: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
|
||||||
|
for ch in html.chars() {
|
||||||
|
match ch {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' => in_tag = false,
|
||||||
|
_ if !in_tag => result.push(ch),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
mod ai_text_process;
|
||||||
mod echo;
|
mod echo;
|
||||||
|
|
||||||
/// Trait for jobbhandlere.
|
/// Trait for jobbhandlere.
|
||||||
|
|
@ -21,18 +22,24 @@ pub type HandlerRegistry = HashMap<String, Box<dyn JobHandler>>;
|
||||||
|
|
||||||
/// Bygg registeret med alle tilgjengelige handlers.
|
/// Bygg registeret med alle tilgjengelige handlers.
|
||||||
pub fn build_registry(http: reqwest::Client, ai_gateway_url: String) -> HandlerRegistry {
|
pub fn build_registry(http: reqwest::Client, ai_gateway_url: String) -> HandlerRegistry {
|
||||||
let _ = (&http, &ai_gateway_url); // brukes av fremtidige handlers
|
|
||||||
|
|
||||||
let mut registry: HandlerRegistry = HashMap::new();
|
let mut registry: HandlerRegistry = HashMap::new();
|
||||||
|
|
||||||
// Echo-handler for testing
|
// Echo-handler for testing
|
||||||
registry.insert("echo".into(), Box::new(echo::EchoHandler));
|
registry.insert("echo".into(), Box::new(echo::EchoHandler));
|
||||||
|
|
||||||
// Fremtidige handlers registreres her:
|
// AI-behandling av tekst (✨-knappen i editoren)
|
||||||
// registry.insert("whisper_transcribe".into(), Box::new(whisper::WhisperHandler::new(http.clone())));
|
registry.insert(
|
||||||
// registry.insert("openrouter_analyze".into(), Box::new(ai::AnalyzeHandler::new(http.clone(), ai_gateway_url.clone())));
|
"ai_text_process".into(),
|
||||||
// registry.insert("research_clip".into(), Box::new(ai::ResearchClipHandler::new(http.clone(), ai_gateway_url.clone())));
|
Box::new(ai_text_process::AiTextProcessHandler::new(
|
||||||
// registry.insert("stats_parse".into(), Box::new(stats::StatsHandler));
|
http.clone(),
|
||||||
|
ai_gateway_url.clone(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fremtidige handlers:
|
||||||
|
// registry.insert("whisper_transcribe".into(), ...);
|
||||||
|
// registry.insert("openrouter_analyze".into(), ...);
|
||||||
|
// registry.insert("research_clip".into(), ...);
|
||||||
|
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue