Compare commits
6 commits
7899b2f224
...
42cf812c64
| Author | SHA1 | Date | |
|---|---|---|---|
| 42cf812c64 | |||
| 89a8f99766 | |||
| 88a22e131b | |||
| 531defe986 | |||
| 58646b1543 | |||
| a1e6fa1c6b |
31 changed files with 764 additions and 337 deletions
|
|
@ -5,6 +5,9 @@ Sidelinja er et redaksjonelt operativsystem og kunnskapsgraf for podcast-produks
|
||||||
Self-hosted på Hetzner VPS med full datakontroll.
|
Self-hosted på Hetzner VPS med full datakontroll.
|
||||||
|
|
||||||
## Arbeidsflyt
|
## Arbeidsflyt
|
||||||
|
- **Standard arbeidsmodus:** Start i planleggingsmodus. Lag en grundig plan, få godkjenning, deretter implementer. Jobbene er ment å kunne kjøre lenge autonomt uten input underveis.
|
||||||
|
- **Testmiljø:** `./dev.sh` er den kanoniske måten å starte utviklingsmiljøet. Når nye tjenester, steg eller oppsett-quirks oppdages, oppdater alltid `dev.sh` slik at kunnskapen bevares i scriptet — ikke bare i hodet. Før Vegard tester i browser: kjør `./dev.sh`, verifiser med `cargo check`/`svelte-check`/`curl`, og meld tilbake at det er klart.
|
||||||
|
- **Browser-testing:** Claude har ikke tilgang til browser. Visuell testing og interaksjon gjøres av Vegard. Claude kan verifisere backend (kompilering, API-kall, database-state) men ikke frontend-rendering.
|
||||||
- **Commit og push:** Bruk egen vurdering. Commit når arbeidet er logisk komplett, push til Forgejo når det gir mening. Ingen grunn til å spørre — det er trygt og reverserbart.
|
- **Commit og push:** Bruk egen vurdering. Commit når arbeidet er logisk komplett, push til Forgejo når det gir mening. Ingen grunn til å spørre — det er trygt og reverserbart.
|
||||||
- **Deploy til produksjon:** Krever alltid eksplisitt godkjenning fra Vegard. Deploy = SSH til server + pull + docker compose up. Aldri gjør dette uten å spørre først.
|
- **Deploy til produksjon:** Krever alltid eksplisitt godkjenning fra Vegard. Deploy = SSH til server + pull + docker compose up. Aldri gjør dette uten å spørre først.
|
||||||
- **Diskusjon:** Forklar og diskuter før arkitekturendringer eller uvanlige valg. For implementering innenfor eksisterende spec — bare kjør.
|
- **Diskusjon:** Forklar og diskuter før arkitekturendringer eller uvanlige valg. For implementering innenfor eksisterende spec — bare kjør.
|
||||||
|
|
|
||||||
|
|
@ -107,5 +107,26 @@ Ny modell:
|
||||||
- SyncOutbox-events prosesseres hver 1. sekund
|
- SyncOutbox-events prosesseres hver 1. sekund
|
||||||
- Støtter: `messages/insert`, `messages/delete`, `messages/update`, `message_reactions/insert`, `message_reactions/delete`
|
- Støtter: `messages/insert`, `messages/delete`, `messages/update`, `message_reactions/insert`, `message_reactions/delete`
|
||||||
|
|
||||||
|
## 7. Subscription-begrensninger
|
||||||
|
|
||||||
|
**SpacetimeDB-subscriptions støtter IKKE JOINs.** En subscription-query som `SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE ...` feiler stille — `onApplied` kalles aldri, og ingen data vises.
|
||||||
|
|
||||||
|
Bruk kun enkle `SELECT * FROM tabell WHERE ...`-queries i `.subscribe([...])`. Filtrer heller klient-side etter at data er lastet.
|
||||||
|
|
||||||
|
Eksempel:
|
||||||
|
```typescript
|
||||||
|
// FEIL — feiler stille, ingen data
|
||||||
|
.subscribe([
|
||||||
|
`SELECT * FROM chat_message WHERE channel_id = '${id}'`,
|
||||||
|
`SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE cm.channel_id = '${id}'`
|
||||||
|
]);
|
||||||
|
|
||||||
|
// RIKTIG — last alle reaksjoner, filtrer i koden
|
||||||
|
.subscribe([
|
||||||
|
`SELECT * FROM chat_message WHERE channel_id = '${id}'`,
|
||||||
|
`SELECT * FROM message_reaction`
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
### Fallback
|
### Fallback
|
||||||
PG-polling adapter (`pg.svelte.ts`) brukes kun når SpacetimeDB ikke er konfigurert. Markeres som `readonly: true`.
|
PG-polling adapter (`pg.svelte.ts`) brukes kun når SpacetimeDB ikke er konfigurert. Markeres som `readonly: true`.
|
||||||
|
|
|
||||||
|
|
@ -86,13 +86,16 @@ Kun aktive når `config.mentions = true`.
|
||||||
* **Mobil-optimalisert:** Autocomplete-listen er tappbar og tilpasset mindre skjermer.
|
* **Mobil-optimalisert:** Autocomplete-listen er tappbar og tilpasset mindre skjermer.
|
||||||
|
|
||||||
## 5. Tråder
|
## 5. Tråder
|
||||||
Kun aktive når `config.threads = true`. Meldinger kan ha en `reply_to`-referanse. Frontend viser tråder som innrykk eller ekspanderbare grupper.
|
Kun aktive når `config.threads = true`. Meldinger kan ha en `reply_to`-referanse. Frontend grupperer meldinger i tråder (rot + svar) med visuell skillelinje mellom hver tråd. Svar vises med innrykk og vertikal linje under rot-meldingen, uten ekstra skillelinje mellom rot og svar.
|
||||||
|
|
||||||
## 6. Vedlegg
|
## 6. Vedlegg
|
||||||
Kun aktive når `config.attachments = true`. Meldinger kan ha vedlegg via `message_attachments` → `media_files`. Whiteboard-eksport kan knyttes som vedlegg.
|
Kun aktive når `config.attachments = true`. Meldinger kan ha vedlegg via `message_attachments` → `media_files`. Whiteboard-eksport kan knyttes som vedlegg.
|
||||||
|
|
||||||
## 7. Versjonshistorikk
|
## 7. Versjonshistorikk
|
||||||
Alle meldinger støtter redigering med full historikk via `message_revisions`. Original tekst bevares alltid.
|
Alle meldinger støtter redigering med full historikk via `message_revisions`. Original tekst bevares alltid. AI-behandlede meldinger har en revisjons-toggle i UI — brukeren kan veksle mellom AI-versjon og original tekst. AI-output rendres som Markdown via `marked`.
|
||||||
|
|
||||||
|
## 7.1 Meldingsvisning
|
||||||
|
Lange meldinger (mer enn 2 linjer) kollapses automatisk med en "Vis mer"-knapp. Ved ekspandering vises "Vis mindre" både over og under meldingen, slik at man slipper å scrolle for å kollapse igjen.
|
||||||
|
|
||||||
## 8. Tale-til-tekst (Voice-to-text)
|
## 8. Tale-til-tekst (Voice-to-text)
|
||||||
Mobilvennlig diktering for situasjoner der tastatur er upraktisk. Brukeren trykker en mikrofon-knapp, snakker, og får teksten tilbake som en vanlig melding klar til redigering og sending.
|
Mobilvennlig diktering for situasjoner der tastatur er upraktisk. Brukeren trykker en mikrofon-knapp, snakker, og får teksten tilbake som en vanlig melding klar til redigering og sending.
|
||||||
|
|
@ -124,8 +127,11 @@ Channels med `config.ttl_days` satt til et tall får sine meldinger automatisk s
|
||||||
- **Worker warmup (`worker/src/warmup.rs`):** PG → SpacetimeDB ved oppstart. Per-kanal konfig (all/messages/days/none). Trådbasert henting.
|
- **Worker warmup (`worker/src/warmup.rs`):** PG → SpacetimeDB ved oppstart. Per-kanal konfig (all/messages/days/none). Trådbasert henting.
|
||||||
- **Worker sync (`worker/src/sync.rs`):** SpacetimeDB → PG hvert sekund. Insert/delete/update meldinger + reaksjoner.
|
- **Worker sync (`worker/src/sync.rs`):** SpacetimeDB → PG hvert sekund. Insert/delete/update meldinger + reaksjoner.
|
||||||
- **Admin-side (`/admin/channels`):** Per-kanal warmup-konfigurasjon.
|
- **Admin-side (`/admin/channels`):** Per-kanal warmup-konfigurasjon.
|
||||||
- **Tråder:** Komplett trådvisning med datogruppering og autoscroll.
|
- **Tråder:** Komplett trådvisning med datogruppering, autoscroll og visuell skillelinje mellom tråder.
|
||||||
- **Reaksjoner:** Via SpacetimeDB-reducers, synket til PG.
|
- **Reaksjoner:** Via SpacetimeDB-reducers, synket til PG.
|
||||||
|
- **Meldingskollaps:** Lange meldinger begrenses til 2 linjer med "Vis mer"/"Vis mindre".
|
||||||
|
- **AI-behandling:** Meldinger kan AI-behandles (✨-knapp). Revisjons-toggle viser original vs. AI-versjon. Markdown-rendering for AI-output.
|
||||||
|
- **Konvertering:** Meldinger kan opprettes som kanban-kort eller kalenderhendelse (dialog sier "Opprett", ikke "Konverter" — meldingen beholdes i chatten).
|
||||||
|
|
||||||
### Gjenstår
|
### Gjenstår
|
||||||
- **Vedlegg, TTL** — avventer implementering.
|
- **Vedlegg, TTL** — avventer implementering.
|
||||||
|
|
|
||||||
|
|
@ -24,59 +24,86 @@ Fordeler:
|
||||||
|
|
||||||
## 3. Modellruting
|
## 3. Modellruting
|
||||||
|
|
||||||
Modellvalg styres av to mekanismer:
|
### 3.1 Arkitekturprinsipp: PG eier config, LiteLLM er stateløs
|
||||||
|
|
||||||
### 3.1 Standard ruting (config.yaml)
|
PostgreSQL er single source of truth for all modellkonfigurasjon. LiteLLM er en stateløs proxy som får generert `config.yaml` fra PG-data. Dette gir:
|
||||||
LiteLLM konfigureres med modellaliaser som mapper til billigste egnede leverandør:
|
|
||||||
|
|
||||||
```yaml
|
* **Ingen avhengighet til LiteLLM sitt admin API** — de endrer API mellom versjoner
|
||||||
model_list:
|
* **All konfig i samme backup/migrasjon** som resten av systemet
|
||||||
# Ruting: billigste først, fallback til dyrere
|
* **Enkel bytte** — hvis LiteLLM erstattes, er all konfig intakt i PG
|
||||||
- model_name: "sidelinja/rutine"
|
* **Admin-UI i SvelteKit** — gjenbruker eksisterende `/admin/`-mønster
|
||||||
litellm_params:
|
|
||||||
model: "gemini/gemini-2.0-flash"
|
|
||||||
api_key: "os.environ/GEMINI_API_KEY"
|
|
||||||
- model_name: "sidelinja/rutine"
|
|
||||||
litellm_params:
|
|
||||||
model: "openrouter/google/gemini-2.0-flash-001"
|
|
||||||
api_key: "os.environ/OPENROUTER_API_KEY"
|
|
||||||
|
|
||||||
- model_name: "sidelinja/resonering"
|
### 3.2 Datamodell
|
||||||
litellm_params:
|
|
||||||
model: "anthropic/claude-sonnet-4-20250514"
|
|
||||||
api_key: "os.environ/ANTHROPIC_API_KEY"
|
|
||||||
- model_name: "sidelinja/resonering"
|
|
||||||
litellm_params:
|
|
||||||
model: "openrouter/anthropic/claude-sonnet-4-20250514"
|
|
||||||
api_key: "os.environ/OPENROUTER_API_KEY"
|
|
||||||
|
|
||||||
router_settings:
|
```sql
|
||||||
routing_strategy: "simple-shuffle" # prøv første, fallback til neste
|
-- Globale modellaliaser (server-nivå, ikke per workspace)
|
||||||
num_retries: 2
|
CREATE TABLE ai_model_aliases (
|
||||||
timeout: 60
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
alias TEXT NOT NULL, -- 'sidelinja/rutine', 'sidelinja/resonering'
|
||||||
|
description TEXT, -- 'Billig, høyt volum'
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(alias)
|
||||||
|
);
|
||||||
|
|
||||||
general_settings:
|
-- Leverandør-modeller med prioritert fallback per alias
|
||||||
master_key: "os.environ/LITELLM_MASTER_KEY"
|
CREATE TABLE ai_model_providers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
alias_id UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL, -- 'gemini', 'openrouter', 'anthropic'
|
||||||
|
model TEXT NOT NULL, -- 'gemini/gemini-2.5-flash', 'openrouter/anthropic/claude-sonnet-4'
|
||||||
|
api_key_env TEXT NOT NULL, -- 'GEMINI_API_KEY', 'OPENROUTER_API_KEY'
|
||||||
|
priority SMALLINT NOT NULL, -- lavere = prøves først
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
UNIQUE(alias_id, model)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Jobbtype → modellalias mapping
|
||||||
|
CREATE TABLE ai_job_routing (
|
||||||
|
job_type TEXT PRIMARY KEY, -- 'ai_text_process', 'whisper_postprocess', etc.
|
||||||
|
alias TEXT NOT NULL, -- 'sidelinja/rutine'
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 Jobbkø-styrt modellvalg
|
### 3.3 Config-generering
|
||||||
Jobbkøen (se `jobbkø.md`) spesifiserer modellalias per jobbtype:
|
|
||||||
|
|
||||||
| Jobbtype | Modellalias | Begrunnelse |
|
SvelteKit-serveren genererer `config.yaml` fra PG ved oppstart og ved endringer i admin-panelet:
|
||||||
|
|
||||||
|
1. Les aktive aliaser og deres providers (sortert etter priority)
|
||||||
|
2. Skriv `config.yaml` til volum delt med LiteLLM-containeren
|
||||||
|
3. Restart LiteLLM (`docker restart ai-gateway`) eller send `SIGHUP`
|
||||||
|
|
||||||
|
Generert config inkluderer alltid `router_settings` og `general_settings` fra faste verdier — kun `model_list` er dynamisk.
|
||||||
|
|
||||||
|
### 3.4 Jobbkø-styrt modellvalg
|
||||||
|
|
||||||
|
Jobbkøen bruker `ai_job_routing` for å bestemme modellalias per jobbtype:
|
||||||
|
|
||||||
|
| Jobbtype | Standard alias | Begrunnelse |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `whisper_postprocess` (transkripsjonsvasking) | `sidelinja/rutine` | Høyt volum, lav kompleksitet |
|
| `ai_text_process` (✨-behandling) | `sidelinja/rutine` | Tekstvasking, høyt volum |
|
||||||
| `openrouter_analyze` (metadata-uttrekk) | `sidelinja/rutine` | Strukturert output, lav kompleksitet |
|
| `whisper_postprocess` | `sidelinja/rutine` | Transkripsjonsvasking, høyt volum |
|
||||||
| `research_clip` (research-oppsummering) | `sidelinja/rutine` | Høyt volum |
|
| `research_clip` | `sidelinja/rutine` | Research-oppsummering, høyt volum |
|
||||||
| `live_factoid_eval` (live-assistent) | `sidelinja/resonering` | Krever presis vurdering under tidspress |
|
| `live_factoid_eval` | `sidelinja/resonering` | Krever presis vurdering under tidspress |
|
||||||
|
|
||||||
Modellalias lagres som felt på jobben i PG — kan overstyres manuelt per jobb ved behov.
|
Modellalias lagres som felt på jobben i PG — kan overstyres manuelt per jobb ved behov.
|
||||||
|
|
||||||
|
### 3.5 Admin-panel (`/admin/ai`)
|
||||||
|
|
||||||
|
Admin-panelet lar administrator:
|
||||||
|
* Se og redigere modellaliaser og deres fallback-liste (drag-and-drop prioritering)
|
||||||
|
* Aktivere/deaktivere individuelle leverandør-modeller
|
||||||
|
* Endre jobbtype → alias mapping
|
||||||
|
* Se live-status: hvilke leverandører som svarer, responstider
|
||||||
|
* Trigge config-regenerering og LiteLLM-restart
|
||||||
|
|
||||||
## 4. Docker-oppsett
|
## 4. Docker-oppsett
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.dev.yml / docker-compose.yml
|
# docker-compose.dev.yml / docker-compose.yml
|
||||||
ai-gateway:
|
ai-gateway:
|
||||||
image: ghcr.io/berriai/litellm:main
|
image: ghcr.io/berriai/litellm:main-stable
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: --config /etc/litellm/config.yaml
|
command: --config /etc/litellm/config.yaml
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -151,36 +178,55 @@ tests/prompts/
|
||||||
└── dataset.json
|
└── dataset.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Kostnadskontroll
|
## 6. Tokenregnskap og kostnadskontroll
|
||||||
|
|
||||||
LiteLLM har innebygd logging, men mangler workspace-nivå budsjettering. For å forhindre kostnadssprekk:
|
### 6.1 Token-logging per workspace
|
||||||
|
|
||||||
### 6.1 Workspace-budsjett
|
Rust-workeren logger tokenforbruk etter hvert AI-kall. Dataen lagres i PG:
|
||||||
Hver workspace har et månedlig AI-budsjett lagret i `workspaces.settings` (JSONB):
|
|
||||||
|
|
||||||
```json
|
```sql
|
||||||
{
|
CREATE TABLE ai_usage_log (
|
||||||
"ai_budget": {
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
"monthly_limit_usd": 50,
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
"alert_threshold_pct": 80,
|
job_id UUID REFERENCES job_queue(id) ON DELETE SET NULL,
|
||||||
"auto_fallback": true
|
model_alias TEXT NOT NULL, -- 'sidelinja/rutine'
|
||||||
}
|
model_actual TEXT, -- 'gemini/gemini-2.5-flash' (fra LiteLLM-respons)
|
||||||
}
|
prompt_tokens INT NOT NULL,
|
||||||
|
completion_tokens INT NOT NULL,
|
||||||
|
total_tokens INT NOT NULL,
|
||||||
|
estimated_cost NUMERIC(10, 6), -- USD, beregnet fra kjente priser
|
||||||
|
job_type TEXT, -- 'ai_text_process', etc.
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ai_usage_workspace_month ON ai_usage_log (workspace_id, created_at);
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Sporing:** SvelteKit logger token-bruk per AI-kall med workspace_id og jobbtype i `ai_usage_log`-tabellen (flyktig, TTL 90 dager).
|
**Flyten:**
|
||||||
- **Alert:** Når 80 % av budsjettet er brukt, postes varsel i workspace-chat (system-channel).
|
1. Rust-worker sender AI-kall via gateway, får tilbake `usage` i responsen
|
||||||
- **Auto-fallback:** Når budsjettet er nådd og `auto_fallback: true`, rutes alle kall til `sidelinja/rutine` (billigste modell). Ellers blokkeres AI-kall med feilmelding.
|
2. Worker skriver rad til `ai_usage_log` med workspace_id, tokens og modellinfo
|
||||||
|
3. Estimert kostnad beregnes fra en enkel prisliste i config (oppdateres manuelt)
|
||||||
|
|
||||||
### 6.2 Per-episode maks-kostnad
|
### 6.2 Visning — to nivåer
|
||||||
|
|
||||||
|
**Admin (`/admin/ai`):**
|
||||||
|
Aggregert oversikt over alle workspaces. Tabell med totaler per workspace/modell/periode. Identifiserer kostnadsdrivere.
|
||||||
|
|
||||||
|
**Workspace (sidebar-widget):**
|
||||||
|
Enkel tekst-indikator i workspace-sidebar: `✨ 12.4k tokens denne uken`. Klikk åpner detaljert visning med fordeling per jobbtype og modell. Ingen speedometer — det krever et definert budsjett for å gi mening, og det er overkill for MVP.
|
||||||
|
|
||||||
|
### 6.3 Workspace-budsjett (fase 2)
|
||||||
|
|
||||||
|
Når token-logging er på plass, kan budsjett-tak legges til:
|
||||||
|
|
||||||
|
- Budsjett lagres i `workspaces.settings` (JSONB): `{ "ai_budget": { "monthly_limit_usd": 50 } }`
|
||||||
|
- Rust-worker sjekker aggregert forbruk før AI-kall
|
||||||
|
- Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste)
|
||||||
|
- Ved budsjett nådd: sett jobb i `paused` med varsel i workspace-chat
|
||||||
|
|
||||||
|
### 6.4 Per-episode maks-kostnad
|
||||||
Podcastfabrikken-jobber (whisper + metadata + oppsummering) kan estimere totalkostnad basert på lydlengde. Jobben avbrytes med varsel hvis estimert kostnad overstiger `max_cost_per_episode` (default: $5).
|
Podcastfabrikken-jobber (whisper + metadata + oppsummering) kan estimere totalkostnad basert på lydlengde. Jobben avbrytes med varsel hvis estimert kostnad overstiger `max_cost_per_episode` (default: $5).
|
||||||
|
|
||||||
### 6.3 Modell-nedgradering
|
|
||||||
Jobbkøen støtter automatisk modell-nedgradering ved kostnadsmål:
|
|
||||||
1. Prøv `sidelinja/resonering` (Claude)
|
|
||||||
2. Ved budsjett-nær: fall tilbake til `sidelinja/rutine` (Gemini gratis)
|
|
||||||
3. Ved budsjett-nådd: sett jobb i `paused`-status med varsel
|
|
||||||
|
|
||||||
## 7. Dataklassifisering (ref. docs/arkitektur.md 2.2)
|
## 7. Dataklassifisering (ref. docs/arkitektur.md 2.2)
|
||||||
|
|
||||||
| Data | Kategori | Detaljer |
|
| Data | Kategori | Detaljer |
|
||||||
|
|
|
||||||
8
migrations/0015_usage_action_column.sql
Normal file
8
migrations/0015_usage_action_column.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- 0015_usage_action_column.sql
|
||||||
|
-- Legg til action-kolonne i ai_usage_log for å spore hvilken prompt som ble brukt.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE ai_usage_log ADD COLUMN IF NOT EXISTS action TEXT;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import type { KanbanConnection, KanbanColumn } from '$lib/kanban/types';
|
import type { KanbanConnection, KanbanColumn } from '$lib/kanban/types';
|
||||||
import type { MessageData } from '$lib/types/message';
|
import type { MessageData } from '$lib/types/message';
|
||||||
import MessageBox from '$lib/components/MessageBox.svelte';
|
import MessageBox from '$lib/components/MessageBox.svelte';
|
||||||
|
import Editor from '$lib/components/Editor.svelte';
|
||||||
|
|
||||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
|
||||||
|
|
@ -225,12 +226,13 @@
|
||||||
bind:value={editTitle}
|
bind:value={editTitle}
|
||||||
placeholder="Tittel"
|
placeholder="Tittel"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<div class="edit-desc-editor">
|
||||||
class="edit-desc"
|
<Editor
|
||||||
bind:value={editDescription}
|
mode="extended"
|
||||||
placeholder="Beskrivelse (valgfritt)"
|
bind:content={editDescription}
|
||||||
rows="3"
|
placeholder="Beskrivelse (valgfritt)"
|
||||||
></textarea>
|
/>
|
||||||
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" onclick={saveEdit}>Lagre</button>
|
<button type="button" onclick={saveEdit}>Lagre</button>
|
||||||
<button type="button" class="delete" onclick={handleDeleteCard}>Slett</button>
|
<button type="button" class="delete" onclick={handleDeleteCard}>Slett</button>
|
||||||
|
|
@ -457,7 +459,7 @@
|
||||||
border: 1px solid #2d3148;
|
border: 1px solid #2d3148;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
width: 340px;
|
width: 420px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -480,24 +482,13 @@
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-desc {
|
.edit-desc-editor {
|
||||||
background: #0f1117;
|
|
||||||
border: 1px solid #2d3148;
|
border: 1px solid #2d3148;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #e1e4e8;
|
overflow: hidden;
|
||||||
padding: 0.5rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
resize: vertical;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-desc:focus {
|
.edit-title::placeholder {
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-desc::placeholder, .edit-title::placeholder {
|
|
||||||
color: #8b92a5;
|
color: #8b92a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
import {
|
import {
|
||||||
TypeBuilder as __TypeBuilder,
|
TypeBuilder as __TypeBuilder,
|
||||||
t as __t,
|
t as __t,
|
||||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
type Infer as __Infer,
|
type Infer as __Infer,
|
||||||
} from "spacetimedb";
|
} from "spacetimedb";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
messageId: __t.string(),
|
messageId: __t.string(),
|
||||||
workspaceId: __t.string(),
|
workspaceId: __t.string(),
|
||||||
userId: __t.string(),
|
userId: __t.string(),
|
||||||
userName: __t.string(),
|
userName: __t.string(),
|
||||||
reaction: __t.string(),
|
reaction: __t.string(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
import {
|
import {
|
||||||
TypeBuilder as __TypeBuilder,
|
TypeBuilder as __TypeBuilder,
|
||||||
t as __t,
|
t as __t,
|
||||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
type Infer as __Infer,
|
type Infer as __Infer,
|
||||||
} from "spacetimedb";
|
} from "spacetimedb";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
channelId: __t.string(),
|
channelId: __t.string(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
import {
|
import {
|
||||||
TypeBuilder as __TypeBuilder,
|
TypeBuilder as __TypeBuilder,
|
||||||
t as __t,
|
t as __t,
|
||||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
type Infer as __Infer,
|
type Infer as __Infer,
|
||||||
} from "spacetimedb";
|
} from "spacetimedb";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
id: __t.string(),
|
id: __t.string(),
|
||||||
workspaceId: __t.string(),
|
workspaceId: __t.string(),
|
||||||
newBody: __t.string(),
|
newBody: __t.string(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -156,3 +156,4 @@ export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
|
||||||
return new SubscriptionBuilder(this);
|
return new SubscriptionBuilder(this);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
import {
|
import {
|
||||||
TypeBuilder as __TypeBuilder,
|
TypeBuilder as __TypeBuilder,
|
||||||
t as __t,
|
t as __t,
|
||||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
type Infer as __Infer,
|
type Infer as __Infer,
|
||||||
} from "spacetimedb";
|
} from "spacetimedb";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
reactionsJson: __t.string(),
|
reactionsJson: __t.string(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
import {
|
import {
|
||||||
TypeBuilder as __TypeBuilder,
|
TypeBuilder as __TypeBuilder,
|
||||||
t as __t,
|
t as __t,
|
||||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
type Infer as __Infer,
|
type Infer as __Infer,
|
||||||
} from "spacetimedb";
|
} from "spacetimedb";
|
||||||
|
|
||||||
export default __t.row({
|
export default __t.row({
|
||||||
id: __t.u64().primaryKey(),
|
id: __t.u64().primaryKey(),
|
||||||
messageId: __t.string().name("message_id"),
|
messageId: __t.string().name("message_id"),
|
||||||
userId: __t.string().name("user_id"),
|
userId: __t.string().name("user_id"),
|
||||||
userName: __t.string().name("user_name"),
|
userName: __t.string().name("user_name"),
|
||||||
reaction: __t.string(),
|
reaction: __t.string(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
import {
|
import {
|
||||||
TypeBuilder as __TypeBuilder,
|
TypeBuilder as __TypeBuilder,
|
||||||
t as __t,
|
t as __t,
|
||||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
type Infer as __Infer,
|
type Infer as __Infer,
|
||||||
} from "spacetimedb";
|
} from "spacetimedb";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
messageId: __t.string(),
|
messageId: __t.string(),
|
||||||
workspaceId: __t.string(),
|
workspaceId: __t.string(),
|
||||||
userId: __t.string(),
|
userId: __t.string(),
|
||||||
reaction: __t.string(),
|
reaction: __t.string(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,4 @@ export const SyncOutbox = __t.object("SyncOutbox", {
|
||||||
synced: __t.bool(),
|
synced: __t.bool(),
|
||||||
});
|
});
|
||||||
export type SyncOutbox = __Infer<typeof SyncOutbox>;
|
export type SyncOutbox = __Infer<typeof SyncOutbox>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,4 @@ export type LoadReactionsParams = __Infer<typeof LoadReactionsReducer>;
|
||||||
export type MarkSyncedParams = __Infer<typeof MarkSyncedReducer>;
|
export type MarkSyncedParams = __Infer<typeof MarkSyncedReducer>;
|
||||||
export type RemoveReactionParams = __Infer<typeof RemoveReactionReducer>;
|
export type RemoveReactionParams = __Infer<typeof RemoveReactionReducer>;
|
||||||
export type SendMessageParams = __Infer<typeof SendMessageReducer>;
|
export type SendMessageParams = __Infer<typeof SendMessageReducer>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,7 @@ export function createSpacetimeChat(
|
||||||
edit,
|
edit,
|
||||||
delete: del,
|
delete: del,
|
||||||
react,
|
react,
|
||||||
refresh: async () => { rebuildMessages(); },
|
refresh: async () => { rebuildMessages(); await enrichFromPg(); },
|
||||||
destroy
|
destroy
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="convert-dialog" onclick={(e) => e.stopPropagation()}>
|
<div class="convert-dialog" onclick={(e) => e.stopPropagation()}>
|
||||||
<h3>{type === 'kanban' ? 'Konverter til kanban-kort' : 'Konverter til kalenderhendelse'}</h3>
|
<h3>{type === 'kanban' ? 'Opprett kanban-kort' : 'Opprett kalenderhendelse'}</h3>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="convert-dialog__loading">Laster...</p>
|
<p class="convert-dialog__loading">Laster...</p>
|
||||||
|
|
@ -147,7 +147,7 @@
|
||||||
<button class="convert-dialog__btn convert-dialog__btn--cancel" onclick={onCancel}>Avbryt</button>
|
<button class="convert-dialog__btn convert-dialog__btn--cancel" onclick={onCancel}>Avbryt</button>
|
||||||
<button class="convert-dialog__btn convert-dialog__btn--confirm" onclick={handleSubmit}
|
<button class="convert-dialog__btn convert-dialog__btn--confirm" onclick={handleSubmit}
|
||||||
disabled={loading || (type === 'kanban' ? !selectedColumnId : !selectedCalendarId || !startsAt)}
|
disabled={loading || (type === 'kanban' ? !selectedColumnId : !selectedCalendarId || !startsAt)}
|
||||||
>Konverter</button>
|
>Opprett</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -307,27 +307,15 @@
|
||||||
<div bind:this={editorEl} class="editor-mount"></div>
|
<div bind:this={editorEl} class="editor-mount"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mode === 'compact'}
|
{#if mode === 'compact' && !expanded}
|
||||||
<button type="button" class="expand-btn" onclick={toggleExpand} title={expanded ? 'Minimer' : 'Utvid editor'}>
|
<button type="button" class="expand-btn" onclick={toggleExpand} title="Utvid editor">
|
||||||
{#if expanded}
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<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="4 14 10 14 10 20"></polyline>
|
<polyline points="9 21 3 21 3 15"></polyline>
|
||||||
<polyline points="20 10 14 10 14 4"></polyline>
|
<line x1="21" y1="3" x2="14" y2="10"></line>
|
||||||
<line x1="14" y1="10" x2="21" y2="3"></line>
|
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||||
<line x1="3" y1="21" x2="10" y2="14"></line>
|
</svg>
|
||||||
</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>
|
</button>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if mode === 'compact'}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="send-btn"
|
class="send-btn"
|
||||||
|
|
@ -342,6 +330,30 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if mode === 'compact' && expanded}
|
||||||
|
<div class="editor-bottom-bar">
|
||||||
|
<button type="button" class="expand-btn" onclick={toggleExpand} title="Minimer">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Mention suggestions popup -->
|
<!-- Mention suggestions popup -->
|
||||||
{#if suggestions.length > 0 && mentionPopupPos}
|
{#if suggestions.length > 0 && mentionPopupPos}
|
||||||
<div
|
<div
|
||||||
|
|
@ -616,6 +628,15 @@
|
||||||
margin: 0 0.15rem;
|
margin: 0 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom bar (expanded compact mode) */
|
||||||
|
.editor-bottom-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Send button (compact mode) */
|
/* Send button (compact mode) */
|
||||||
.send-btn {
|
.send-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -412,8 +412,9 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.metadata?.ai_action}
|
{#if message.metadata?.ai_action}
|
||||||
{@const promptInfo = aiPrompts.find(p => p.action === message.metadata?.ai_action)}
|
<span class="messagebox__ai-badge">
|
||||||
<span class="messagebox__ai-badge">{promptInfo?.icon ?? '✨'} {promptInfo?.label ?? message.metadata.ai_action}</span>
|
✨ {message.metadata.ai_label ?? message.metadata.ai_action}{#if message.metadata.ai_model} · {message.metadata.ai_model}{/if}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ export interface Workspace {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hent alle workspaces brukeren er medlem av */
|
/** Hent alle workspaces brukeren er medlem av, med rolle */
|
||||||
export async function getUserWorkspaces(userId: string): Promise<Workspace[]> {
|
export async function getUserWorkspaces(userId: string): Promise<(Workspace & { role: string })[]> {
|
||||||
return sql<Workspace[]>`
|
return sql<(Workspace & { role: string })[]>`
|
||||||
SELECT w.id, w.name, w.slug, w.domain, w.settings
|
SELECT w.id, w.name, w.slug, w.domain, w.settings, wm.role::text AS role
|
||||||
FROM workspaces w
|
FROM workspaces w
|
||||||
JOIN workspace_members wm ON wm.workspace_id = w.id
|
JOIN workspace_members wm ON wm.workspace_id = w.id
|
||||||
WHERE wm.user_id = ${userId}
|
WHERE wm.user_id = ${userId}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ export interface MessageData {
|
||||||
ai_processing?: boolean;
|
ai_processing?: boolean;
|
||||||
ai_processed?: boolean;
|
ai_processed?: boolean;
|
||||||
ai_action?: string;
|
ai_action?: string;
|
||||||
|
ai_label?: string;
|
||||||
|
ai_model?: string;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,13 @@
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { env } from '$env/dynamic/private';
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
interface OpenRouterModel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
context_length: number;
|
|
||||||
pricing: { prompt: string; completion: string };
|
|
||||||
top_provider?: { max_completion_tokens?: number };
|
|
||||||
architecture?: { modality?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CatalogModel {
|
export interface CatalogModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
litellm_prefix: string;
|
||||||
|
api_key_env: string;
|
||||||
context_length: number;
|
context_length: number;
|
||||||
prompt_price_per_m: number;
|
prompt_price_per_m: number;
|
||||||
completion_price_per_m: number;
|
completion_price_per_m: number;
|
||||||
|
|
@ -22,46 +15,157 @@ export interface CatalogModel {
|
||||||
max_completion: number | null;
|
max_completion: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderFetcher {
|
||||||
|
keyEnv: string;
|
||||||
|
label: string;
|
||||||
|
litellmPrefix: string;
|
||||||
|
fetch: (apiKey: string) => Promise<CatalogModel[]>;
|
||||||
|
}
|
||||||
|
|
||||||
let cache: { models: CatalogModel[]; fetched_at: number } | null = null;
|
let cache: { models: CatalogModel[]; fetched_at: number } | null = null;
|
||||||
const CACHE_TTL = 60 * 60 * 1000; // 1 time
|
const CACHE_TTL = 60 * 60 * 1000; // 1 time
|
||||||
|
|
||||||
function toPerMillion(pricePerToken: string): number {
|
function toPerMillion(pricePerToken: string | number): number {
|
||||||
const n = parseFloat(pricePerToken);
|
const n = typeof pricePerToken === 'string' ? parseFloat(pricePerToken) : pricePerToken;
|
||||||
if (isNaN(n)) return 0;
|
if (isNaN(n)) return 0;
|
||||||
return Math.round(n * 1_000_000 * 100) / 100;
|
return Math.round(n * 1_000_000 * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
// --- Provider-spesifikke hentere ---
|
||||||
if (!locals.workspace || !locals.user) error(401);
|
|
||||||
|
|
||||||
const apiKey = env.OPENROUTER_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
error(500, 'OPENROUTER_API_KEY er ikke konfigurert');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cache && Date.now() - cache.fetched_at < CACHE_TTL) {
|
|
||||||
return json(cache.models);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async function fetchOpenRouter(apiKey: string): Promise<CatalogModel[]> {
|
||||||
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
||||||
headers: { Authorization: `Bearer ${apiKey}` }
|
headers: { Authorization: `Bearer ${apiKey}` }
|
||||||
});
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
if (!res.ok) {
|
|
||||||
error(502, `OpenRouter returnerte ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
const models: CatalogModel[] = (body.data as OpenRouterModel[]).map((m) => ({
|
return (body.data ?? []).map((m: any) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name ?? m.id,
|
||||||
provider: m.id.split('/')[0],
|
provider: m.id.split('/')[0],
|
||||||
context_length: m.context_length,
|
litellm_prefix: 'openrouter/',
|
||||||
|
api_key_env: 'OPENROUTER_API_KEY',
|
||||||
|
context_length: m.context_length ?? 0,
|
||||||
prompt_price_per_m: toPerMillion(m.pricing?.prompt ?? '0'),
|
prompt_price_per_m: toPerMillion(m.pricing?.prompt ?? '0'),
|
||||||
completion_price_per_m: toPerMillion(m.pricing?.completion ?? '0'),
|
completion_price_per_m: toPerMillion(m.pricing?.completion ?? '0'),
|
||||||
modality: m.architecture?.modality ?? 'text',
|
modality: m.architecture?.modality ?? 'text',
|
||||||
max_completion: m.top_provider?.max_completion_tokens ?? null
|
max_completion: m.top_provider?.max_completion_tokens ?? null
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchXai(_apiKey: string): Promise<CatalogModel[]> {
|
||||||
|
// xAI /v1/models krever betalt konto — hardkod kjente modeller
|
||||||
|
// Kilde: https://docs.x.ai/docs/models
|
||||||
|
const models = [
|
||||||
|
{ id: 'grok-4.20-multi-agent-beta-0309', name: 'Grok 4.20 Multi-Agent (beta)', ctx: 131072 },
|
||||||
|
{ id: 'grok-4.20-beta-0309-reasoning', name: 'Grok 4.20 (reasoning, beta)', ctx: 131072 },
|
||||||
|
{ id: 'grok-4.20-beta-0309-non-reasoning', name: 'Grok 4.20 (beta)', ctx: 131072 },
|
||||||
|
{ id: 'grok-4-0709', name: 'Grok 4', ctx: 131072 },
|
||||||
|
{ id: 'grok-4-fast-reasoning', name: 'Grok 4 Fast (reasoning)', ctx: 131072 },
|
||||||
|
{ id: 'grok-4-fast-non-reasoning', name: 'Grok 4 Fast', ctx: 131072 },
|
||||||
|
{ id: 'grok-4-1-fast-reasoning', name: 'Grok 4.1 Fast (reasoning)', ctx: 131072 },
|
||||||
|
{ id: 'grok-4-1-fast-non-reasoning', name: 'Grok 4.1 Fast', ctx: 131072 },
|
||||||
|
{ id: 'grok-3', name: 'Grok 3', ctx: 131072 },
|
||||||
|
{ id: 'grok-3-mini', name: 'Grok 3 Mini', ctx: 131072 },
|
||||||
|
{ id: 'grok-code-fast-1', name: 'Grok Code Fast', ctx: 131072 },
|
||||||
|
];
|
||||||
|
return models.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
provider: 'xai',
|
||||||
|
litellm_prefix: 'xai/',
|
||||||
|
api_key_env: 'XAI_API_KEY',
|
||||||
|
context_length: m.ctx,
|
||||||
|
prompt_price_per_m: -1,
|
||||||
|
completion_price_per_m: -1,
|
||||||
|
modality: 'text',
|
||||||
|
max_completion: null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGemini(apiKey: string): Promise<CatalogModel[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const body = await res.json();
|
||||||
|
return (body.models ?? [])
|
||||||
|
.filter((m: any) => m.supportedGenerationMethods?.includes('generateContent'))
|
||||||
|
.map((m: any) => {
|
||||||
|
// models/gemini-2.5-flash → gemini-2.5-flash
|
||||||
|
const shortName = (m.name as string).replace('models/', '');
|
||||||
|
return {
|
||||||
|
id: shortName,
|
||||||
|
name: m.displayName ?? shortName,
|
||||||
|
provider: 'google',
|
||||||
|
litellm_prefix: 'gemini/',
|
||||||
|
api_key_env: 'GEMINI_API_KEY',
|
||||||
|
context_length: m.inputTokenLimit ?? 0,
|
||||||
|
prompt_price_per_m: -1,
|
||||||
|
completion_price_per_m: -1,
|
||||||
|
modality: 'text',
|
||||||
|
max_completion: m.outputTokenLimit ?? null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOpenAI(apiKey: string): Promise<CatalogModel[]> {
|
||||||
|
const res = await fetch('https://api.openai.com/v1/models', {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const body = await res.json();
|
||||||
|
return (body.data ?? [])
|
||||||
|
.filter((m: any) => m.id.startsWith('gpt-') || m.id.startsWith('o') || m.id.startsWith('chatgpt-'))
|
||||||
|
.map((m: any) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.id,
|
||||||
|
provider: 'openai',
|
||||||
|
litellm_prefix: 'openai/',
|
||||||
|
api_key_env: 'OPENAI_API_KEY',
|
||||||
|
context_length: 128000,
|
||||||
|
prompt_price_per_m: 0,
|
||||||
|
completion_price_per_m: 0,
|
||||||
|
modality: 'text',
|
||||||
|
max_completion: null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS: ProviderFetcher[] = [
|
||||||
|
{ keyEnv: 'XAI_API_KEY', label: 'xAI', litellmPrefix: 'xai/', fetch: fetchXai },
|
||||||
|
{ keyEnv: 'GEMINI_API_KEY', label: 'Google', litellmPrefix: 'gemini/', fetch: fetchGemini },
|
||||||
|
{ keyEnv: 'OPENAI_API_KEY', label: 'OpenAI', litellmPrefix: 'openai/', fetch: fetchOpenAI },
|
||||||
|
{ keyEnv: 'OPENROUTER_API_KEY', label: 'OpenRouter', litellmPrefix: 'openrouter/', fetch: fetchOpenRouter }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const forceRefresh = url.searchParams.get('refresh') === '1';
|
||||||
|
|
||||||
|
if (!forceRefresh && cache && Date.now() - cache.fetched_at < CACHE_TTL) {
|
||||||
|
return json(cache.models);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hent aktive nøkler med verdier fra DB
|
||||||
|
const keys = await sql`
|
||||||
|
SELECT env_name, key_value FROM ai_api_keys WHERE is_enabled = true AND key_value IS NOT NULL
|
||||||
|
`;
|
||||||
|
const keyMap = new Map(keys.map((k: any) => [k.env_name, k.key_value as string]));
|
||||||
|
|
||||||
|
// Hent fra alle aktive leverandører parallelt
|
||||||
|
const promises = PROVIDERS
|
||||||
|
.filter(p => keyMap.has(p.keyEnv))
|
||||||
|
.map(async (p) => {
|
||||||
|
try {
|
||||||
|
return await p.fetch(keyMap.get(p.keyEnv)!);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const models = results.flat();
|
||||||
|
|
||||||
cache = { models, fetched_at: Date.now() };
|
cache = { models, fetched_at: Date.now() };
|
||||||
return json(models);
|
return json(models);
|
||||||
|
|
|
||||||
16
web/src/routes/api/admin/ai/prompts/+server.ts
Normal file
16
web/src/routes/api/admin/ai/prompts/+server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** GET — list alle AI-prompts */
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT action, system_prompt, description, updated_at
|
||||||
|
FROM ai_prompts
|
||||||
|
ORDER BY action
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(rows);
|
||||||
|
};
|
||||||
44
web/src/routes/api/admin/ai/prompts/[action]/+server.ts
Normal file
44
web/src/routes/api/admin/ai/prompts/[action]/+server.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** PATCH — oppdater system_prompt, description, label og/eller icon */
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const [row] = await sql`
|
||||||
|
UPDATE ai_prompts SET
|
||||||
|
system_prompt = COALESCE(${body.system_prompt ?? null}, system_prompt),
|
||||||
|
description = COALESCE(${body.description ?? null}, description),
|
||||||
|
label = COALESCE(${body.label ?? null}, label),
|
||||||
|
icon = COALESCE(${body.icon ?? null}, icon),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE action = ${params.action}
|
||||||
|
RETURNING action, system_prompt, description, label, icon, sort_order, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!row) error(404, 'Prompt ikke funnet');
|
||||||
|
return json(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PUT — opprett eller erstatt prompt for en action */
|
||||||
|
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.system_prompt) error(400, 'system_prompt er påkrevd');
|
||||||
|
|
||||||
|
const [row] = await sql`
|
||||||
|
INSERT INTO ai_prompts (action, system_prompt, description)
|
||||||
|
VALUES (${params.action}, ${body.system_prompt}, ${body.description ?? null})
|
||||||
|
ON CONFLICT (action) DO UPDATE SET
|
||||||
|
system_prompt = EXCLUDED.system_prompt,
|
||||||
|
description = COALESCE(EXCLUDED.description, ai_prompts.description),
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING action, system_prompt, description, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(row);
|
||||||
|
};
|
||||||
19
web/src/routes/api/admin/ai/providers/renumber/+server.ts
Normal file
19
web/src/routes/api/admin/ai/providers/renumber/+server.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** POST — renummerer prioriteter for en liste providers */
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const items: { id: string; priority: number }[] = await request.json();
|
||||||
|
if (!Array.isArray(items) || items.length === 0) error(400);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
await sql`
|
||||||
|
UPDATE ai_model_providers SET priority = ${item.priority} WHERE id = ${item.id}::uuid
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
|
|
@ -22,7 +22,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
const rows = after
|
const rows = after
|
||||||
? await sql`
|
? await sql`
|
||||||
SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
|
SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
|
||||||
m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to,
|
m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to, m.metadata,
|
||||||
u.display_name as author_name, u.authentik_id as author_id,
|
u.display_name as author_name, u.authentik_id as author_id,
|
||||||
(SELECT count(*)::int FROM messages r WHERE r.reply_to = m.id) as reply_count,
|
(SELECT count(*)::int FROM messages r WHERE r.reply_to = m.id) as reply_count,
|
||||||
pm.body as parent_body, pu.display_name as parent_author_name,
|
pm.body as parent_body, pu.display_name as parent_author_name,
|
||||||
|
|
@ -44,7 +44,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
`
|
`
|
||||||
: await sql`
|
: await sql`
|
||||||
SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
|
SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
|
||||||
m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to,
|
m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to, m.metadata,
|
||||||
u.display_name as author_name, u.authentik_id as author_id,
|
u.display_name as author_name, u.authentik_id as author_id,
|
||||||
(SELECT count(*)::int FROM messages r WHERE r.reply_to = m.id) as reply_count,
|
(SELECT count(*)::int FROM messages r WHERE r.reply_to = m.id) as reply_count,
|
||||||
pm.body as parent_body, pu.display_name as parent_author_name,
|
pm.body as parent_body, pu.display_name as parent_author_name,
|
||||||
|
|
@ -117,7 +117,8 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
ends_at: m.cal_ends_at ?? null,
|
ends_at: m.cal_ends_at ?? null,
|
||||||
all_day: m.cal_all_day ?? false,
|
all_day: m.cal_all_day ?? false,
|
||||||
color: m.cal_color ?? null
|
color: m.cal_color ?? null
|
||||||
} : null
|
} : null,
|
||||||
|
metadata: m.metadata ?? null
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return json(messages);
|
return json(messages);
|
||||||
|
|
|
||||||
28
web/src/routes/api/jobs/[jobId]/+server.ts
Normal file
28
web/src/routes/api/jobs/[jobId]/+server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/jobs/:jobId — Hent status for en jobb.
|
||||||
|
* Workspace-scopet: jobben må tilhøre brukerens workspace.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const [job] = await sql`
|
||||||
|
SELECT id, status, result, error_msg, created_at, completed_at
|
||||||
|
FROM job_queue
|
||||||
|
WHERE id = ${params.jobId}::uuid AND workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!job) error(404, 'Jobb ikke funnet');
|
||||||
|
|
||||||
|
return json({
|
||||||
|
id: job.id,
|
||||||
|
status: job.status,
|
||||||
|
result: job.result,
|
||||||
|
error: job.error_msg,
|
||||||
|
created_at: job.created_at,
|
||||||
|
completed_at: job.completed_at
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -30,10 +30,15 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
`;
|
`;
|
||||||
if (!msg) error(404, 'Melding ikke funnet');
|
if (!msg) error(404, 'Melding ikke funnet');
|
||||||
|
|
||||||
|
// Fjern eventuell eksisterende reaksjon fra denne brukeren (én reaksjon per bruker per melding)
|
||||||
|
await sql`
|
||||||
|
DELETE FROM message_reactions
|
||||||
|
WHERE message_id = ${messageId} AND user_id = ${userId}
|
||||||
|
`;
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO message_reactions (message_id, user_id, reaction)
|
INSERT INTO message_reactions (message_id, user_id, reaction)
|
||||||
VALUES (${messageId}, ${userId}, ${reaction})
|
VALUES (${messageId}, ${userId}, ${reaction})
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const reactions = await getReactions(messageId, userId);
|
const reactions = await getReactions(messageId, userId);
|
||||||
|
|
|
||||||
|
|
@ -22,22 +22,23 @@ export const load: PageServerLoad = async () => {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const prompts = await sql`
|
const prompts = await sql`
|
||||||
SELECT action, system_prompt, description, updated_at
|
SELECT action, system_prompt, description, label, icon, sort_order, updated_at
|
||||||
FROM ai_prompts
|
FROM ai_prompts
|
||||||
ORDER BY action
|
ORDER BY sort_order, action
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const usage = await sql`
|
const usage = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
model_alias,
|
model_alias,
|
||||||
model_actual,
|
model_actual,
|
||||||
|
action,
|
||||||
count(*)::int AS call_count,
|
count(*)::int AS call_count,
|
||||||
sum(prompt_tokens)::int AS prompt_tokens,
|
sum(prompt_tokens)::int AS prompt_tokens,
|
||||||
sum(completion_tokens)::int AS completion_tokens,
|
sum(completion_tokens)::int AS completion_tokens,
|
||||||
sum(total_tokens)::int AS total_tokens
|
sum(total_tokens)::int AS total_tokens
|
||||||
FROM ai_usage_log
|
FROM ai_usage_log
|
||||||
WHERE created_at > now() - interval '30 days'
|
WHERE created_at > now() - interval '30 days'
|
||||||
GROUP BY model_alias, model_actual
|
GROUP BY model_alias, model_actual, action
|
||||||
ORDER BY total_tokens DESC
|
ORDER BY total_tokens DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,16 @@
|
||||||
action: string;
|
action: string;
|
||||||
system_prompt: string;
|
system_prompt: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
label: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
sort_order: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageRow {
|
interface UsageRow {
|
||||||
model_alias: string;
|
model_alias: string;
|
||||||
model_actual: string | null;
|
model_actual: string | null;
|
||||||
|
action: string | null;
|
||||||
call_count: number;
|
call_count: number;
|
||||||
prompt_tokens: number;
|
prompt_tokens: number;
|
||||||
completion_tokens: number;
|
completion_tokens: number;
|
||||||
|
|
@ -47,6 +51,8 @@
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
litellm_prefix: string;
|
||||||
|
api_key_env: string;
|
||||||
context_length: number;
|
context_length: number;
|
||||||
prompt_price_per_m: number;
|
prompt_price_per_m: number;
|
||||||
completion_price_per_m: number;
|
completion_price_per_m: number;
|
||||||
|
|
@ -74,6 +80,8 @@
|
||||||
let configMsg = $state('');
|
let configMsg = $state('');
|
||||||
let editingPrompt = $state<string | null>(null);
|
let editingPrompt = $state<string | null>(null);
|
||||||
let editPromptText = $state('');
|
let editPromptText = $state('');
|
||||||
|
let editPromptLabel = $state('');
|
||||||
|
let editPromptIcon = $state('');
|
||||||
let expandedAlias = $state<string | null>(null);
|
let expandedAlias = $state<string | null>(null);
|
||||||
|
|
||||||
// Alias-redigering
|
// Alias-redigering
|
||||||
|
|
@ -135,8 +143,9 @@
|
||||||
return String(n);
|
return String(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(n: number): string {
|
function formatPrice(n: number | null | undefined): string {
|
||||||
if (n === 0) return 'Gratis';
|
if (n == null || n < 0) return '\u2014';
|
||||||
|
if (n === 0) return '\u2014';
|
||||||
return `$${n.toFixed(2)}`;
|
return `$${n.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,6 +272,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grupper etter api_key_env + provider (f.eks. "google via GEMINI_API_KEY" vs "google via OPENROUTER_API_KEY")
|
||||||
let groupedByProvider = $derived.by(() => {
|
let groupedByProvider = $derived.by(() => {
|
||||||
const search = catalogSearch.toLowerCase();
|
const search = catalogSearch.toLowerCase();
|
||||||
const filtered = search
|
const filtered = search
|
||||||
|
|
@ -270,23 +280,42 @@
|
||||||
(m) =>
|
(m) =>
|
||||||
m.name.toLowerCase().includes(search) ||
|
m.name.toLowerCase().includes(search) ||
|
||||||
m.id.toLowerCase().includes(search) ||
|
m.id.toLowerCase().includes(search) ||
|
||||||
m.provider.toLowerCase().includes(search)
|
m.provider.toLowerCase().includes(search) ||
|
||||||
|
m.api_key_env.toLowerCase().includes(search)
|
||||||
)
|
)
|
||||||
: catalogModels;
|
: catalogModels;
|
||||||
|
|
||||||
const map = new Map<string, CatalogModel[]>();
|
const map = new Map<string, CatalogModel[]>();
|
||||||
for (const m of filtered) {
|
for (const m of filtered) {
|
||||||
const list = map.get(m.provider) ?? [];
|
// Grupper per API-nøkkel, med provider som undergruppe
|
||||||
|
const groupKey = m.api_key_env === 'OPENROUTER_API_KEY'
|
||||||
|
? `${m.provider} (OpenRouter)`
|
||||||
|
: m.provider;
|
||||||
|
const list = map.get(groupKey) ?? [];
|
||||||
list.push(m);
|
list.push(m);
|
||||||
map.set(m.provider, list);
|
map.set(groupKey, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorter modeller synkende etter pris innen hver provider
|
// Sorter: pris synkende først (ukjent/-1 sist), deretter navn synkende
|
||||||
for (const [, models] of map) {
|
for (const [, models] of map) {
|
||||||
models.sort((a, b) => b.completion_price_per_m - a.completion_price_per_m);
|
models.sort((a, b) => {
|
||||||
|
const aPrice = a.completion_price_per_m;
|
||||||
|
const bPrice = b.completion_price_per_m;
|
||||||
|
const aHasPrice = aPrice > 0;
|
||||||
|
const bHasPrice = bPrice > 0;
|
||||||
|
if (aHasPrice !== bHasPrice) return aHasPrice ? -1 : 1;
|
||||||
|
if (aPrice !== bPrice) return bPrice - aPrice;
|
||||||
|
return b.name.localeCompare(a.name);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
|
// Direkte API-nøkler først, deretter OpenRouter-grupper
|
||||||
|
return [...map.entries()].sort(([a], [b]) => {
|
||||||
|
const aOr = a.includes('OpenRouter');
|
||||||
|
const bOr = b.includes('OpenRouter');
|
||||||
|
if (aOr !== bOr) return aOr ? 1 : -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let catalogPickerFiltered = $derived.by(() => {
|
let catalogPickerFiltered = $derived.by(() => {
|
||||||
|
|
@ -310,43 +339,11 @@
|
||||||
expandedProviders = new Set(expandedProviders);
|
expandedProviders = new Set(expandedProviders);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mapping fra OpenRouter-provider til LiteLLM direkte-prefiks + nøkkel
|
function litellmModelId(model: CatalogModel): string {
|
||||||
const directKeyMap: Record<string, { prefix: string; key: string }> = {
|
return `${model.litellm_prefix}${model.id}`;
|
||||||
google: { prefix: 'gemini/', key: 'GEMINI_API_KEY' },
|
|
||||||
anthropic: { prefix: 'anthropic/', key: 'ANTHROPIC_API_KEY' },
|
|
||||||
openai: { prefix: 'openai/', key: 'OPENAI_API_KEY' },
|
|
||||||
'x-ai': { prefix: 'xai/', key: 'XAI_API_KEY' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function modelForKey(model: CatalogModel, keyEnv: string): string {
|
|
||||||
if (keyEnv === 'OPENROUTER_API_KEY') return `openrouter/${model.id}`;
|
|
||||||
// Direkte: strip provider-prefix fra model.id, legg til LiteLLM-prefiks
|
|
||||||
const mapping = directKeyMap[model.provider];
|
|
||||||
if (mapping) {
|
|
||||||
const modelName = model.id.replace(`${model.provider}/`, '');
|
|
||||||
return `${mapping.prefix}${modelName}`;
|
|
||||||
}
|
|
||||||
return `openrouter/${model.id}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function availableKeysForModel(model: CatalogModel): ApiKey[] {
|
async function addFromCatalog(model: CatalogModel, aliasId: string) {
|
||||||
const keys: ApiKey[] = [];
|
|
||||||
// Direkte nøkkel for denne leverandøren
|
|
||||||
const mapping = directKeyMap[model.provider];
|
|
||||||
if (mapping) {
|
|
||||||
const directKey = apiKeys.find(k => k.name === mapping.key);
|
|
||||||
if (directKey) keys.push(directKey);
|
|
||||||
}
|
|
||||||
// OpenRouter alltid tilgjengelig
|
|
||||||
const orKey = apiKeys.find(k => k.name === 'OPENROUTER_API_KEY');
|
|
||||||
if (orKey) keys.push(orKey);
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catalog add — steg 1: velg alias, steg 2: velg nøkkel
|
|
||||||
let catalogAddKey = $state('');
|
|
||||||
|
|
||||||
async function addFromCatalog(model: CatalogModel, aliasId: string, keyEnv: string) {
|
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
const maxPri = Math.max(0, ...providersForAlias(aliasId).map((p) => p.priority));
|
const maxPri = Math.max(0, ...providersForAlias(aliasId).map((p) => p.priority));
|
||||||
try {
|
try {
|
||||||
|
|
@ -356,8 +353,8 @@
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
alias_id: aliasId,
|
alias_id: aliasId,
|
||||||
priority: maxPri + 1,
|
priority: maxPri + 1,
|
||||||
litellm_model: modelForKey(model, keyEnv),
|
litellm_model: litellmModelId(model),
|
||||||
api_key_env: keyEnv
|
api_key_env: model.api_key_env
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Feil ved opprettelse');
|
if (!res.ok) throw new Error('Feil ved opprettelse');
|
||||||
|
|
@ -365,14 +362,14 @@
|
||||||
providers = [...providers, row];
|
providers = [...providers, row];
|
||||||
addingFromCatalog = null;
|
addingFromCatalog = null;
|
||||||
catalogAddAlias = '';
|
catalogAddAlias = '';
|
||||||
catalogAddKey = '';
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg = 'Kunne ikke legge til provider fra katalog';
|
errorMsg = 'Kunne ikke legge til provider fra katalog';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFromPicker(model: CatalogModel) {
|
function selectFromPicker(model: CatalogModel) {
|
||||||
newProvider.litellm_model = modelForKey(model, newProvider.api_key_env);
|
newProvider.litellm_model = litellmModelId(model);
|
||||||
|
newProvider.api_key_env = model.api_key_env;
|
||||||
showCatalogPicker = false;
|
showCatalogPicker = false;
|
||||||
catalogPickerSearch = '';
|
catalogPickerSearch = '';
|
||||||
}
|
}
|
||||||
|
|
@ -433,8 +430,12 @@
|
||||||
|
|
||||||
function estimateCost(row: UsageRow): number | null {
|
function estimateCost(row: UsageRow): number | null {
|
||||||
if (!catalogLoaded || !row.model_actual) return null;
|
if (!catalogLoaded || !row.model_actual) return null;
|
||||||
const model = catalogModels.find((m) => m.id === row.model_actual);
|
// model_actual kan være "xai/grok-..." eller "google/gemma-..." — match mot id eller litellm_prefix+id
|
||||||
if (!model) return null;
|
const actual = row.model_actual;
|
||||||
|
const model = catalogModels.find((m) =>
|
||||||
|
m.id === actual || `${m.litellm_prefix}${m.id}` === actual || `${m.provider}/${m.id}` === actual
|
||||||
|
);
|
||||||
|
if (!model || model.prompt_price_per_m < 0 || model.completion_price_per_m < 0) return null;
|
||||||
return (
|
return (
|
||||||
(row.prompt_tokens / 1_000_000) * model.prompt_price_per_m +
|
(row.prompt_tokens / 1_000_000) * model.prompt_price_per_m +
|
||||||
(row.completion_tokens / 1_000_000) * model.completion_price_per_m
|
(row.completion_tokens / 1_000_000) * model.completion_price_per_m
|
||||||
|
|
@ -589,11 +590,33 @@
|
||||||
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, { method: 'DELETE' });
|
||||||
if (!res.ok) throw new Error('Feil');
|
if (!res.ok) throw new Error('Feil');
|
||||||
providers = providers.filter((p) => p.id !== provider.id);
|
providers = providers.filter((p) => p.id !== provider.id);
|
||||||
|
// Renummerer prioriteter for gjenværende providers under samme alias
|
||||||
|
await renumberPriorities(provider.alias_id);
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg = 'Kunne ikke slette provider';
|
errorMsg = 'Kunne ikke slette provider';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renumberPriorities(aliasId: string) {
|
||||||
|
const ap = providersForAlias(aliasId);
|
||||||
|
let changed = false;
|
||||||
|
for (let i = 0; i < ap.length; i++) {
|
||||||
|
if (ap[i].priority !== i + 1) {
|
||||||
|
ap[i].priority = i + 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/admin/ai/providers/renumber', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(ap.map(p => ({ id: p.id, priority: p.priority })))
|
||||||
|
});
|
||||||
|
providers = [...providers];
|
||||||
|
} catch { /* stille */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function addAlias() {
|
async function addAlias() {
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
if (!newAlias.alias) return;
|
if (!newAlias.alias) return;
|
||||||
|
|
@ -655,11 +678,15 @@
|
||||||
function startEditPrompt(prompt: Prompt) {
|
function startEditPrompt(prompt: Prompt) {
|
||||||
editingPrompt = prompt.action;
|
editingPrompt = prompt.action;
|
||||||
editPromptText = prompt.system_prompt;
|
editPromptText = prompt.system_prompt;
|
||||||
|
editPromptLabel = prompt.label ?? '';
|
||||||
|
editPromptIcon = prompt.icon ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEditPrompt() {
|
function cancelEditPrompt() {
|
||||||
editingPrompt = null;
|
editingPrompt = null;
|
||||||
editPromptText = '';
|
editPromptText = '';
|
||||||
|
editPromptLabel = '';
|
||||||
|
editPromptIcon = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePrompt(prompt: Prompt) {
|
async function savePrompt(prompt: Prompt) {
|
||||||
|
|
@ -669,14 +696,22 @@
|
||||||
const res = await fetch(`/api/admin/ai/prompts/${prompt.action}`, {
|
const res = await fetch(`/api/admin/ai/prompts/${prompt.action}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ system_prompt: editPromptText })
|
body: JSON.stringify({
|
||||||
|
system_prompt: editPromptText,
|
||||||
|
label: editPromptLabel || null,
|
||||||
|
icon: editPromptIcon || null
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Feil ved lagring');
|
if (!res.ok) throw new Error('Feil ved lagring');
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
prompt.system_prompt = updated.system_prompt;
|
prompt.system_prompt = updated.system_prompt;
|
||||||
|
prompt.label = updated.label;
|
||||||
|
prompt.icon = updated.icon;
|
||||||
prompt.updated_at = updated.updated_at;
|
prompt.updated_at = updated.updated_at;
|
||||||
editingPrompt = null;
|
editingPrompt = null;
|
||||||
editPromptText = '';
|
editPromptText = '';
|
||||||
|
editPromptLabel = '';
|
||||||
|
editPromptIcon = '';
|
||||||
markSaved(prompt.action);
|
markSaved(prompt.action);
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg = 'Kunne ikke lagre prompt';
|
errorMsg = 'Kunne ikke lagre prompt';
|
||||||
|
|
@ -816,7 +851,7 @@
|
||||||
<!-- Seksjon 1: Modellkatalog -->
|
<!-- Seksjon 1: Modellkatalog -->
|
||||||
<section>
|
<section>
|
||||||
<div class="catalog-header">
|
<div class="catalog-header">
|
||||||
<h3>Modellkatalog (OpenRouter)</h3>
|
<h3>Modellkatalog</h3>
|
||||||
<div class="catalog-actions">
|
<div class="catalog-actions">
|
||||||
{#if catalogLoaded}
|
{#if catalogLoaded}
|
||||||
<input
|
<input
|
||||||
|
|
@ -825,6 +860,7 @@
|
||||||
placeholder="Søk modeller..."
|
placeholder="Søk modeller..."
|
||||||
bind:value={catalogSearch}
|
bind:value={catalogSearch}
|
||||||
/>
|
/>
|
||||||
|
<button class="toggle-btn" onclick={() => { catalogLoaded = false; catalogModels = []; }}>Skjul</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
|
|
@ -867,30 +903,23 @@
|
||||||
<span class="cat-col-add">
|
<span class="cat-col-add">
|
||||||
<button
|
<button
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
onclick={() => { addingFromCatalog = addingFromCatalog === model.id ? null : model.id; catalogAddAlias = ''; catalogAddKey = ''; }}
|
onclick={() => { addingFromCatalog = addingFromCatalog === model.id ? null : model.id; catalogAddAlias = ''; }}
|
||||||
>{addingFromCatalog === model.id ? '✗' : 'Legg til →'}</button>
|
>{addingFromCatalog === model.id ? '✗' : 'Legg til →'}</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if addingFromCatalog === model.id}
|
{#if addingFromCatalog === model.id}
|
||||||
{@const modelKeys = availableKeysForModel(model)}
|
|
||||||
<div class="catalog-add-row">
|
<div class="catalog-add-row">
|
||||||
<span class="catalog-add-label">Legg til <strong>{model.name}</strong>:</span>
|
<span class="catalog-add-label">Legg til <strong>{model.name}</strong> via {model.api_key_env}:</span>
|
||||||
<select bind:value={catalogAddAlias}>
|
<select bind:value={catalogAddAlias}>
|
||||||
<option value="">Velg alias...</option>
|
<option value="">Velg alias...</option>
|
||||||
{#each aliases as a}
|
{#each aliases as a}
|
||||||
<option value={a.id}>{a.alias}</option>
|
<option value={a.id}>{a.alias}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<select bind:value={catalogAddKey}>
|
|
||||||
<option value="">Velg nøkkel...</option>
|
|
||||||
{#each modelKeys as k}
|
|
||||||
<option value={k.name}>{k.label}{k.is_enabled ? '' : ' (av)'}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
class="add-btn"
|
class="add-btn"
|
||||||
disabled={!catalogAddAlias || !catalogAddKey}
|
disabled={!catalogAddAlias}
|
||||||
onclick={() => addFromCatalog(model, catalogAddAlias, catalogAddKey)}
|
onclick={() => addFromCatalog(model, catalogAddAlias)}
|
||||||
>Legg til</button>
|
>Legg til</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -1139,6 +1168,7 @@
|
||||||
<div class="table-list">
|
<div class="table-list">
|
||||||
<div class="table-row table-row--header prompt-row">
|
<div class="table-row table-row--header prompt-row">
|
||||||
<span class="col-action">Action</span>
|
<span class="col-action">Action</span>
|
||||||
|
<span class="col-label">Visningsnavn</span>
|
||||||
<span class="col-desc">Beskrivelse</span>
|
<span class="col-desc">Beskrivelse</span>
|
||||||
<span class="col-chars">Tegn</span>
|
<span class="col-chars">Tegn</span>
|
||||||
<span class="col-updated">Oppdatert</span>
|
<span class="col-updated">Oppdatert</span>
|
||||||
|
|
@ -1148,6 +1178,7 @@
|
||||||
{#each prompts as prompt (prompt.action)}
|
{#each prompts as prompt (prompt.action)}
|
||||||
<div class="table-row prompt-row">
|
<div class="table-row prompt-row">
|
||||||
<span class="col-action">{prompt.action}</span>
|
<span class="col-action">{prompt.action}</span>
|
||||||
|
<span class="col-label">{prompt.icon ?? ''} {prompt.label ?? '\u2014'}</span>
|
||||||
<span class="col-desc">{prompt.description ?? '\u2014'}</span>
|
<span class="col-desc">{prompt.description ?? '\u2014'}</span>
|
||||||
<span class="col-chars">{prompt.system_prompt.length}</span>
|
<span class="col-chars">{prompt.system_prompt.length}</span>
|
||||||
<span class="col-updated">{new Date(prompt.updated_at).toLocaleDateString('nb-NO')}</span>
|
<span class="col-updated">{new Date(prompt.updated_at).toLocaleDateString('nb-NO')}</span>
|
||||||
|
|
@ -1164,6 +1195,16 @@
|
||||||
|
|
||||||
{#if editingPrompt === prompt.action}
|
{#if editingPrompt === prompt.action}
|
||||||
<div class="prompt-editor">
|
<div class="prompt-editor">
|
||||||
|
<div class="prompt-editor-meta">
|
||||||
|
<label>
|
||||||
|
<span class="prompt-meta-label">Ikon</span>
|
||||||
|
<input type="text" bind:value={editPromptIcon} placeholder="🧹" class="prompt-meta-input prompt-meta-input--icon" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="prompt-meta-label">Visningsnavn</span>
|
||||||
|
<input type="text" bind:value={editPromptLabel} placeholder="Vask tekst" class="prompt-meta-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={editPromptText}
|
bind:value={editPromptText}
|
||||||
rows="12"
|
rows="12"
|
||||||
|
|
@ -1196,8 +1237,9 @@
|
||||||
<div class="table-row table-row--header usage-row">
|
<div class="table-row table-row--header usage-row">
|
||||||
<span>Alias</span>
|
<span>Alias</span>
|
||||||
<span>Modell</span>
|
<span>Modell</span>
|
||||||
|
<span>Prompt</span>
|
||||||
<span class="col-num">Kall</span>
|
<span class="col-num">Kall</span>
|
||||||
<span class="col-num">Prompt</span>
|
<span class="col-num">Prompt-tok.</span>
|
||||||
<span class="col-num">Kompl.</span>
|
<span class="col-num">Kompl.</span>
|
||||||
<span class="col-num">Totalt</span>
|
<span class="col-num">Totalt</span>
|
||||||
<span class="col-num">Est. $</span>
|
<span class="col-num">Est. $</span>
|
||||||
|
|
@ -1208,6 +1250,7 @@
|
||||||
<div class="table-row usage-row">
|
<div class="table-row usage-row">
|
||||||
<span class="col-alias">{row.model_alias}</span>
|
<span class="col-alias">{row.model_alias}</span>
|
||||||
<span class="col-model-actual">{row.model_actual ?? '\u2014'}</span>
|
<span class="col-model-actual">{row.model_actual ?? '\u2014'}</span>
|
||||||
|
<span class="col-action-usage">{row.action ?? '\u2014'}</span>
|
||||||
<span class="col-num">{row.call_count}</span>
|
<span class="col-num">{row.call_count}</span>
|
||||||
<span class="col-num">{row.prompt_tokens.toLocaleString('nb-NO')}</span>
|
<span class="col-num">{row.prompt_tokens.toLocaleString('nb-NO')}</span>
|
||||||
<span class="col-num">{row.completion_tokens.toLocaleString('nb-NO')}</span>
|
<span class="col-num">{row.completion_tokens.toLocaleString('nb-NO')}</span>
|
||||||
|
|
@ -2040,7 +2083,15 @@
|
||||||
|
|
||||||
/* Usage/tokenforbruk */
|
/* Usage/tokenforbruk */
|
||||||
.usage-row {
|
.usage-row {
|
||||||
grid-template-columns: 1fr 2fr 70px 80px 80px 80px 70px;
|
grid-template-columns: 1fr 2fr 1fr 60px 80px 80px 80px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-action-usage {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-model-actual {
|
.col-model-actual {
|
||||||
|
|
@ -2060,7 +2111,11 @@
|
||||||
|
|
||||||
/* Prompt-seksjon */
|
/* Prompt-seksjon */
|
||||||
.prompt-row {
|
.prompt-row {
|
||||||
grid-template-columns: 1.5fr 2.5fr 60px 90px 70px;
|
grid-template-columns: 1.2fr 1.2fr 2fr 60px 80px 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-action {
|
.col-action {
|
||||||
|
|
@ -2089,6 +2144,43 @@
|
||||||
border-bottom: 1px solid #2d3148;
|
border-bottom: 1px solid #2d3148;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-editor-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-editor-meta label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-meta-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-meta-input {
|
||||||
|
background: #161822;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e1e4e8;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-meta-input--icon {
|
||||||
|
width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-meta-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-editor textarea {
|
.prompt-editor textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #161822;
|
background: #161822;
|
||||||
|
|
|
||||||
|
|
@ -163,28 +163,44 @@ impl JobHandler for AiTextProcessHandler {
|
||||||
.await
|
.await
|
||||||
.context("Feil ved lagring av revisjon")?;
|
.context("Feil ved lagring av revisjon")?;
|
||||||
|
|
||||||
// 4. Bygg system-prompt basert på action
|
// 4. Hent prompt-label fra DB (for metadata-stempel i chat)
|
||||||
|
let prompt_label: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT label FROM ai_prompts WHERE action = $1"
|
||||||
|
)
|
||||||
|
.bind(action)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
// 5. Bygg system-prompt basert på action
|
||||||
let system_prompt = match prompt_override {
|
let system_prompt = match prompt_override {
|
||||||
Some(custom) => custom.to_string(),
|
Some(custom) => custom.to_string(),
|
||||||
None => get_system_prompt_from_db(pool, action).await,
|
None => get_system_prompt_from_db(pool, action).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. Send til AI Gateway
|
// 6. Send til AI Gateway
|
||||||
let ai_resp = self
|
let ai_resp = self
|
||||||
.call_ai_gateway(&system_prompt, &plain_text, &model)
|
.call_ai_gateway(&system_prompt, &plain_text, &model)
|
||||||
.await
|
.await
|
||||||
.context("AI Gateway-kall feilet")?;
|
.context("AI Gateway-kall feilet")?;
|
||||||
|
|
||||||
// 6. Oppdater SpacetimeDB — dette er primær-kanalen til frontend.
|
// 7. Beregn faktisk modellnavn
|
||||||
// sync.rs synker body-endringen til PG automatisk.
|
// LiteLLM returnerer alias-navnet i model-feltet — bruk expected_model fra DB
|
||||||
self.update_spacetimedb(&message_id, workspace_id, &ai_resp.content)
|
let actual_model = match &ai_resp.model_actual {
|
||||||
.await
|
Some(m) if m != &model => Some(m.clone()), // Gateway returnerte faktisk modellnavn
|
||||||
.context("Kunne ikke oppdatere SpacetimeDB med AI-resultat")?;
|
_ => expected_model, // Bruk oppslaget fra providers-tabellen
|
||||||
|
};
|
||||||
|
// Strip openrouter/-prefiks for lesbarhet
|
||||||
|
let actual_model_clean = actual_model.map(|m| m.replace("openrouter/", "").replace("gemini/", "google/"));
|
||||||
|
|
||||||
// 7. Skriv PG-only data (metadata, revisjon er allerede lagret, tokenforbruk)
|
// 8. Skriv PG metadata FØR SpacetimeDB-oppdatering
|
||||||
|
// (frontend henter metadata fra PG når SpacetimeDB-update trigger onUpdate)
|
||||||
let metadata = json!({
|
let metadata = json!({
|
||||||
"ai_processed": true,
|
"ai_processed": true,
|
||||||
"ai_action": action
|
"ai_action": action,
|
||||||
|
"ai_label": prompt_label.as_deref().unwrap_or(action),
|
||||||
|
"ai_model": actual_model_clean.as_deref().unwrap_or(&model)
|
||||||
});
|
});
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|
@ -194,31 +210,30 @@ impl JobHandler for AiTextProcessHandler {
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(metadata)
|
.bind(&metadata)
|
||||||
.bind(message_id)
|
.bind(message_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.context("Feil ved oppdatering av metadata")?;
|
.context("Feil ved oppdatering av metadata")?;
|
||||||
|
|
||||||
// 8. Logg tokenforbruk til ai_usage_log
|
// 9. Oppdater SpacetimeDB — dette er primær-kanalen til frontend.
|
||||||
// LiteLLM returnerer alias-navnet i model-feltet — bruk expected_model fra DB
|
// Gjøres ETTER PG-metadata slik at enrichMessageFromPg finner fersk data.
|
||||||
let actual_model = match &ai_resp.model_actual {
|
self.update_spacetimedb(&message_id, workspace_id, &ai_resp.content)
|
||||||
Some(m) if m != &model => Some(m.clone()), // Gateway returnerte faktisk modellnavn
|
.await
|
||||||
_ => expected_model, // Bruk oppslaget fra providers-tabellen
|
.context("Kunne ikke oppdatere SpacetimeDB med AI-resultat")?;
|
||||||
};
|
|
||||||
// Strip openrouter/-prefiks for lesbarhet
|
|
||||||
let actual_model_clean = actual_model.map(|m| m.replace("openrouter/", "").replace("gemini/", "google/"));
|
|
||||||
|
|
||||||
|
// 10. Logg tokenforbruk til ai_usage_log
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO ai_usage_log (workspace_id, job_id, job_type, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens)
|
INSERT INTO ai_usage_log (workspace_id, job_id, job_type, model_alias, model_actual, action, prompt_tokens, completion_tokens, total_tokens)
|
||||||
VALUES ($1, $2, 'ai_text_process', $3, $4, $5, $6, $7)
|
VALUES ($1, $2, 'ai_text_process', $3, $4, $5, $6, $7, $8)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.bind(job_id)
|
.bind(job_id)
|
||||||
.bind(&model)
|
.bind(&model)
|
||||||
.bind(&actual_model_clean)
|
.bind(&actual_model_clean)
|
||||||
|
.bind(action)
|
||||||
.bind(ai_resp.prompt_tokens)
|
.bind(ai_resp.prompt_tokens)
|
||||||
.bind(ai_resp.completion_tokens)
|
.bind(ai_resp.completion_tokens)
|
||||||
.bind(ai_resp.total_tokens)
|
.bind(ai_resp.total_tokens)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue