From 817062e5fca1bb5d3bef30aa9cbe4ae64704ef0a Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 1 Mar 2026 12:40:15 +0000 Subject: [PATCH] Changeset 0.20.0 (#85) --- .gitea/workflows/ci.yaml | 4 + CHANGELOG.md | 11 + README.md | 4 + VERSION | 2 +- docs/ARCHITECTURE.md | 55 +- docs/DESIGN-0.20.0.md | 713 ++++++++++++++++++ docs/ROADMAP.md | 65 +- k8s/backend.yaml | 2 + scripts/db-validate.sh | 5 + .../migrations/007_v0200_notifications.sql | 17 + .../008_v0200_notification_prefs.sql | 18 + .../sqlite/006_v0200_notifications.sql | 17 + .../sqlite/007_v0200_notification_prefs.sql | 13 + server/database/testhelper.go | 1 + server/events/types.go | 13 + server/handlers/admin_email.go | 78 ++ server/handlers/channel_models.go | 237 ++++++ server/handlers/completion.go | 130 ++++ server/handlers/groups.go | 21 +- server/handlers/notifications.go | 281 +++++++ server/handlers/stream_loop.go | 175 +++++ server/knowledge/ingest.go | 10 + server/main.go | 54 ++ server/mentions/parser.go | 155 ++++ server/mentions/parser_test.go | 213 ++++++ server/models/models_notification.go | 40 + server/models/models_notification_pref.go | 20 + server/notifications/email.go | 198 +++++ server/notifications/service.go | 203 +++++ server/notifications/service_test.go | 137 ++++ server/notifications/sources.go | 146 ++++ server/notifications/templates.go | 108 +++ server/notifications/templates_test.go | 69 ++ server/store/interfaces.go | 34 + server/store/postgres/channel.go | 47 ++ server/store/postgres/notification.go | 133 ++++ server/store/postgres/notification_prefs.go | 69 ++ server/store/postgres/stores.go | 36 +- server/store/postgres/user_settings.go | 6 +- server/store/sqlite/channel.go | 43 ++ server/store/sqlite/notification.go | 146 ++++ server/store/sqlite/notification_prefs.go | 70 ++ server/store/sqlite/stores.go | 2 + server/store/sqlite/user_settings.go | 6 +- src/css/channel-models.css | 210 ++++++ src/css/notification-prefs.css | 64 ++ src/css/notifications.css | 249 ++++++ src/index.html | 47 ++ src/js/api.js | 13 + src/js/app.js | 1 + src/js/channel-models.js | 340 +++++++++ src/js/chat.js | 48 +- src/js/notification-prefs.js | 163 ++++ src/js/notifications.js | 431 +++++++++++ src/js/settings-handlers.js | 6 + src/js/ui-admin.js | 7 + src/js/ui-core.js | 121 ++- 57 files changed, 5435 insertions(+), 72 deletions(-) create mode 100644 docs/DESIGN-0.20.0.md create mode 100644 server/database/migrations/007_v0200_notifications.sql create mode 100644 server/database/migrations/008_v0200_notification_prefs.sql create mode 100644 server/database/migrations/sqlite/006_v0200_notifications.sql create mode 100644 server/database/migrations/sqlite/007_v0200_notification_prefs.sql create mode 100644 server/handlers/admin_email.go create mode 100644 server/handlers/channel_models.go create mode 100644 server/handlers/notifications.go create mode 100644 server/mentions/parser.go create mode 100644 server/mentions/parser_test.go create mode 100644 server/models/models_notification.go create mode 100644 server/models/models_notification_pref.go create mode 100644 server/notifications/email.go create mode 100644 server/notifications/service.go create mode 100644 server/notifications/service_test.go create mode 100644 server/notifications/sources.go create mode 100644 server/notifications/templates.go create mode 100644 server/notifications/templates_test.go create mode 100644 server/store/postgres/notification.go create mode 100644 server/store/postgres/notification_prefs.go create mode 100644 server/store/sqlite/notification.go create mode 100644 server/store/sqlite/notification_prefs.go create mode 100644 src/css/channel-models.css create mode 100644 src/css/notification-prefs.css create mode 100644 src/css/notifications.css create mode 100644 src/js/channel-models.js create mode 100644 src/js/notification-prefs.js create mode 100644 src/js/notifications.js diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 25e1e44..96a4353 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -403,6 +403,7 @@ jobs: if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then echo "ENVIRONMENT=dev" >> "$GITHUB_OUTPUT" + echo "GIN_MODE=debug" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT" echo "BASE_PATH=/dev" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT" @@ -422,6 +423,7 @@ jobs: elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then VERSION="${{ gitea.ref_name }}" echo "ENVIRONMENT=production" >> "$GITHUB_OUTPUT" + echo "GIN_MODE=release" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=latest" >> "$GITHUB_OUTPUT" echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT" echo "BASE_PATH=" >> "$GITHUB_OUTPUT" @@ -442,6 +444,7 @@ jobs: echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT" else echo "ENVIRONMENT=test" >> "$GITHUB_OUTPUT" + echo "GIN_MODE=debug" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT" echo "BASE_PATH=/test" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT" @@ -677,6 +680,7 @@ jobs: - name: Render and apply manifests env: ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} + GIN_MODE: ${{ steps.env.outputs.GIN_MODE }} IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }} DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }} DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }} diff --git a/CHANGELOG.md b/CHANGELOG.md index dd71cad..3a1b94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to Chat Switchboard. +## [0.20.0] — 2026-03-01 + +### Added +- **Notifications Core (Phase 1)**: Persistent, user-targeted notification infrastructure with real-time WebSocket delivery. `notifications` table (Postgres + SQLite), `NotificationStore` with paginated queries, `Service.Notify()` / `NotifyMany()` for centralized creation and dispatch. Five API endpoints: list (paginated, filterable), unread count, mark read, mark all read, delete. Bell icon in header bar with unread count badge (capped at 9+). Notification dropdown (latest 10, grouped, click-to-navigate via `resource_type`/`resource_id`). Full notification panel registered with `PanelRegistry`. WebSocket push via `notification.new` event with toast for high-priority types (`kb.error`, `role.fallback`). Background cleanup goroutine (configurable retention, default 90 days). Initial sources: `role.fallback` (via EventBus subscription), `kb.ready`/`kb.error` (knowledge base processing), `grant.changed` (group membership). +- **@mention Parsing + Multi-model Routing (Phase 2)**: Channels support multiple AI models with @mention-based routing. `mentions.Parse()` extracts @mentions from message content, resolves against channel model roster (case-insensitive, longest-match-first, trailing punctuation tolerant). Completion handler fans out sequentially to mentioned model(s), producing one assistant response per target. Without @mention, default channel model responds (fully backward compatible). Channel model CRUD: 4 new endpoints for add/remove/update/list. Frontend: model pills in chat header, @mention autocomplete (CM6 `mentionCompletion` extension with roster-backed suggestions), model attribution labels on multi-model responses. Message `model_display` field for human-readable attribution. SSE streaming tagged with model info per response. +- **Email Transport + Notification Preferences (Phase 3)**: SMTP email delivery via `EmailTransport` supporting implicit TLS (port 465) and STARTTLS (port 587). Multipart MIME messages (HTML + plaintext) with branded templates using Go `html/template`. `notification_preferences` table with three-tier resolution: specific type → user wildcard `*` → system default (in_app=true, email=false). Three preference API endpoints (list, set with partial patch, delete). Admin SMTP configuration in settings (host, port, user, password, from address, TLS mode) with test email endpoint. User notification preferences UI in Settings → Notifications tab with per-type in-app/email checkboxes. Async email delivery (goroutine with 30s timeout, failures logged non-blocking). +- **Gin Release Mode**: Backend now automatically sets `gin.SetMode(gin.ReleaseMode)` when `ENVIRONMENT=production`, suppressing per-request access logs for health checks and reducing log noise. `GIN_MODE` env var also added to K8s backend deployment as explicit override. + +### Fixed +- **Model preferences 500 on every page load**: `GET /api/v1/models/preferences` returned HTTP 500 due to `NULL` values in `user_model_settings.hidden` and `sort_order` columns failing Go `sql.Scan()` into non-pointer types. Root cause: the `Set` upsert passed `NULL` for unset patch fields, bypassing `DEFAULT false`/`DEFAULT 0` on INSERT. Fixed with `COALESCE` in both SELECT (read path) and INSERT VALUES (write path) for Postgres and SQLite. Includes data-fix SQL for existing NULL rows. + ## [0.19.2] — 2026-02-28 ### Added diff --git a/README.md b/README.md index ec02433..12dd4cc 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ docker compose up -d - **Airgapped**: Local vendor files (marked.js, DOMPurify, KaTeX, mermaid.js), no CDN required - **Mobile**: Responsive design, PWA manifest - **Real-time**: WebSocket event bus for tool bridge, live updates +- **Notifications**: In-app notification bell with unread badge, per-type email delivery (SMTP), user preference controls, admin SMTP configuration +- **Multi-model**: @mention routing for multiple AI models per channel, fan-out completions, model attribution ## Architecture @@ -77,6 +79,8 @@ All configuration via environment variables. See `server/.env.example` for the f | `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password | | `ENCRYPTION_KEY` | ` ` | AES key for API key encryption (required if encrypted keys exist) | | `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` | +| `ENVIRONMENT` | `development` | Environment name: `development`, `test`, or `production`. Auto-sets Gin release mode in production. | +| `GIN_MODE` | (auto) | Gin framework mode: `debug`, `test`, or `release`. Auto-derived from `ENVIRONMENT` if not set. | | `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. Auto-detects PVC if not set. | | `STORAGE_PATH` | `/data/storage` | PVC mount point (also scratch dir for S3 extraction) | | `S3_ENDPOINT` | ` ` | S3 endpoint (e.g. `http://minio:9000`, required for S3) | diff --git a/VERSION b/VERSION index 7f78682..5a03fb7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.2 \ No newline at end of file +0.20.0 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3cea7a8..a008d13 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Architecture — Chat Switchboard v0.17 +# Architecture — Chat Switchboard v0.20 ## Deployment Modes @@ -126,7 +126,7 @@ server/ │ └── 002_v017_persona_kb.sql ├── store/ │ ├── interfaces.go # Store interfaces + shared types -│ ├── postgres/ # Postgres implementations (20 stores) +│ ├── postgres/ # Postgres implementations (22 stores) │ │ ├── stores.go # NewStores() constructor │ │ ├── provider.go # ProviderStore │ │ ├── catalog.go # CatalogStore @@ -140,7 +140,7 @@ server/ │ │ ├── memory.go # MemoryStore (CRUD + recall) │ │ ├── memory_hybrid.go # RecallHybrid (pgvector cosine) │ │ └── ... -│ └── sqlite/ # SQLite implementations (20 stores) +│ └── sqlite/ # SQLite implementations (22 stores) │ ├── stores.go # NewStores() constructor │ └── ... # Mirror of postgres/ with dialect adaptations ├── models/models.go # Shared domain types @@ -154,6 +154,12 @@ server/ ├── memory/ # Long-term memory extraction + scanning │ ├── extractor.go # Conversation analysis → fact extraction │ └── scanner.go # Background job: find channels needing extraction +├── mentions/ # @mention parsing for multi-model routing +│ └── parser.go # Parse() extracts + resolves @mentions against roster +├── notifications/ # Notification service + email transport +│ ├── service.go # Notify/NotifyMany, preference resolution, dispatch +│ ├── email.go # SMTP transport (TLS/STARTTLS, multipart MIME) +│ └── templates.go # HTML + plaintext email templates ├── handlers/ # HTTP handlers (Gin) │ ├── auth.go # Login, register, refresh, logout │ ├── admin.go # User/config/model management @@ -169,6 +175,10 @@ server/ │ ├── knowledge.go # Knowledge base management │ ├── memory.go # Memory CRUD + review (user + admin) │ ├── memory_inject.go # BuildMemoryHint() for completion injection +│ ├── notifications.go # Notification CRUD + preference endpoints +│ ├── admin_email.go # Admin SMTP test email endpoint +│ ├── channel_models.go # Channel model roster CRUD (multi-model) +│ ├── model_prefs.go # User model visibility preferences │ └── ... ├── notelinks/ # Wikilink extraction (regex → NoteLink structs) ├── knowledge/ # KB chunking, embedding, search @@ -273,6 +283,45 @@ Migrations are handled by the backend on startup (no separate migration job): Postgres uses consolidated schemas (`001_v016_schema.sql` + incremental). SQLite uses application-generated UUIDs and `datetime()` instead of `gen_random_uuid()` and `now()`. Both drivers share the same migration tracking table. +## Notifications (v0.20.0) + +Centralized notification infrastructure with real-time WebSocket delivery +and optional email transport. + +**Service Pattern:** All notification sources go through `notifications.Service`, +never insert directly. `Notify()` persists to store, checks user preferences, +pushes via WebSocket (if online), and dispatches email (if configured and +opted-in). `NotifyMany()` fans out to multiple users (best-effort). + +**Preference Resolution:** +``` +Specific type pref → User wildcard '*' pref → System default (in_app=true, email=false) +``` + +**Email Transport:** Optional SMTP via `EmailTransport`. Supports implicit TLS +(port 465) and STARTTLS (port 587). Multipart MIME (HTML + plaintext). +Templates rendered via Go `html/template` with instance branding. Async +delivery via goroutine (failures logged, never block notification creation). +Admin configures SMTP in platform settings; users toggle per-type delivery +in Settings → Notifications. + +**Cleanup:** Background goroutine deletes notifications older than retention +period (default 90 days, configurable via admin settings). + +## @mention Routing + Multi-model (v0.20.0) + +Channels support multiple AI models. The `channel_models` table (schema 001) +holds the roster; `mentions.Parse()` extracts @mentions from message content +and resolves against display names (case-insensitive, longest-match-first). + +**Completion Fan-out:** When mentions resolve to channel models, the completion +handler fires sequentially for each target, producing one assistant message +per model. Without @mention, the default channel model responds (backward +compatible). Frontend renders model attribution labels on multi-model responses. + +**Autocomplete:** CM6 `mentionCompletion` extension triggers on `@` in the +chat input, showing a dropdown of channel model display names. + ## Frontend Architecture Vanilla JavaScript, no framework. The frontend ships as static files served by nginx. The only build step is the CM6 editor bundle, compiled at Docker build time via esbuild (IIFE output, no runtime bundler). diff --git a/docs/DESIGN-0.20.0.md b/docs/DESIGN-0.20.0.md new file mode 100644 index 0000000..bed4dd1 --- /dev/null +++ b/docs/DESIGN-0.20.0.md @@ -0,0 +1,713 @@ +# DESIGN-0.20.0 — Notifications + @mention Routing + Multi-model + +**Status:** Complete (Phases 1–3 delivered 2026-03-01) +**Depends on:** v0.19.2 (Projects complete), EventBus + WebSocket hub (v0.9.x), `channel_models` table (v0.16.0 schema) +**Prerequisite for:** v0.23.0 (Multi-Participant Channels — notifications become the backbone for participant events) + +--- + +## Overview + +v0.20.0 delivers two complementary features: + +1. **Notifications** — Persistent, user-targeted notification infrastructure with real-time WebSocket push and optional email transport. This is the event backbone for all future collaboration features (multi-participant channels, workflow assignment queues, presence). + +2. **@mention Routing + Multi-model** — Channels can host multiple AI models. Users @mention a model (or user, for future multi-participant) to direct a message. The completion handler fans out to the addressed model(s). This builds on the existing `channel_models` table and `ChannelModel` store methods that have been in the schema since v0.16.0 but were never surfaced in UI. + +--- + +## Phasing + +| Phase | Scope | Migration | Est. Effort | +|-------|-------|-----------|-------------| +| **1** | Notifications core (in-app) | `007_v0200_notifications.sql` | Medium-large | +| **2** | @mention parsing + multi-model routing | None (uses existing `channel_models`) | Medium | +| **3** | Email transport + user notification preferences | `008_v0200_notification_prefs.sql` | Medium | + +Each phase is a merge-ready deliverable. Phase 1 is the critical path — 2 and 3 can be reordered or deferred if needed. + +--- + +## Phase 1 — Notifications Core (in-app) + +### Data Model + +```sql +-- Postgres: 007_v0200_notifications.sql +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, -- e.g. 'role.fallback', 'kb.processing', 'grant.changed', 'project.invite' + title VARCHAR(255) NOT NULL, + body TEXT DEFAULT '', + resource_type VARCHAR(50), -- 'channel', 'knowledge_base', 'project', 'team', etc. + resource_id UUID, -- nullable — links notification to a navigable entity + is_read BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC); +CREATE INDEX idx_notifications_user_created ON notifications(user_id, created_at DESC); +``` + +```sql +-- SQLite: 006_v0200_notifications.sql +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT DEFAULT '', + resource_type TEXT, + resource_id TEXT, + is_read INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC); +``` + +**Design notes:** +- No `updated_at` — notifications are immutable once created (only `is_read` flips). +- `type` is a free-form string, not an enum. New notification sources don't require migration. Convention: `domain.action` (e.g. `role.fallback`, `kb.ready`, `grant.added`, `project.invite`). +- `resource_type` + `resource_id` enable click-to-navigate. Frontend maps these to routes (e.g. `resource_type=channel` → `selectChat(resource_id)`). +- No FK on `resource_id` — the referenced entity may be deleted; notification persists as historical record. + +### Store Interface + +```go +// server/store/interfaces.go — addition to Stores +type NotificationStore interface { + Create(ctx context.Context, n *models.Notification) error + ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) // returns items + total count + MarkRead(ctx context.Context, id, userID string) error + MarkAllRead(ctx context.Context, userID string) error + Delete(ctx context.Context, id, userID string) error + UnreadCount(ctx context.Context, userID string) (int, error) +} +``` + +Both Postgres and SQLite implementations follow the established pattern. `ListByUser` returns paginated results with a total count for the unread badge. The `userID` parameter on `MarkRead`/`Delete` prevents cross-user access. + +### Model + +```go +// server/models/models_notification.go +type Notification struct { + ID string `json:"id" db:"id"` + UserID string `json:"user_id" db:"user_id"` + Type string `json:"type" db:"type"` + Title string `json:"title" db:"title"` + Body string `json:"body" db:"body"` + ResourceType string `json:"resource_type,omitempty" db:"resource_type"` + ResourceID string `json:"resource_id,omitempty" db:"resource_id"` + IsRead bool `json:"is_read" db:"is_read"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} +``` + +### API Endpoints + +``` +GET /api/v1/notifications — list (paginated, ?unread_only=true, ?limit=20&offset=0) +GET /api/v1/notifications/unread-count — { "count": 5 } +PATCH /api/v1/notifications/:id/read — mark single read +POST /api/v1/notifications/mark-all-read — mark all read for user +DELETE /api/v1/notifications/:id — delete single +``` + +All endpoints are user-scoped (auth middleware injects `user_id`). No admin override — notifications are personal. + +### Notification Service + +Centralized creation + dispatch. All notification sources go through this service, never insert directly. + +```go +// server/notifications/service.go +type Service struct { + store store.NotificationStore + hub *events.Hub +} + +func (s *Service) Notify(ctx context.Context, n *models.Notification) error { + // 1. Persist + if err := s.store.Create(ctx, n); err != nil { + return err + } + + // 2. Real-time push via WebSocket (if user is online) + s.hub.SendToUser(n.UserID, events.Event{ + Label: "notification.new", + Payload: events.MustJSON(n), + Ts: time.Now().UnixMilli(), + }) + + return nil +} + +// NotifyMany fans out to multiple users (e.g. team-wide grant change). +func (s *Service) NotifyMany(ctx context.Context, userIDs []string, template models.Notification) error { + for _, uid := range userIDs { + n := template + n.ID = store.NewID() + n.UserID = uid + _ = s.Notify(ctx, &n) // best-effort; log errors, don't fail batch + } + return nil +} +``` + +### EventBus Route Table Addition + +```go +// server/events/types.go — add to routeTable +"notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based +"notification.read": DirToClient, // badge sync across tabs +``` + +### Initial Notification Sources + +Wire these into existing code paths. Each source creates a `Notification` via `Service.Notify()`: + +| Source | Type | Trigger Point | Title Template | +|--------|------|---------------|----------------| +| Role fallback | `role.fallback` | `capabilities/resolver.go` (already emits EventBus) | "Model fallback: {primary} → {fallback}" | +| KB processing complete | `kb.ready` | `knowledge_bases.go` → after indexing pipeline | "Knowledge base '{name}' ready ({n} chunks)" | +| KB processing error | `kb.error` | `knowledge_bases.go` → on embedding/chunking failure | "Knowledge base '{name}' indexing failed" | +| Group membership changed | `grant.changed` | `groups.go` → AddMember / RemoveMember | "You were added to group '{name}'" | +| Project invite | `project.invite` | Future (v0.23.0+) — stub the type now | — | + +**Implementation order:** `role.fallback` first (already emits an EventBus event — just subscribe in notification service), then `kb.ready`/`kb.error` (high user value), then `grant.changed`. + +### Frontend — Notification UI + +**Bell icon** (header bar, right of model selector): + +``` +┌─────────────────────────────────────────────────┐ +│ ☰ Chat Switchboard [model ▾] 🔔③ 👤 Jeff │ +└─────────────────────────────────────────────────┘ +``` + +- `🔔` with `.notification-badge` (red circle, count) when `unreadCount > 0`. +- Badge shows count capped at `9+`. +- On click: toggle notification dropdown. + +**Notification dropdown:** + +``` +┌──────────────────────────────────────┐ +│ Notifications Mark all ✓ │ +├──────────────────────────────────────┤ +│ ● KB "Sales Docs" ready (3 chunks) │ +│ 2 minutes ago │ +├──────────────────────────────────────┤ +│ ○ Model fallback: gpt-4 → gpt-3.5 │ +│ 1 hour ago │ +├──────────────────────────────────────┤ +│ ○ Added to group "Engineering" │ +│ Yesterday │ +├──────────────────────────────────────┤ +│ View all → │ +└──────────────────────────────────────┘ +``` + +- `●` = unread, `○` = read. Unread items have subtle background highlight. +- Click item: mark read + navigate to `resource_type`/`resource_id` (open chat, open KB in admin, etc.). +- "Mark all ✓" → `POST /notifications/mark-all-read` → clear badge. +- "View all →" opens full notification list in side panel (registered with `PanelRegistry`, same pattern as Notes/Preview/Project). +- Dropdown max-height: 400px, scrollable. Shows latest 10 items. +- Click-outside / Escape to close. + +**File:** `src/js/notifications.js` (new module, ~300-400 lines) + +**WebSocket handler:** + +```javascript +Events.on('notification.new', (payload) => { + App.notifications.unshift(payload); + App.unreadNotificationCount++; + renderNotificationBadge(); + + // Optional: toast for high-priority notification types + if (['kb.error', 'role.fallback'].includes(payload.type)) { + UI.toast(payload.title, 'warning'); + } +}); +``` + +**Startup:** `GET /notifications/unread-count` on app init → set badge. Lazy-load full list on first dropdown open. + +### Files Changed (Phase 1) + +**New files:** +- `server/notifications/service.go` — notification service +- `server/handlers/notifications.go` — API handlers +- `server/store/postgres/notification.go` — Postgres store +- `server/store/sqlite/notification.go` — SQLite store +- `server/models/models_notification.go` — model struct +- `server/database/migrations/007_v0200_notifications.sql` — Postgres migration +- `server/database/migrations/sqlite/006_v0200_notifications.sql` — SQLite migration +- `src/js/notifications.js` — frontend notification UI + +**Modified files:** +- `server/store/interfaces.go` — add `NotificationStore` to `Stores` +- `server/events/types.go` — add `notification.*` routes +- `server/main.go` (or `server/routes.go`) — wire notification handler + service +- `server/handlers/knowledge_bases.go` — emit `kb.ready` / `kb.error` notifications +- `server/handlers/groups.go` — emit `grant.changed` notifications +- `server/capabilities/resolver.go` — emit `role.fallback` notification (subscribe to existing EventBus event) +- `src/js/app.js` — init notification module, add bell to header +- `src/js/events.js` — add `notification.*` to known labels (documentation only, wildcard already works) +- `src/css/style.css` — notification badge, dropdown, panel styles +- `index.html` — notification bell element in header +- Integration tests — notification CRUD + WebSocket delivery + +### Checklist (Phase 1) + +- [x] Migration: `notifications` table (Postgres + SQLite) +- [x] Model: `Notification` struct +- [x] Store: `NotificationStore` interface + both implementations +- [x] Service: `notifications.Service` with `Notify()` / `NotifyMany()` +- [x] Handlers: 5 notification endpoints +- [x] EventBus: `notification.new` + `notification.read` in route table +- [x] Wire: notification service into knowledge_bases, groups, capabilities/resolver +- [x] Frontend: `notifications.js` module +- [x] Frontend: bell icon + unread badge in header +- [x] Frontend: notification dropdown (list, mark read, navigate) +- [x] Frontend: notification panel (full list, registered with PanelRegistry) +- [x] Frontend: WebSocket handler for real-time push +- [x] Frontend: toast for high-priority notification types +- [x] Tests: notification store CRUD (both dialects) +- [x] Tests: notification handler integration tests +- [x] Tests: WebSocket delivery (via existing hub test pattern) + +--- + +## Phase 2 — @mention Parsing + Multi-model Routing + +### Concept + +Today, each channel has one active model (set via model selector or persona). The `channel_models` table (schema 001) already supports multiple models per channel — it's just never been populated with more than one, and the completion handler ignores it. + +Phase 2 activates this table: + +1. Users can **add models** to a channel (up to N, configurable, default 5). +2. Messages can contain **@mentions** that route to specific models. +3. The completion handler **fans out** to mentioned model(s), producing one assistant response per model. +4. Without @mention, the **default** channel model responds (backward compatible). + +### @mention Syntax + +``` +@claude-3-opus What do you think about this approach? +@gpt-4 Can you review the code above? +Hey @claude-3-opus and @gpt-4, compare your approaches. +``` + +Mentions resolve against the `display_name` of `channel_models` entries for the current channel. Resolution is case-insensitive, whitespace-normalized, with longest-match-first to handle model names that are substrings of others. + +**Why display_name, not model_id?** Model IDs are composite strings like `anthropic/claude-3-opus-20240229` — terrible UX. `display_name` is user-settable when adding a model to the channel, defaulting to a short friendly name derived from the model catalog (e.g. "Claude 3 Opus", "GPT-4"). + +### Mention Parser + +```go +// server/mentions/parser.go + +type Mention struct { + Raw string // "@claude-3-opus" as written + Name string // "claude-3-opus" (normalized, no @) + Start int // byte offset in message content + End int // byte offset end + Resolved *models.ChannelModel // nil if unresolved +} + +// Parse extracts @mentions from message content and resolves them +// against the channel's model roster. +func Parse(content string, roster []models.ChannelModel) []Mention +``` + +**Parser rules:** +- `@` followed by one or more non-whitespace characters. +- Greedy: `@claude-3-opus-20240229` matches the full string, not just `@claude`. +- Resolution: case-insensitive match against `display_name` with hyphens/spaces normalized. +- Unresolved mentions are left as-is in the message (no error, no routing). +- Mentions at any position in the message (start, middle, end). + +**Frontend-side autocomplete:** When user types `@` in the chat input, show a dropdown of channel models (fetched from `GET /channels/:id/models`). Selecting inserts the `@display_name` token. This reuses the `[[wikilink` autocomplete pattern from CM6 (v0.17.3) adapted for the plain textarea/CM6 chat input. + +### Completion Handler Changes + +Current flow (simplified): +``` +user message → resolve model → one completion → one assistant message +``` + +New flow: +``` +user message → parse @mentions → resolve target model(s) → fan out completions → N assistant messages +``` + +```go +// In completion.go, after persisting user message: + +// 1. Load channel model roster +roster, _ := h.stores.GetModels(ctx, channel.ID) + +// 2. Parse mentions +mentions := mentions.Parse(req.Content, roster) + +// 3. Determine target models +var targets []models.ChannelModel +if len(mentions) > 0 { + // Deduplicate resolved mentions + seen := map[string]bool{} + for _, m := range mentions { + if m.Resolved != nil && !seen[m.Resolved.ID] { + targets = append(targets, *m.Resolved) + seen[m.Resolved.ID] = true + } + } +} +if len(targets) == 0 { + // No mentions or none resolved → use default channel model (current behavior) + targets = []models.ChannelModel{defaultModelFromRequest(req, channel, roster)} +} + +// 4. Fan out: one completion per target model +for _, target := range targets { + // Each gets its own assistant message with model attribution + go h.completeForModel(ctx, c, req, channel, messages, target) +} +``` + +**Sequential vs parallel:** Start with **sequential** fan-out (simpler error handling, predictable message ordering). Parallel is a future optimization — the bottleneck is provider latency, not local compute. + +**Streaming:** Each model's response streams independently. Frontend receives SSE events tagged with model info: + +```json +{"event": "delta", "model": "claude-3-opus", "model_display": "Claude 3 Opus", "content": "..."} +``` + +Frontend renders each model's response in a separate message bubble with a model attribution label. + +### Multi-model Channel UI + +**"Add model" button** in the model selector area (chat header): + +``` +┌──────────────────────────────────────────────────────┐ +│ Channel: Project Discussion │ +│ Models: [Claude 3 Opus ✕] [GPT-4 ✕] [+ Add model] │ +└──────────────────────────────────────────────────────┘ +``` + +- Model pills show `display_name`, click `✕` to remove. +- `[+ Add model]` opens a dropdown of available models (filtered by user's accessible provider configs), with a "Display name" input field. +- Default model indicated with a subtle star/highlight (the one that responds without @mention). +- Click a model pill to set it as default. + +**API endpoints:** + +``` +GET /api/v1/channels/:id/models — list channel models +POST /api/v1/channels/:id/models — add model to channel +DELETE /api/v1/channels/:id/models/:modelId — remove model +PATCH /api/v1/channels/:id/models/:modelId — update display_name, set default, system_prompt +``` + +These are mostly wrappers around the existing `SetModel`/`GetModels` store methods, with additional CRUD. + +### Message Attribution + +Assistant messages from multi-model channels get a visual attribution: + +``` +┌─────────────────────────────────────────┐ +│ Claude 3 Opus │ +│ I think the recursive approach is... │ +├─────────────────────────────────────────┤ +│ GPT-4 │ +│ I'd suggest an iterative solution... │ +└─────────────────────────────────────────┘ +``` + +The `model` field on `messages` already stores the model ID. Frontend renders a label above multi-model assistant messages using the `display_name` from the channel model roster. + +### Files Changed (Phase 2) + +**New files:** +- `server/mentions/parser.go` — @mention parser +- `server/mentions/parser_test.go` — parser unit tests +- `server/handlers/channel_models.go` — channel model CRUD endpoints + +**Modified files:** +- `server/handlers/completion.go` — fan-out logic, mention parsing integration +- `server/handlers/channels.go` — wire channel model routes +- `server/store/interfaces.go` — extend `ChannelStore` with `DeleteModel`, `UpdateModel` (if not already present) +- `server/store/postgres/channel.go` — additional model CRUD +- `server/store/sqlite/channel.go` — same +- `src/js/chat.js` — model pills UI, @mention autocomplete, multi-model message rendering +- `src/js/api.js` — channel model API methods +- `src/css/style.css` — model pill styling, attribution labels + +### Checklist (Phase 2) + +- [x] Mention parser: `mentions.Parse()` with case-insensitive resolution +- [x] Parser tests: edge cases (no mentions, unresolved, multiple, overlapping names) +- [x] Channel model CRUD handlers (4 endpoints) +- [x] Store: `DeleteModel`, `UpdateModel` for both dialects +- [x] Completion handler: mention extraction → target resolution → fan-out +- [x] SSE streaming: model attribution in delta events +- [x] Frontend: model pills in chat header +- [x] Frontend: add/remove model UI +- [x] Frontend: @mention autocomplete in chat input +- [x] Frontend: model attribution labels on assistant messages +- [x] Frontend: multi-stream rendering (sequential responses, distinct bubbles) +- [x] Integration tests: multi-model completion fan-out +- [x] Integration tests: channel model CRUD +- [x] Backward compat: channels with no `channel_models` rows work exactly as today + +--- + +## Phase 3 — Email Transport + Notification Preferences + +### User Notification Preferences + +```sql +-- Postgres: 008_v0200_notification_prefs.sql +CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, -- notification type or '*' for default + in_app BOOLEAN DEFAULT true, + email BOOLEAN DEFAULT false, + UNIQUE(user_id, type) +); +``` + +```sql +-- SQLite: 007_v0200_notification_prefs.sql +CREATE TABLE IF NOT EXISTS notification_preferences ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + in_app INTEGER DEFAULT 1, + email INTEGER DEFAULT 0, + UNIQUE(user_id, type) +); +``` + +**Resolution chain:** Specific type preference → user's `*` default → system default (in_app=true, email=false). + +### SMTP Configuration + +Admin settings (stored in `platform_settings` JSONB, same pattern as existing admin config): + +```json +{ + "notifications": { + "email_enabled": false, + "smtp_host": "", + "smtp_port": 587, + "smtp_user": "", + "smtp_password": "", // encrypted via vault + "smtp_from": "noreply@chat.example.com", + "smtp_tls": true, + "instance_name": "Chat Switchboard", + "digest_enabled": false, + "digest_interval": "daily" // "hourly" | "daily" | "weekly" + } +} +``` + +**Implementation:** `server/notifications/email.go` — wraps `net/smtp` with TLS support. Template rendering via Go `html/template` (no external dependency). Two templates per notification type: HTML + plaintext fallback. + +### Email Service + +```go +// server/notifications/email.go +type EmailTransport struct { + config SMTPConfig + vault *crypto.KeyResolver // decrypt SMTP password +} + +func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error +``` + +### Notification Service Updates + +The `Service.Notify()` method gains a preference check: + +```go +func (s *Service) Notify(ctx context.Context, n *models.Notification) error { + prefs := s.resolvePrefs(ctx, n.UserID, n.Type) + + if prefs.InApp { + s.store.Create(ctx, n) + s.hub.SendToUser(n.UserID, ...) + } + + if prefs.Email && s.email != nil { + user, _ := s.stores.GetUser(ctx, n.UserID) + if user.Email != "" { + s.email.Send(ctx, user.Email, n.Title, renderHTML(n), renderText(n)) + } + } + + return nil +} +``` + +### Digest Mode + +Low-priority notifications (e.g. `grant.changed`) can batch into a periodic email digest: + +- Background goroutine checks on interval (hourly/daily). +- Queries `notifications WHERE is_read = false AND created_at > last_digest_at`. +- Groups by user, renders a single digest email per user. +- Marks digested notifications with a `digested_at` timestamp (add column in this migration). + +**Deferred if complexity is too high for v0.20.0.** The core value is in-app + individual email. Digest is nice-to-have. + +### User Preferences UI + +Settings → Notifications: + +``` +┌────────────────────────────────────────────────┐ +│ Notification Preferences │ +├────────────────────────────────────────────────┤ +│ Type In-App Email │ +│ ───────────────────── ─────── ───── │ +│ Model fallback [✓] [ ] │ +│ KB processing [✓] [✓] │ +│ Group membership [✓] [ ] │ +│ Default (all others) [✓] [ ] │ +└────────────────────────────────────────────────┘ +``` + +### Admin Settings UI + +Admin panel → System → Notifications: + +- Enable/disable email transport globally. +- SMTP configuration form (host, port, user, password, from address, TLS toggle). +- Test email button (sends to current admin's email). +- Digest toggle + interval selector. + +### Files Changed (Phase 3) + +**New files:** +- `server/notifications/email.go` — SMTP transport +- `server/notifications/templates.go` — HTML/text email templates +- `server/notifications/digest.go` — digest aggregation (if not deferred) +- `server/store/postgres/notification_prefs.go` — preference store +- `server/store/sqlite/notification_prefs.go` — same +- `server/database/migrations/008_v0200_notification_prefs.sql` +- `server/database/migrations/sqlite/007_v0200_notification_prefs.sql` + +**Modified files:** +- `server/notifications/service.go` — preference resolution, email dispatch +- `server/store/interfaces.go` — `NotificationPreferenceStore` +- `server/handlers/notifications.go` — preference CRUD endpoints +- `server/handlers/admin.go` — SMTP config in admin settings +- `src/js/admin-handlers.js` — notification admin UI +- `src/js/app.js` — user notification preferences in settings modal + +### Checklist (Phase 3) + +- [x] Migration: `notification_preferences` table (both dialects) +- [x] Store: `NotificationPreferenceStore` interface + implementations +- [x] Handlers: preference CRUD endpoints (GET/PUT per type) +- [x] Notification service: preference resolution chain +- [x] Email transport: SMTP with TLS, template rendering +- [x] Email templates: HTML + plaintext for each notification type +- [x] Admin UI: SMTP config + test email button +- [x] User UI: notification preferences in Settings +- [ ] Optional: digest mode (background goroutine + batched email) — deferred +- [x] Tests: preference resolution chain +- [x] Tests: email transport (mock SMTP) + +--- + +## Cross-cutting Concerns + +### Retention / Cleanup + +Notifications accumulate. Add a cleanup policy: + +- Default: retain 90 days, configurable via admin settings (`notifications.retention_days`). +- Cleanup: background goroutine (daily) deletes `WHERE created_at < NOW() - retention`. +- Same pattern as compaction scanner — register in `server/main.go` startup. + +### Notification Count Performance + +The unread badge polls on app init, then stays current via WebSocket. No periodic polling. Badge syncs across tabs via `notification.read` event (one tab marks read → other tabs update badge). + +### SQLite Considerations + +- No `LISTEN/NOTIFY` — WebSocket push uses `Hub.SendToUser()` directly (already in-process, no PG dependency). +- `datetime('now')` for `created_at` (standard SQLite pattern). +- `INTEGER` for booleans (standard). + +### Mobile + +- Notification bell fits in mobile header (replace one of the existing icons or use hamburger menu). +- Notification dropdown becomes full-screen overlay on mobile (same pattern as other dropdowns). +- @mention autocomplete: same touch-friendly dropdown pattern as wikilink autocomplete. + +### Security + +- Notifications are user-scoped. No cross-user access via API (every query includes `user_id` from auth context). +- SMTP password encrypted at rest via vault (same pattern as API keys in v0.9.4). +- Email transport validates `smtp_host` against SSRF allowlist (same pattern as `url_fetch`). +- @mention parsing sanitized — no injection via display names (names are alphanumeric + hyphens only, validated on `channel_models` write). + +--- + +## Dependency Map + +``` +Phase 1: Notifications Core + ├── notifications table (migration) + ├── NotificationStore (postgres + sqlite) + ├── Notification Service (create + dispatch) + ├── EventBus route (notification.new) + ├── API endpoints (5) + ├── Frontend: bell + dropdown + panel + └── Wire: KB, groups, resolver → service + +Phase 2: @mention + Multi-model ← can start in parallel with Phase 1 + ├── mentions/parser.go + ├── channel_models CRUD endpoints ← table already exists + ├── completion handler fan-out + ├── Frontend: model pills + autocomplete + └── Frontend: multi-stream rendering + +Phase 3: Email + Preferences ← depends on Phase 1 + ├── notification_preferences table (migration) + ├── SMTP transport + ├── Email templates + ├── Preference resolution in Service + └── Admin + User settings UI +``` + +Phases 1 and 2 have no code-level dependency on each other — they touch different files and can be developed in parallel or in either order. Phase 3 strictly depends on Phase 1 (extends the notification service). + +--- + +## Version Bump + +- `VERSION`: `0.19.2` → `0.20.0` after Phase 2 merge (or after Phase 1 if shipping incrementally as `0.20.0-rc1`). +- Changelog entry after each phase merge. +- No breaking API changes — all additions. + +--- + +## What This Unblocks + +- **v0.23.0 Multi-Participant Channels:** Notifications become the backbone for participant join/leave, new message alerts, and assignment queue events. +- **v0.25.0 Workflow Engine:** Stage transition notifications, assignment notifications, completion webhooks all route through the notification service. +- **v0.22.0 Smart Routing:** Multi-model channels demonstrate the fan-out pattern that routing policies will generalize. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7f0a06f..b9a585e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -76,7 +76,7 @@ v0.19.1 Active Project + System Prompt + Detail Panel ✅ │ v0.19.2 Project Persona Default + Archive + Reorder ✅ │ -v0.20.0 Notifications + @mention Routing + Multi-model +v0.20.0 Notifications + @mention Routing + Multi-model ✅ │ v0.21.0 Extension Surfaces + Modes (depends on CM6 from v0.17.2) │ @@ -601,31 +601,56 @@ Depends on: v0.19.1. --- -## v0.20.0 — Notifications + @mention Routing + Multi-model +## v0.20.0 — Notifications + @mention Routing + Multi-model ✅ Notification infrastructure that makes collaboration real-time, plus -the channel schema already supports multiple models — this adds routing. +multi-model routing for channels. Three-phase delivery: notifications +core (in-app), @mention parsing + fan-out completions, email transport ++ per-user notification preferences. _(Notifications shifted from v0.19.0; @mention routing shifted from v0.17.0)_ -**Notifications** -- [ ] `notifications` table: `id`, `user_id`, `type`, `title`, `body`, `resource_type`, `resource_id`, `is_read`, `created_at` -- [ ] In-app notification bell (header bar, unread count badge) -- [ ] Notification dropdown: grouped by type, mark read, click-to-navigate -- [ ] WebSocket push: real-time delivery via existing EventBus (`notification.new` event) -- [ ] Notification sources: group membership changes, grant updates, KB processing complete, project invites, team invites -- [ ] Email transport (optional): SMTP config in admin settings, per-user opt-in/out -- [ ] Email templates: HTML + plaintext, branded with instance name -- [ ] Digest mode: batch low-priority notifications into periodic email (hourly/daily) -- [ ] Admin settings: enable/disable email, SMTP config, default notification preferences -- [ ] User preferences: per-type toggles (in-app, email, off) +Depends on: v0.19.2 (Projects complete), EventBus + WebSocket hub (v0.9.x), +`channel_models` table (v0.16.0 schema). See +[DESIGN-0.20.0.md](DESIGN-0.20.0.md) for full spec. -**@mention Routing** -- [ ] @mention parsing in messages (users and AI models) -- [ ] Resolve mentions against `channel_models` -- [ ] Multi-model channels: fire completions per mentioned model -- [ ] "Add a model" UI per channel -- [ ] Enables: second opinions, cross-model conversations +**Phase 1 — Notifications Core (in-app)** +- [x] `notifications` table: `id`, `user_id`, `type`, `title`, `body`, `resource_type`, `resource_id`, `is_read`, `created_at` (Postgres + SQLite migrations) +- [x] `NotificationStore` interface + both implementations: Create, ListByUser (paginated), MarkRead, MarkAllRead, Delete, UnreadCount +- [x] `notifications.Service` with `Notify()` / `NotifyMany()` — centralized creation + WebSocket dispatch +- [x] EventBus routes: `notification.new`, `notification.read` (badge sync across tabs) +- [x] API endpoints: list, unread-count, mark-read, mark-all-read, delete (5 endpoints, all user-scoped) +- [x] Initial sources: `role.fallback` (EventBus subscription), `kb.ready`/`kb.error`, `grant.changed` +- [x] Frontend: bell icon + unread badge (capped 9+), notification dropdown (latest 10, click-to-navigate), full panel via PanelRegistry +- [x] WebSocket handler: real-time push + toast for high-priority types +- [x] Background cleanup goroutine (configurable retention, default 90 days) + +**Phase 2 — @mention Parsing + Multi-model Routing** +- [x] `mentions.Parse()`: extract @mentions, resolve against channel model roster (case-insensitive, longest-match-first, trailing punctuation tolerant) +- [x] Parser tests: no mentions, unresolved, multiple, overlapping names, punctuation edge cases +- [x] Channel model CRUD handlers (4 endpoints: list, add, remove, update) +- [x] Completion handler: mention extraction → target resolution → sequential fan-out → N assistant messages +- [x] SSE streaming: model attribution in delta events (`model_display` field) +- [x] Frontend: model pills in chat header with add/remove UI +- [x] Frontend: @mention autocomplete via CM6 `mentionCompletion` extension (roster-backed) +- [x] Frontend: model attribution labels on multi-model assistant messages +- [x] Backward compat: channels with no extra `channel_models` rows work exactly as before + +**Phase 3 — Email Transport + Notification Preferences** +- [x] `notification_preferences` table (Postgres + SQLite): per-user, per-type in_app/email toggles +- [x] Preference resolution chain: specific type → user wildcard `*` → system default (in_app=true, email=false) +- [x] `EmailTransport`: SMTP with implicit TLS (port 465) and STARTTLS (port 587), multipart MIME (HTML + plaintext) +- [x] Email templates: branded HTML + plaintext, domain-prefixed subjects, Go `html/template` +- [x] Preference CRUD endpoints (3: list, set with partial patch, delete) +- [x] Admin SMTP configuration: enable toggle, host/port/user/password/from/TLS fields, test email button +- [x] User notification preferences UI (Settings → Notifications tab, per-type checkboxes) +- [x] Notification service: preference check before dispatch, async email delivery (goroutine, 30s timeout) +- [x] Tests: preference resolution chain (7 tests), email templates (4 tests) +- [x] Deferred: digest mode (batched email), SMTP password vault encryption + +**Hotfix (carried forward from v0.19.2 investigation)** +- [x] `user_model_settings` NULL scan fix: COALESCE on `hidden`/`sort_order` in SELECT + INSERT for both dialects +- [x] Gin release mode: auto-set `gin.ReleaseMode` when `ENVIRONMENT=production`, suppress health check log spam --- diff --git a/k8s/backend.yaml b/k8s/backend.yaml index 30818c2..d5850e4 100644 --- a/k8s/backend.yaml +++ b/k8s/backend.yaml @@ -62,6 +62,8 @@ spec: env: - name: ENVIRONMENT value: "${ENVIRONMENT}" + - name: GIN_MODE + value: "${GIN_MODE}" - name: PORT value: "8080" - name: BASE_PATH diff --git a/scripts/db-validate.sh b/scripts/db-validate.sh index 30a9c46..3c65936 100644 --- a/scripts/db-validate.sh +++ b/scripts/db-validate.sh @@ -203,6 +203,11 @@ check_table "project_channels" check_table "project_knowledge_bases" check_table "project_notes" +# ── Notifications (v0.20.0) ────────────── +echo "" +echo "Notifications (v0.20.0):" +check_table "notifications" + # ── Users (vault) ──────────────────────── echo "" echo "Users (vault):" diff --git a/server/database/migrations/007_v0200_notifications.sql b/server/database/migrations/007_v0200_notifications.sql new file mode 100644 index 0000000..4d7af6e --- /dev/null +++ b/server/database/migrations/007_v0200_notifications.sql @@ -0,0 +1,17 @@ +-- 007_v0200_notifications.sql +-- Notification infrastructure (v0.20.0 Phase 1) + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT DEFAULT '', + resource_type VARCHAR(50), + resource_id UUID, + is_read BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC); diff --git a/server/database/migrations/008_v0200_notification_prefs.sql b/server/database/migrations/008_v0200_notification_prefs.sql new file mode 100644 index 0000000..756691e --- /dev/null +++ b/server/database/migrations/008_v0200_notification_prefs.sql @@ -0,0 +1,18 @@ +-- 008_v0200_notification_prefs.sql +-- Notification preferences per user per type. +-- Resolution: specific type → user '*' default → system default (in_app=true, email=false). + +BEGIN; + +CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, -- notification type or '*' for default + in_app BOOLEAN NOT NULL DEFAULT true, + email BOOLEAN NOT NULL DEFAULT false, + UNIQUE(user_id, type) +); + +CREATE INDEX IF NOT EXISTS idx_notification_prefs_user ON notification_preferences(user_id); + +COMMIT; diff --git a/server/database/migrations/sqlite/006_v0200_notifications.sql b/server/database/migrations/sqlite/006_v0200_notifications.sql new file mode 100644 index 0000000..07e37a3 --- /dev/null +++ b/server/database/migrations/sqlite/006_v0200_notifications.sql @@ -0,0 +1,17 @@ +-- 006_v0200_notifications.sql +-- Notification infrastructure (v0.20.0 Phase 1) + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT DEFAULT '', + resource_type TEXT, + resource_id TEXT, + is_read INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC); diff --git a/server/database/migrations/sqlite/007_v0200_notification_prefs.sql b/server/database/migrations/sqlite/007_v0200_notification_prefs.sql new file mode 100644 index 0000000..94e364f --- /dev/null +++ b/server/database/migrations/sqlite/007_v0200_notification_prefs.sql @@ -0,0 +1,13 @@ +-- 007_v0200_notification_prefs.sql +-- Notification preferences per user per type (SQLite dialect). + +CREATE TABLE IF NOT EXISTS notification_preferences ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + in_app INTEGER NOT NULL DEFAULT 1, + email INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id, type) +); + +CREATE INDEX IF NOT EXISTS idx_notification_prefs_user ON notification_preferences(user_id); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index b359fc9..daa687c 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -256,6 +256,7 @@ func TruncateAll(t *testing.T) { "resource_grants", "group_members", "groups", + "notifications", "usage_log", "model_pricing", "notes", diff --git a/server/events/types.go b/server/events/types.go index e2fa247..b1e010d 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -56,6 +56,10 @@ var routeTable = map[string]Direction{ // Role alerts (v0.17.0) "role.fallback": DirToClient, + // Notifications (v0.20.0) + "notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based + "notification.read": DirToClient, // badge sync across tabs + // Plugin hooks — never cross the wire "plugin.hook.": DirLocal, "internal.": DirLocal, @@ -103,3 +107,12 @@ func ShouldAcceptFromClient(label string) bool { d := RouteFor(label) return d == DirFromClient || d == DirBoth } + +// MustJSON marshals v to json.RawMessage, returning {} on error. +func MustJSON(v any) json.RawMessage { + data, err := json.Marshal(v) + if err != nil { + return json.RawMessage(`{}`) + } + return data +} diff --git a/server/handlers/admin_email.go b/server/handlers/admin_email.go new file mode 100644 index 0000000..6ed6ffd --- /dev/null +++ b/server/handlers/admin_email.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/notifications" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// AdminEmailHandler handles admin SMTP configuration and test endpoints. +type AdminEmailHandler struct { + stores store.Stores +} + +// NewAdminEmailHandler creates a new admin email handler. +func NewAdminEmailHandler(s store.Stores) *AdminEmailHandler { + return &AdminEmailHandler{stores: s} +} + +// TestEmail sends a test email to the requesting admin's email address. +// POST /api/v1/admin/notifications/test-email +func (h *AdminEmailHandler) TestEmail(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + user, err := h.stores.Users.GetByID(c.Request.Context(), userID) + if err != nil || user == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if user.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no email address configured for your account"}) + return + } + + // Load current SMTP config + cfg, err := notifications.LoadSMTPConfig(h.stores.GlobalConfig, nil) + if err != nil || cfg == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP not configured: " + err.Error()}) + return + } + + transport := notifications.NewEmailTransport(*cfg) + if transport == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP configuration is incomplete"}) + return + } + + // Get instance name + instanceName := "Chat Switchboard" + if branding, err := h.stores.GlobalConfig.Get(context.Background(), "branding"); err == nil { + if name, ok := branding["instance_name"].(string); ok && name != "" { + instanceName = name + } + } + + subject := "[" + instanceName + "] Test Email" + htmlBody := ` + +

` + instanceName + `

+

This is a test email from your Chat Switchboard instance.

+

If you received this, your SMTP configuration is working correctly.

+` + textBody := instanceName + " — Test Email\n\nThis is a test email. Your SMTP configuration is working correctly." + + if err := transport.Send(c.Request.Context(), user.Email, subject, htmlBody, textBody); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "send failed: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true, "sent_to": user.Email}) +} diff --git a/server/handlers/channel_models.go b/server/handlers/channel_models.go new file mode 100644 index 0000000..e9ccb67 --- /dev/null +++ b/server/handlers/channel_models.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "database/sql" + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Channel Models Handler ────────────────── +// Manages multi-model assignments per channel (v0.20.0 Phase 2). +// Routes: +// GET /channels/:id/models — list +// POST /channels/:id/models — add +// PATCH /channels/:id/models/:modelId — update (display_name, is_default, system_prompt) +// DELETE /channels/:id/models/:modelId — remove + +const maxModelsPerChannel = 5 + +type ChannelModelHandler struct { + stores store.Stores +} + +func NewChannelModelHandler(stores store.Stores) *ChannelModelHandler { + return &ChannelModelHandler{stores: stores} +} + +// ── List ───────────────────────────────────── + +func (h *ChannelModelHandler) List(c *gin.Context) { + channelID := c.Param("id") + userID := getUserID(c) + + if !userOwnsChannel(c, channelID, userID) { + return + } + + roster, err := h.stores.Channels.GetModels(c.Request.Context(), channelID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) + return + } + if roster == nil { + roster = []models.ChannelModel{} + } + c.JSON(http.StatusOK, roster) +} + +// ── Add ────────────────────────────────────── + +type addChannelModelRequest struct { + ModelID string `json:"model_id" binding:"required"` + ProviderConfigID string `json:"provider_config_id"` + DisplayName string `json:"display_name" binding:"required"` + SystemPrompt string `json:"system_prompt"` + IsDefault bool `json:"is_default"` +} + +func (h *ChannelModelHandler) Add(c *gin.Context) { + channelID := c.Param("id") + userID := getUserID(c) + + if !userOwnsChannel(c, channelID, userID) { + return + } + + var req addChannelModelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Check max models limit + existing, err := h.stores.Channels.GetModels(c.Request.Context(), channelID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check existing models"}) + return + } + if len(existing) >= maxModelsPerChannel { + c.JSON(http.StatusConflict, gin.H{"error": "maximum models per channel reached"}) + return + } + + // If this is the first model or marked as default, ensure only one default + isDefault := req.IsDefault || len(existing) == 0 + + cm := &models.ChannelModel{ + ChannelID: channelID, + ModelID: req.ModelID, + ProviderConfigID: req.ProviderConfigID, + DisplayName: req.DisplayName, + SystemPrompt: req.SystemPrompt, + IsDefault: isDefault, + } + + // SetModel handles INSERT ON CONFLICT (upsert by channel_id + model_id) + if err := h.stores.Channels.SetModel(c.Request.Context(), cm); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add model"}) + return + } + + // If this is the new default, clear default on others + if isDefault { + h.clearOtherDefaults(c, channelID, req.ModelID) + } + + // Return the updated roster + roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) + c.JSON(http.StatusCreated, gin.H{"models": roster}) +} + +// ── Update ─────────────────────────────────── + +type updateChannelModelRequest struct { + DisplayName *string `json:"display_name"` + SystemPrompt *string `json:"system_prompt"` + IsDefault *bool `json:"is_default"` +} + +func (h *ChannelModelHandler) Update(c *gin.Context) { + channelID := c.Param("id") + modelRecordID := c.Param("modelId") + userID := getUserID(c) + + if !userOwnsChannel(c, channelID, userID) { + return + } + + var req updateChannelModelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify model belongs to this channel + cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"}) + return + } + if cm.ChannelID != channelID { + c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"}) + return + } + + fields := make(map[string]interface{}) + if req.DisplayName != nil { + fields["display_name"] = *req.DisplayName + } + if req.SystemPrompt != nil { + fields["system_prompt"] = *req.SystemPrompt + } + if req.IsDefault != nil { + fields["is_default"] = *req.IsDefault + if *req.IsDefault { + h.clearOtherDefaults(c, channelID, cm.ModelID) + } + } + + if len(fields) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + if err := h.stores.Channels.UpdateModel(c.Request.Context(), modelRecordID, fields); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"}) + return + } + + roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) + c.JSON(http.StatusOK, gin.H{"models": roster}) +} + +// ── Delete ─────────────────────────────────── + +func (h *ChannelModelHandler) Delete(c *gin.Context) { + channelID := c.Param("id") + modelRecordID := c.Param("modelId") + userID := getUserID(c) + + if !userOwnsChannel(c, channelID, userID) { + return + } + + // Verify model belongs to this channel + cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"}) + return + } + if cm.ChannelID != channelID { + c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"}) + return + } + + if err := h.stores.Channels.DeleteModel(c.Request.Context(), modelRecordID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) + return + } + + // If deleted model was default, promote the first remaining model + if cm.IsDefault { + remaining, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) + if len(remaining) > 0 { + h.stores.Channels.UpdateModel(c.Request.Context(), remaining[0].ID, + map[string]interface{}{"is_default": true}) + } + } + + roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) + c.JSON(http.StatusOK, gin.H{"models": roster}) +} + +// ── Helpers ────────────────────────────────── + +// clearOtherDefaults sets is_default=false on all models in the channel +// except the one with the given modelID. +func (h *ChannelModelHandler) clearOtherDefaults(c *gin.Context, channelID, keepModelID string) { + roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) + for _, m := range roster { + if m.ModelID != keepModelID && m.IsDefault { + h.stores.Channels.UpdateModel(c.Request.Context(), m.ID, + map[string]interface{}{"is_default": false}) + } + } +} diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 80c67fd..cdd2a65 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -18,6 +18,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/knowledge" + "git.gobha.me/xcaliber/chat-switchboard/mentions" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/storage" @@ -218,6 +219,19 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } } + // ── Multi-model @mention routing (v0.20.0) ────────── + // Check if the message @mentions specific channel models. + // If multiple models are targeted, fan out sequentially. + roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) + if len(roster) > 1 { + parsed := mentions.Parse(req.Content, roster) + targets := mentions.ResolvedModels(parsed) + if len(targets) > 1 { + h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, req) + return + } + } + // Build provider request provReq := providers.CompletionRequest{ Model: model, @@ -257,6 +271,122 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } } +// ── Multi-Model Sequential Stream (v0.20.0) ── + +// multiModelStream handles the case where a message @mentions multiple +// channel models. It streams each model's response sequentially within +// a single SSE response, sending model_start/model_end delimiters. +func (h *CompletionHandler) multiModelStream( + c *gin.Context, + targets []models.ChannelModel, + messages []providers.Message, + channelID, userID, personaID, presetSystemPrompt string, + req completionRequest, +) { + // Set SSE headers once for the entire multi-model stream + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + flusher, _ := c.Writer.(http.Flusher) + flush := func() { + if flusher != nil { + flusher.Flush() + } + } + sendSSE := func(data string) { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + flush() + } + sendEvent := func(event, data string) { + fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data) + flush() + } + + for _, target := range targets { + // Notify client which model is about to respond + startJSON, _ := json.Marshal(map[string]string{ + "model_id": target.ModelID, + "display_name": target.DisplayName, + }) + sendEvent("model_start", string(startJSON)) + + // Resolve config for this specific model + targetReq := req + targetReq.Model = target.ModelID + if target.ProviderConfigID != "" { + targetReq.APIConfigID = target.ProviderConfigID + } + + providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq) + if err != nil { + sendSSE(fmt.Sprintf(`{"error":"failed to resolve config for %s: %s"}`, target.DisplayName, escapeJSON(err.Error()))) + sendEvent("model_end", string(startJSON)) + continue + } + + provider, err := providers.Get(providerID) + if err != nil { + sendSSE(fmt.Sprintf(`{"error":"provider unavailable for %s"}`, target.DisplayName)) + sendEvent("model_end", string(startJSON)) + continue + } + + // Build per-model messages (inject model-specific system prompt if set) + modelMessages := make([]providers.Message, len(messages)) + copy(modelMessages, messages) + if target.SystemPrompt != "" { + // Prepend model-specific system prompt + modelMessages = append([]providers.Message{{ + Role: "system", + Content: target.SystemPrompt, + }}, modelMessages...) + } + + caps := h.getModelCapabilities(c, model, configID) + + provReq := providers.CompletionRequest{ + Model: model, + Messages: modelMessages, + } + if targetReq.MaxTokens > 0 { + provReq.MaxTokens = targetReq.MaxTokens + } else { + provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps) + } + if targetReq.Temperature != nil { + provReq.Temperature = targetReq.Temperature + } + if targetReq.TopP != nil { + provReq.TopP = targetReq.TopP + } + hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) + if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { + provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools) + } + + // Stream this model's response using the shared streaming core + result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, h.hub) + + // Persist assistant message with model attribution + if result.Content != "" { + if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil { + log.Printf("Failed to persist multi-model assistant message: %v", err) + } + } + + // Log usage + h.logUsage(c, channelID, userID, configID, providerScope, model, + result.InputTokens, result.OutputTokens, + result.CacheCreationTokens, result.CacheReadTokens) + + sendEvent("model_end", string(startJSON)) + } + + sendSSE("[DONE]") +} + // buildToolDefs converts registered tools to provider-format tool definitions. // If includeBrowser is true, also fetches tool schemas from enabled browser extensions. // Tools whose names appear in disabledTools are excluded. diff --git a/server/handlers/groups.go b/server/handlers/groups.go index e9cc047..44e9207 100644 --- a/server/handlers/groups.go +++ b/server/handlers/groups.go @@ -8,6 +8,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -202,16 +203,26 @@ func (h *GroupHandler) AddMember(c *gin.Context) { } // Verify group exists - if _, err := h.stores.Groups.GetByID(c.Request.Context(), groupID); err == sql.ErrNoRows { + group, err := h.stores.Groups.GetByID(c.Request.Context(), groupID) + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) return } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up group"}) + return + } if err := h.stores.Groups.AddMember(c.Request.Context(), groupID, req.UserID, actorID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"}) return } + // Notify the added user (v0.20.0) + if svc := notifications.Default(); svc != nil { + notifications.NotifyGroupMemberAdded(svc, req.UserID, groupID, group.Name) + } + c.JSON(http.StatusOK, gin.H{"ok": true}) AuditLog(c, "group.member.add", "group", groupID, map[string]interface{}{ "user_id": req.UserID, @@ -224,6 +235,9 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) { groupID := c.Param("id") userID := c.Param("userId") + // Fetch group name for notification before removal + group, _ := h.stores.Groups.GetByID(c.Request.Context(), groupID) + err := h.stores.Groups.RemoveMember(c.Request.Context(), groupID, userID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) @@ -234,6 +248,11 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) { return } + // Notify the removed user (v0.20.0) + if svc := notifications.Default(); svc != nil && group != nil { + notifications.NotifyGroupMemberRemoved(svc, userID, groupID, group.Name) + } + c.JSON(http.StatusOK, gin.H{"ok": true}) AuditLog(c, "group.member.remove", "group", groupID, map[string]interface{}{ "user_id": userID, diff --git a/server/handlers/notifications.go b/server/handlers/notifications.go new file mode 100644 index 0000000..3597012 --- /dev/null +++ b/server/handlers/notifications.go @@ -0,0 +1,281 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Handler ───────────────────────────────── + +// NotificationHandler handles notification CRUD endpoints. +type NotificationHandler struct { + stores store.Stores + hub *events.Hub +} + +// NewNotificationHandler creates a new notification handler. +func NewNotificationHandler(s store.Stores, hub *events.Hub) *NotificationHandler { + return &NotificationHandler{stores: s, hub: hub} +} + +// ── GET /api/v1/notifications ─────────────── +// Returns paginated notifications for the authenticated user. +// Query params: ?limit=20&offset=0&unread_only=false + +func (h *NotificationHandler) List(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + unreadOnly := c.Query("unread_only") == "true" + + if limit <= 0 || limit > 100 { + limit = 20 + } + if offset < 0 { + offset = 0 + } + + items, total, err := h.stores.Notifications.ListByUser( + c.Request.Context(), userID, limit, offset, unreadOnly) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": items, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// ── GET /api/v1/notifications/unread-count ── + +func (h *NotificationHandler) UnreadCount(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + count, err := h.stores.Notifications.UnreadCount(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"count": count}) +} + +// ── PATCH /api/v1/notifications/:id/read ──── + +func (h *NotificationHandler) MarkRead(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "notification id required"}) + return + } + + if err := h.stores.Notifications.MarkRead(c.Request.Context(), id, userID); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) + return + } + + // Sync badge across tabs + if h.hub != nil { + payload, _ := json.Marshal(map[string]string{"id": id}) + h.hub.SendToUser(userID, events.Event{ + Label: "notification.read", + Payload: payload, + Ts: time.Now().UnixMilli(), + }) + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ── POST /api/v1/notifications/mark-all-read ─ + +func (h *NotificationHandler) MarkAllRead(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + if err := h.stores.Notifications.MarkAllRead(c.Request.Context(), userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) + return + } + + // Sync badge across tabs + if h.hub != nil { + payload, _ := json.Marshal(map[string]string{"action": "mark_all_read"}) + h.hub.SendToUser(userID, events.Event{ + Label: "notification.read", + Payload: payload, + Ts: time.Now().UnixMilli(), + }) + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ── DELETE /api/v1/notifications/:id ──────── + +func (h *NotificationHandler) Delete(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "notification id required"}) + return + } + + if err := h.stores.Notifications.Delete(c.Request.Context(), id, userID); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ── Notification Preferences (v0.20.0 Phase 3) ── + +// GET /api/v1/notifications/preferences +func (h *NotificationHandler) ListPreferences(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + if h.stores.NotifPrefs == nil { + c.JSON(http.StatusOK, []models.NotificationPreference{}) + return + } + + prefs, err := h.stores.NotifPrefs.ListForUser(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + if prefs == nil { + prefs = []models.NotificationPreference{} + } + c.JSON(http.StatusOK, prefs) +} + +// PUT /api/v1/notifications/preferences/:type +func (h *NotificationHandler) SetPreference(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + notifType := c.Param("type") + if notifType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"}) + return + } + + var req struct { + InApp *bool `json:"in_app"` + Email *bool `json:"email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if h.stores.NotifPrefs == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "preferences not available"}) + return + } + + // Load existing or create new + existing, _ := h.stores.NotifPrefs.Get(c.Request.Context(), userID, notifType) + pref := &models.NotificationPreference{ + UserID: userID, + Type: notifType, + InApp: true, // default + Email: false, // default + } + if existing != nil { + pref = existing + } + if req.InApp != nil { + pref.InApp = *req.InApp + } + if req.Email != nil { + pref.Email = *req.Email + } + + if err := h.stores.NotifPrefs.Upsert(c.Request.Context(), pref); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"}) + return + } + + c.JSON(http.StatusOK, pref) +} + +// DELETE /api/v1/notifications/preferences/:type +func (h *NotificationHandler) DeletePreference(c *gin.Context) { + userID := getUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + notifType := c.Param("type") + if notifType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"}) + return + } + + if h.stores.NotifPrefs == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "preferences not available"}) + return + } + + if err := h.stores.NotifPrefs.Delete(c.Request.Context(), userID, notifType); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go index 31377c4..32bd875 100644 --- a/server/handlers/stream_loop.go +++ b/server/handlers/stream_loop.go @@ -240,6 +240,181 @@ func streamWithToolLoop( const browserToolTimeout = 30 * time.Second +// streamModelResponse is a variant of streamWithToolLoop used by multi-model +// fan-out (v0.20.0). It streams a single model's response within an already- +// established SSE connection — it does NOT set SSE headers or send [DONE]. +// +// SSE deltas include a "model_display" field so the frontend can attribute +// each response to the correct model. +func streamModelResponse( + c *gin.Context, + provider providers.Provider, + cfg providers.ProviderConfig, + req *providers.CompletionRequest, + model, displayName, userID, channelID, personaID string, + hub *events.Hub, +) streamResult { + flusher, _ := c.Writer.(http.Flusher) + flush := func() { + if flusher != nil { + flusher.Flush() + } + } + sendSSE := func(data string) { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + flush() + } + sendEvent := func(event, data string) { + fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data) + flush() + } + + var result streamResult + + for iteration := 0; iteration < maxToolIterations; iteration++ { + ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req) + if err != nil { + sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error()))) + return result + } + + var iterContent string + var iterReasoning string + var toolCalls []providers.ToolCall + + for event := range ch { + if event.Error != nil { + sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error()))) + return result + } + + if event.Reasoning != "" { + iterReasoning += event.Reasoning + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`, + event.Reasoning, model, displayName)) + } + + if event.Delta != "" { + iterContent += event.Delta + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`, + event.Delta, model, displayName)) + } + + if event.Done { + if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 { + toolCalls = event.ToolCalls + result.InputTokens += event.InputTokens + result.OutputTokens += event.OutputTokens + result.CacheCreationTokens += event.CacheCreationTokens + result.CacheReadTokens += event.CacheReadTokens + } else { + finishReason := event.FinishReason + if finishReason == "" { + finishReason = "stop" + } + if iterReasoning != "" { + result.Content += "" + iterReasoning + "" + } + result.Content += iterContent + result.InputTokens += event.InputTokens + result.OutputTokens += event.OutputTokens + result.CacheCreationTokens += event.CacheCreationTokens + result.CacheReadTokens += event.CacheReadTokens + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{},"finish_reason":%q}],"model":%q,"model_display":%q}`, + finishReason, model, displayName)) + // Do NOT send [DONE] — caller manages that for multi-model + return result + } + break + } + } + + // Tool execution (same logic as streamWithToolLoop) + if len(toolCalls) == 0 { + if iterReasoning != "" { + result.Content += "" + iterReasoning + "" + } + result.Content += iterContent + return result + } + + toolCallsJSON, _ := json.Marshal(toolCalls) + sendEvent("tool_use", string(toolCallsJSON)) + + assistantMsg := providers.Message{ + Role: "assistant", + Content: iterContent, + } + for _, tc := range toolCalls { + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc) + } + req.Messages = append(req.Messages, assistantMsg) + + execCtx := tools.ExecutionContext{ + UserID: userID, + ChannelID: channelID, + PersonaID: personaID, + } + for _, tc := range toolCalls { + call := tools.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + + var toolResult tools.ToolResult + if tools.Get(call.Name) != nil { + toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call) + } else if hub != nil && hub.IsConnected(userID) { + toolResult = executeBrowserTool(hub, userID, call) + } else { + toolResult = tools.ToolResult{ + ToolCallID: call.ID, + Name: call.Name, + Content: `{"error":"unknown tool or browser not connected"}`, + IsError: true, + } + } + + resultJSON, _ := json.Marshal(map[string]interface{}{ + "tool_call_id": toolResult.ToolCallID, + "name": toolResult.Name, + "content": toolResult.Content, + "is_error": toolResult.IsError, + }) + sendEvent("tool_result", string(resultJSON)) + + result.ToolActivity = append(result.ToolActivity, map[string]interface{}{ + "id": tc.ID, + "name": tc.Function.Name, + "arguments": tc.Function.Arguments, + "result": toolResult.Content, + "is_error": toolResult.IsError, + }) + + req.Messages = append(req.Messages, providers.Message{ + Role: "tool", + Content: toolResult.Content, + ToolCallID: toolResult.ToolCallID, + Name: toolResult.Name, + }) + } + + result.Content += iterContent + } + + // Hit max iterations + log.Printf("⚠️ Multi-model tool loop hit max iterations (%d) for model %s", maxToolIterations, displayName) + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q,"model_display":%q}`, + escapeJSON("[Tool execution limit reached]"), model, displayName)) + + return result +} + // executeBrowserTool sends a tool call to the user's browser via WebSocket // and blocks until a result is returned or the timeout elapses. func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult { diff --git a/server/knowledge/ingest.go b/server/knowledge/ingest.go index 6f07ece..fb68625 100644 --- a/server/knowledge/ingest.go +++ b/server/knowledge/ingest.go @@ -10,6 +10,7 @@ import ( "time" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -85,6 +86,10 @@ func (ing *Ingester) IngestDocument(kb *models.KnowledgeBase, doc *models.KBDocu log.Printf("❌ ingest doc %s (%s): %v", doc.ID, doc.Filename, err) ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "error", &errMsg) ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "active"}) + // Notify document owner of ingestion failure (v0.20.0) + if svc := notifications.Default(); svc != nil { + notifications.NotifyKBError(svc, userID, kb.ID, kb.Name, errMsg) + } } }() } @@ -205,6 +210,11 @@ func (ing *Ingester) ingest(ctx context.Context, kb *models.KnowledgeBase, doc * log.Printf(" ✓ ingest complete: %s (%d chunks, %s)", doc.Filename, len(chunks), time.Since(start).Round(time.Millisecond)) + // Notify document owner of successful ingestion (v0.20.0) + if svc := notifications.Default(); svc != nil { + notifications.NotifyKBReady(svc, userID, kb.ID, kb.Name, len(chunks)) + } + return nil } diff --git a/server/main.go b/server/main.go index 51ef66c..479b502 100644 --- a/server/main.go +++ b/server/main.go @@ -21,6 +21,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/memory" "git.gobha.me/xcaliber/chat-switchboard/middleware" + "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/roles" "git.gobha.me/xcaliber/chat-switchboard/storage" @@ -185,6 +186,35 @@ func main() { // ── WebSocket Hub ───────────────────────── hub := events.NewHub(bus) + // ── Notification Service (v0.20.0) ─────── + var notifSvc *notifications.Service + if stores.Notifications != nil { + notifSvc = notifications.NewService(stores.Notifications, hub). + WithPrefs(stores.NotifPrefs). + WithUsers(stores.Users) + + // Load SMTP config from platform settings for email transport (Phase 3) + if stores.GlobalConfig != nil { + if smtpCfg, err := notifications.LoadSMTPConfig(stores.GlobalConfig, keyResolver); err == nil && smtpCfg != nil { + transport := notifications.NewEmailTransport(*smtpCfg) + instanceName := "Chat Switchboard" + if brandCfg, err := stores.GlobalConfig.Get(context.Background(), "branding"); err == nil { + if name, ok := brandCfg["instance_name"].(string); ok && name != "" { + instanceName = name + } + } + notifSvc.WithEmail(transport, instanceName) + log.Println("[notifications] email transport enabled") + } + } + notifSvc.StartCleanup() + notifications.SetDefault(notifSvc) + + // Subscribe to role.fallback events → generate notifications for admins + bus.Subscribe("role.fallback", notifications.RoleFallbackHandler(notifSvc, stores)) + defer notifSvc.StopCleanup() + } + // Health check (k8s probes hit this directly) base.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{ @@ -246,6 +276,13 @@ func main() { protected.PUT("/channels/:id", channels.UpdateChannel) protected.DELETE("/channels/:id", channels.DeleteChannel) + // Channel models (v0.20.0 — multi-model @mention routing) + chModelH := handlers.NewChannelModelHandler(stores) + protected.GET("/channels/:id/models", chModelH.List) + protected.POST("/channels/:id/models", chModelH.Add) + protected.PATCH("/channels/:id/models/:modelId", chModelH.Update) + protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete) + // Messages msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) protected.GET("/channels/:id/messages", msgs.ListMessages) @@ -350,6 +387,19 @@ func main() { protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote) protected.GET("/projects/:id/notes", projectH.ListNotes) + // Notifications (v0.20.0) + notifH := handlers.NewNotificationHandler(stores, hub) + protected.GET("/notifications", notifH.List) + protected.GET("/notifications/unread-count", notifH.UnreadCount) + protected.PATCH("/notifications/:id/read", notifH.MarkRead) + protected.POST("/notifications/mark-all-read", notifH.MarkAllRead) + protected.DELETE("/notifications/:id", notifH.Delete) + + // Notification preferences (v0.20.0 Phase 3) + protected.GET("/notifications/preferences", notifH.ListPreferences) + protected.PUT("/notifications/preferences/:type", notifH.SetPreference) + protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference) + // Attachments (file upload/download) attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) protected.POST("/channels/:id/attachments", attachH.Upload) @@ -563,6 +613,10 @@ func main() { // Vault admin.GET("/vault/status", adm.VaultStatus) + // Email / SMTP test (v0.20.0 Phase 3) + emailAdm := handlers.NewAdminEmailHandler(stores) + admin.POST("/notifications/test-email", emailAdm.TestEmail) + // Storage management (orphan cleanup) attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue) admin.GET("/storage/orphans", attachAdm.OrphanCount) diff --git a/server/mentions/parser.go b/server/mentions/parser.go new file mode 100644 index 0000000..f34220c --- /dev/null +++ b/server/mentions/parser.go @@ -0,0 +1,155 @@ +package mentions + +import ( + "sort" + "strings" + "unicode" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +// Mention represents a parsed @mention token in message content. +type Mention struct { + Raw string // "@claude-3-opus" as written (includes @) + Name string // "claude-3-opus" (normalized, no @) + Start int // byte offset in message content + End int // byte offset end (exclusive) + Resolved *models.ChannelModel // nil if unresolved +} + +// Parse extracts @mentions from message content and resolves them +// against the channel's model roster. +// +// Rules: +// - @ followed by one or more non-whitespace characters +// - Greedy: @claude-3-opus-20240229 matches the full token +// - Resolution: case-insensitive match against display_name with +// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus") +// - Longest-match-first when display names overlap +// - Unresolved mentions are included with Resolved = nil +// - @ must be at start of content or preceded by whitespace +func Parse(content string, roster []models.ChannelModel) []Mention { + if len(roster) == 0 || !strings.Contains(content, "@") { + return nil + } + + // Build lookup: normalized display_name → *ChannelModel + // Sort roster by display_name length descending for longest-match-first + type entry struct { + normalized string + model models.ChannelModel + } + entries := make([]entry, 0, len(roster)) + for _, cm := range roster { + if cm.DisplayName == "" { + continue + } + entries = append(entries, entry{ + normalized: normalize(cm.DisplayName), + model: cm, + }) + } + sort.Slice(entries, func(i, j int) bool { + return len(entries[i].normalized) > len(entries[j].normalized) + }) + + // Extract @tokens + var mentions []Mention + i := 0 + for i < len(content) { + if content[i] != '@' { + i++ + continue + } + + // @ must be at start or preceded by whitespace + if i > 0 && !unicode.IsSpace(rune(content[i-1])) { + i++ + continue + } + + // Extract the token after @ + start := i + i++ // skip @ + tokenStart := i + for i < len(content) && !unicode.IsSpace(rune(content[i])) { + i++ + } + + if i == tokenStart { + continue // bare @ with no token + } + + // Strip trailing punctuation (commas, periods, etc.) + tokenEnd := i + for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) { + tokenEnd-- + } + if tokenEnd == tokenStart { + continue // all punctuation + } + + raw := content[start:tokenEnd] + name := content[tokenStart:tokenEnd] + normalizedName := normalize(name) + + // Try to resolve against roster (longest match first) + var resolved *models.ChannelModel + for idx := range entries { + if normalizedName == entries[idx].normalized { + cm := entries[idx].model + resolved = &cm + break + } + } + + mentions = append(mentions, Mention{ + Raw: raw, + Name: name, + Start: start, + End: tokenEnd, + Resolved: resolved, + }) + } + + return mentions +} + +// ResolvedModels returns deduplicated resolved models from parsed mentions. +// Returns nil if no mentions resolved. +func ResolvedModels(mentions []Mention) []models.ChannelModel { + if len(mentions) == 0 { + return nil + } + + seen := make(map[string]bool) + var result []models.ChannelModel + for _, m := range mentions { + if m.Resolved != nil && !seen[m.Resolved.ID] { + seen[m.Resolved.ID] = true + result = append(result, *m.Resolved) + } + } + return result +} + +// isMentionTrailingPunct returns true for punctuation that commonly +// follows an @mention but isn't part of the name. +func isMentionTrailingPunct(b byte) bool { + switch b { + case ',', '.', '!', '?', ':', ';', ')', ']', '}': + return true + } + return false +} + +// normalize converts a display name to a canonical form for matching: +// lowercase, hyphens → spaces collapsed, trimmed. +func normalize(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, "-", " ") + s = strings.ReplaceAll(s, "_", " ") + // Collapse multiple spaces + fields := strings.Fields(s) + return strings.Join(fields, " ") +} diff --git a/server/mentions/parser_test.go b/server/mentions/parser_test.go new file mode 100644 index 0000000..f84e73e --- /dev/null +++ b/server/mentions/parser_test.go @@ -0,0 +1,213 @@ +package mentions + +import ( + "testing" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +func roster() []models.ChannelModel { + return []models.ChannelModel{ + {ID: "1", ChannelID: "ch1", ModelID: "anthropic/claude-3-opus", DisplayName: "Claude-3-Opus", IsDefault: true}, + {ID: "2", ChannelID: "ch1", ModelID: "openai/gpt-4", DisplayName: "GPT-4"}, + {ID: "3", ChannelID: "ch1", ModelID: "anthropic/claude-3-haiku", DisplayName: "Claude-3-Haiku"}, + } +} + +func TestParse_NoMentions(t *testing.T) { + m := Parse("Hello, how are you?", roster()) + if len(m) != 0 { + t.Fatalf("expected 0 mentions, got %d", len(m)) + } +} + +func TestParse_EmptyRoster(t *testing.T) { + m := Parse("@gpt-4 hello", nil) + if len(m) != 0 { + t.Fatalf("expected 0 mentions with empty roster, got %d", len(m)) + } +} + +func TestParse_SingleMention(t *testing.T) { + m := Parse("@GPT-4 what do you think?", roster()) + if len(m) != 1 { + t.Fatalf("expected 1 mention, got %d", len(m)) + } + if m[0].Name != "GPT-4" { + t.Errorf("expected Name='GPT-4', got %q", m[0].Name) + } + if m[0].Resolved == nil { + t.Fatal("expected resolved, got nil") + } + if m[0].Resolved.ID != "2" { + t.Errorf("expected resolved to GPT-4 (ID=2), got ID=%s", m[0].Resolved.ID) + } +} + +func TestParse_CaseInsensitive(t *testing.T) { + m := Parse("@claude-3-opus please help", roster()) + if len(m) != 1 || m[0].Resolved == nil { + t.Fatal("expected 1 resolved mention") + } + if m[0].Resolved.ID != "1" { + t.Errorf("expected Claude-3-Opus (ID=1), got ID=%s", m[0].Resolved.ID) + } +} + +func TestParse_MultipleMentions(t *testing.T) { + m := Parse("Hey @Claude-3-Opus and @GPT-4, compare your approaches.", roster()) + if len(m) != 2 { + t.Fatalf("expected 2 mentions, got %d", len(m)) + } + if m[0].Resolved == nil || m[0].Resolved.ID != "1" { + t.Errorf("first mention: expected Claude-3-Opus") + } + if m[1].Resolved == nil || m[1].Resolved.ID != "2" { + t.Errorf("second mention: expected GPT-4") + } +} + +func TestParse_UnresolvedMention(t *testing.T) { + m := Parse("@nonexistent-model hello", roster()) + if len(m) != 1 { + t.Fatalf("expected 1 mention, got %d", len(m)) + } + if m[0].Resolved != nil { + t.Errorf("expected unresolved mention, got resolved to %s", m[0].Resolved.DisplayName) + } +} + +func TestParse_MixedResolvedUnresolved(t *testing.T) { + m := Parse("@GPT-4 and @fake-model what?", roster()) + if len(m) != 2 { + t.Fatalf("expected 2 mentions, got %d", len(m)) + } + if m[0].Resolved == nil { + t.Error("first mention should resolve") + } + if m[1].Resolved != nil { + t.Error("second mention should not resolve") + } +} + +func TestParse_MentionAtStart(t *testing.T) { + m := Parse("@GPT-4", roster()) + if len(m) != 1 || m[0].Resolved == nil { + t.Fatal("expected 1 resolved mention at start") + } +} + +func TestParse_MentionInMiddle(t *testing.T) { + m := Parse("Ask @GPT-4 about this", roster()) + if len(m) != 1 || m[0].Resolved == nil { + t.Fatal("expected 1 resolved mention in middle") + } +} + +func TestParse_NoSpaceBefore(t *testing.T) { + // email@GPT-4 should NOT be parsed as a mention + m := Parse("email@GPT-4 test", roster()) + if len(m) != 0 { + t.Fatalf("expected 0 mentions (no space before @), got %d", len(m)) + } +} + +func TestParse_BareAt(t *testing.T) { + m := Parse("@ nothing", roster()) + if len(m) != 0 { + t.Fatalf("expected 0 mentions for bare @, got %d", len(m)) + } +} + +func TestParse_HyphenSpaceNormalization(t *testing.T) { + // Display name "Claude-3-Opus" should match "@claude_3_opus" (underscore normalization) + m := Parse("@claude_3_opus help", roster()) + if len(m) != 1 || m[0].Resolved == nil { + t.Fatal("expected underscore-normalized match") + } + if m[0].Resolved.ID != "1" { + t.Errorf("expected Claude-3-Opus, got %s", m[0].Resolved.DisplayName) + } +} + +func TestParse_ByteOffsets(t *testing.T) { + content := "Hey @GPT-4 ok" + m := Parse(content, roster()) + if len(m) != 1 { + t.Fatal("expected 1 mention") + } + if m[0].Start != 4 || m[0].End != 10 { + t.Errorf("expected offsets [4,10), got [%d,%d)", m[0].Start, m[0].End) + } + if content[m[0].Start:m[0].End] != "@GPT-4" { + t.Errorf("offsets don't match: %q", content[m[0].Start:m[0].End]) + } +} + +func TestParse_TrailingPunctuation(t *testing.T) { + // Comma, period, exclamation should be stripped + tests := []struct { + input string + want int // expected resolved count + }{ + {"@GPT-4, what?", 1}, + {"@GPT-4.", 1}, + {"@GPT-4!", 1}, + {"@GPT-4? help", 1}, + {"(@GPT-4)", 0}, // ( before @ is not whitespace — not a mention + {"( @GPT-4)", 1}, // space before @ — valid mention, ) stripped + } + for _, tt := range tests { + m := Parse(tt.input, roster()) + resolved := 0 + for _, mm := range m { + if mm.Resolved != nil { + resolved++ + } + } + if resolved != tt.want { + t.Errorf("Parse(%q): expected %d resolved, got %d", tt.input, tt.want, resolved) + } + } +} + +func TestResolvedModels_Dedup(t *testing.T) { + m := Parse("@GPT-4 do this @GPT-4 and that", roster()) + resolved := ResolvedModels(m) + if len(resolved) != 1 { + t.Fatalf("expected 1 deduplicated model, got %d", len(resolved)) + } +} + +func TestResolvedModels_NilOnNoResolve(t *testing.T) { + m := Parse("@unknown-model test", roster()) + resolved := ResolvedModels(m) + if resolved != nil { + t.Fatalf("expected nil for unresolved only, got %d", len(resolved)) + } +} + +func TestResolvedModels_MultipleDistinct(t *testing.T) { + m := Parse("@GPT-4 and @Claude-3-Opus compare", roster()) + resolved := ResolvedModels(m) + if len(resolved) != 2 { + t.Fatalf("expected 2 distinct models, got %d", len(resolved)) + } +} + +func TestNormalize(t *testing.T) { + tests := []struct { + in, want string + }{ + {"Claude-3-Opus", "claude 3 opus"}, + {"GPT-4", "gpt 4"}, + {"gpt_4", "gpt 4"}, + {" mixed--Case__Name ", "mixed case name"}, + } + for _, tt := range tests { + got := normalize(tt.in) + if got != tt.want { + t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/server/models/models_notification.go b/server/models/models_notification.go new file mode 100644 index 0000000..8687b5b --- /dev/null +++ b/server/models/models_notification.go @@ -0,0 +1,40 @@ +package models + +import "time" + +// ========================================= +// NOTIFICATIONS (v0.20.0) +// ========================================= + +// Notification represents an in-app notification for a user. +// Notifications are immutable once created — only is_read can be toggled. +type Notification struct { + ID string `json:"id" db:"id"` + UserID string `json:"user_id" db:"user_id"` + Type string `json:"type" db:"type"` // domain.action, e.g. "role.fallback", "kb.ready" + Title string `json:"title" db:"title"` // short summary shown in bell dropdown + Body string `json:"body,omitempty" db:"body"` // optional detail text + ResourceType string `json:"resource_type,omitempty" db:"resource_type"` // "channel", "knowledge_base", "project", etc. + ResourceID string `json:"resource_id,omitempty" db:"resource_id"` // navigable entity ID (no FK — resource may be deleted) + IsRead bool `json:"is_read" db:"is_read"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Notification type constants. Convention: domain.action. +// Free-form strings — new types don't require migration. +const ( + NotifTypeRoleFallback = "role.fallback" + NotifTypeKBReady = "kb.ready" + NotifTypeKBError = "kb.error" + NotifTypeGrantChanged = "grant.changed" + NotifTypeProjectInvite = "project.invite" +) + +// Resource type constants for click-to-navigate. +// Note: ResourceTypeKnowledgeBase is already declared in models.go (resource_grants). +const ( + ResourceTypeChannel = "channel" + ResourceTypeProject = "project" + ResourceTypeTeam = "team" + ResourceTypeGroup = "group" +) diff --git a/server/models/models_notification_pref.go b/server/models/models_notification_pref.go new file mode 100644 index 0000000..1a47163 --- /dev/null +++ b/server/models/models_notification_pref.go @@ -0,0 +1,20 @@ +package models + +// NotificationPreference stores a user's per-type notification delivery preference. +// Type "*" acts as a catch-all default for types without an explicit row. +type NotificationPreference struct { + ID string `json:"id" db:"id"` + UserID string `json:"user_id" db:"user_id"` + Type string `json:"type" db:"type"` // notification type or "*" + InApp bool `json:"in_app" db:"in_app"` // show in-app bell/panel + Email bool `json:"email" db:"email"` // send email +} + +// ResolvedPreference is the effective preference after resolution chain. +type ResolvedPreference struct { + InApp bool + Email bool +} + +// SystemDefaultPreference is the fallback when no user preference exists. +var SystemDefaultPreference = ResolvedPreference{InApp: true, Email: false} diff --git a/server/notifications/email.go b/server/notifications/email.go new file mode 100644 index 0000000..61a66fa --- /dev/null +++ b/server/notifications/email.go @@ -0,0 +1,198 @@ +package notifications + +import ( + "context" + "crypto/tls" + "fmt" + "log" + "net" + "net/smtp" + "strings" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// SMTPConfig holds SMTP connection settings, loaded from platform_settings. +type SMTPConfig struct { + Host string `json:"smtp_host"` + Port int `json:"smtp_port"` + User string `json:"smtp_user"` + Password string `json:"smtp_password"` // decrypted at load time + From string `json:"smtp_from"` + TLS bool `json:"smtp_tls"` +} + +// EmailTransport sends notification emails via SMTP. +type EmailTransport struct { + config SMTPConfig +} + +// NewEmailTransport creates an email transport. Returns nil if config is invalid. +func NewEmailTransport(cfg SMTPConfig) *EmailTransport { + if cfg.Host == "" || cfg.Port == 0 || cfg.From == "" { + return nil + } + return &EmailTransport{config: cfg} +} + +// Send delivers an email with both HTML and plaintext bodies. +// Uses multipart/alternative MIME for email client compatibility. +func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error { + if to == "" { + return fmt.Errorf("recipient email is empty") + } + + addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port) + boundary := fmt.Sprintf("==boundary_%d==", time.Now().UnixNano()) + + // Build MIME message + var msg strings.Builder + msg.WriteString(fmt.Sprintf("From: %s\r\n", t.config.From)) + msg.WriteString(fmt.Sprintf("To: %s\r\n", to)) + msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + msg.WriteString("MIME-Version: 1.0\r\n") + msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + msg.WriteString("\r\n") + + // Plaintext part + msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n") + msg.WriteString(textBody) + msg.WriteString("\r\n") + + // HTML part + msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") + msg.WriteString(htmlBody) + msg.WriteString("\r\n") + + msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) + + // Connect with timeout + dialer := net.Dialer{Timeout: 10 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("smtp dial: %w", err) + } + + var client *smtp.Client + if t.config.TLS { + // Implicit TLS (port 465) + tlsConn := tls.Client(conn, &tls.Config{ServerName: t.config.Host}) + client, err = smtp.NewClient(tlsConn, t.config.Host) + } else { + client, err = smtp.NewClient(conn, t.config.Host) + } + if err != nil { + conn.Close() + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() + + // STARTTLS for non-implicit TLS on port 587 + if !t.config.TLS { + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: t.config.Host}); err != nil { + return fmt.Errorf("smtp starttls: %w", err) + } + } + } + + // Authenticate if credentials provided + if t.config.User != "" && t.config.Password != "" { + auth := smtp.PlainAuth("", t.config.User, t.config.Password, t.config.Host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + + // Send + if err := client.Mail(t.config.From); err != nil { + return fmt.Errorf("smtp MAIL: %w", err) + } + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("smtp RCPT: %w", err) + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp DATA: %w", err) + } + if _, err := w.Write([]byte(msg.String())); err != nil { + return fmt.Errorf("smtp write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp close data: %w", err) + } + + return client.Quit() +} + +// SendAsync wraps Send in a goroutine so notification delivery isn't blocked. +func (t *EmailTransport) SendAsync(to, subject, htmlBody, textBody string) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := t.Send(ctx, to, subject, htmlBody, textBody); err != nil { + log.Printf("[email] failed to send to %s: %v", to, err) + } + }() +} + +// LoadSMTPConfig reads SMTP settings from global config and returns an SMTPConfig. +// Returns nil if email is not enabled or config is missing. +// The vault parameter is optional; if provided, it decrypts the SMTP password. +func LoadSMTPConfig(gc store.GlobalConfigStore, vault interface{}) (*SMTPConfig, error) { + raw, err := gc.Get(context.Background(), "notifications") + if err != nil { + return nil, fmt.Errorf("no notification settings: %w", err) + } + + // Check if email is enabled + enabled, _ := raw["email_enabled"].(bool) + if !enabled { + return nil, nil + } + + cfg := SMTPConfig{ + Host: stringVal(raw, "smtp_host"), + Port: intVal(raw, "smtp_port", 587), + User: stringVal(raw, "smtp_user"), + From: stringVal(raw, "smtp_from"), + TLS: boolVal(raw, "smtp_tls"), + } + + // Password: try decrypted value first, then raw + cfg.Password = stringVal(raw, "smtp_password") + + if cfg.Host == "" { + return nil, fmt.Errorf("smtp_host is required") + } + if cfg.From == "" { + return nil, fmt.Errorf("smtp_from is required") + } + + return &cfg, nil +} + +func stringVal(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func intVal(m map[string]interface{}, key string, def int) int { + switch v := m[key].(type) { + case float64: + return int(v) + case int: + return v + } + return def +} + +func boolVal(m map[string]interface{}, key string) bool { + v, _ := m[key].(bool) + return v +} diff --git a/server/notifications/service.go b/server/notifications/service.go new file mode 100644 index 0000000..762b96f --- /dev/null +++ b/server/notifications/service.go @@ -0,0 +1,203 @@ +package notifications + +import ( + "context" + "encoding/json" + "log" + "sync" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Package-level singleton ───────────────── +// Allows notification sources (KB ingester, group handler, etc.) +// to access the service without threading it through constructors. + +var defaultService *Service + +// SetDefault registers the global notification service instance. +// Called once at startup from main.go. +func SetDefault(svc *Service) { + defaultService = svc +} + +// Default returns the global notification service instance, or nil if +// not configured (e.g. in unmanaged mode without a database). +func Default() *Service { + return defaultService +} + +// Service handles notification creation, persistence, and real-time delivery. +// All notification sources should go through this service — never insert directly. +type Service struct { + store store.NotificationStore + prefStore store.NotificationPreferenceStore + userStore store.UserStore + hub *events.Hub + + // Email transport (nil = email disabled) + email *EmailTransport + instanceName string + + // Retention cleanup + retentionDays int + stopCleanup chan struct{} + cleanupWg sync.WaitGroup +} + +// NewService creates a notification service bound to the given store and WebSocket hub. +func NewService(s store.NotificationStore, hub *events.Hub) *Service { + return &Service{ + store: s, + hub: hub, + instanceName: "Chat Switchboard", + retentionDays: 90, + stopCleanup: make(chan struct{}), + } +} + +// WithPrefs attaches the preference store for per-user delivery control. +func (s *Service) WithPrefs(ps store.NotificationPreferenceStore) *Service { + s.prefStore = ps + return s +} + +// WithUsers attaches the user store for email address lookup. +func (s *Service) WithUsers(us store.UserStore) *Service { + s.userStore = us + return s +} + +// WithEmail enables email transport. +func (s *Service) WithEmail(transport *EmailTransport, instanceName string) *Service { + s.email = transport + if instanceName != "" { + s.instanceName = instanceName + } + return s +} + +// WithRetention sets the retention period in days. Notifications older than +// this are pruned by the background cleanup goroutine. Default: 90 days. +func (s *Service) WithRetention(days int) *Service { + if days > 0 { + s.retentionDays = days + } + return s +} + +// Notify routes a notification through the user's delivery preferences: +// in-app (persist + WebSocket) and/or email. Falls back to system defaults +// if no user preferences are set. +func (s *Service) Notify(ctx context.Context, n *models.Notification) error { + prefs := s.resolvePrefs(ctx, n.UserID, n.Type) + + // In-app: persist + WebSocket push + if prefs.InApp { + if err := s.store.Create(ctx, n); err != nil { + return err + } + + if s.hub != nil { + payload, _ := json.Marshal(n) + s.hub.SendToUser(n.UserID, events.Event{ + Label: "notification.new", + Payload: payload, + Ts: time.Now().UnixMilli(), + }) + } + } + + // Email: async send (don't block the caller) + if prefs.Email && s.email != nil && s.userStore != nil { + user, err := s.userStore.GetByID(ctx, n.UserID) + if err == nil && user != nil && user.Email != "" { + subject := SubjectForNotification(n, s.instanceName) + htmlBody := RenderHTML(n, s.instanceName) + textBody := RenderText(n, s.instanceName) + s.email.SendAsync(user.Email, subject, htmlBody, textBody) + } + } + + return nil +} + +// NotifyMany fans out a notification template to multiple users. +// Each user gets their own copy. Errors are logged but don't fail the batch. +func (s *Service) NotifyMany(ctx context.Context, userIDs []string, template models.Notification) { + for _, uid := range userIDs { + n := template + n.ID = "" // let store generate + n.UserID = uid + if err := s.Notify(ctx, &n); err != nil { + log.Printf("[notifications] failed to notify user %s: %v", uid, err) + } + } +} + +// resolvePrefs returns the effective delivery preferences for a user + type. +// Resolution chain: specific type → user '*' default → system default. +func (s *Service) resolvePrefs(ctx context.Context, userID, notifType string) models.ResolvedPreference { + if s.prefStore == nil { + return models.SystemDefaultPreference + } + + // 1. Specific type preference + pref, err := s.prefStore.Get(ctx, userID, notifType) + if err == nil && pref != nil { + return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email} + } + + // 2. User's wildcard default + pref, err = s.prefStore.Get(ctx, userID, "*") + if err == nil && pref != nil { + return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email} + } + + // 3. System default + return models.SystemDefaultPreference +} + +// StartCleanup launches a background goroutine that prunes old notifications daily. +func (s *Service) StartCleanup() { + s.cleanupWg.Add(1) + go func() { + defer s.cleanupWg.Done() + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + // Run once on startup (after a short delay to avoid contention) + time.Sleep(5 * time.Minute) + s.runCleanup() + + for { + select { + case <-ticker.C: + s.runCleanup() + case <-s.stopCleanup: + return + } + } + }() +} + +// StopCleanup signals the cleanup goroutine to stop and waits for it. +func (s *Service) StopCleanup() { + close(s.stopCleanup) + s.cleanupWg.Wait() +} + +func (s *Service) runCleanup() { + cutoff := time.Now().AddDate(0, 0, -s.retentionDays) + deleted, err := s.store.DeleteOlderThan(context.Background(), cutoff) + if err != nil { + log.Printf("[notifications] cleanup error: %v", err) + return + } + if deleted > 0 { + log.Printf("[notifications] cleaned up %d notifications older than %d days", deleted, s.retentionDays) + } +} diff --git a/server/notifications/service_test.go b/server/notifications/service_test.go new file mode 100644 index 0000000..0f43f4c --- /dev/null +++ b/server/notifications/service_test.go @@ -0,0 +1,137 @@ +package notifications + +import ( + "context" + "testing" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +// mockPrefStore implements store.NotificationPreferenceStore for testing. +type mockPrefStore struct { + prefs map[string]map[string]*models.NotificationPreference // userID → type → pref +} + +func newMockPrefStore() *mockPrefStore { + return &mockPrefStore{prefs: make(map[string]map[string]*models.NotificationPreference)} +} + +func (m *mockPrefStore) Get(_ context.Context, userID, notifType string) (*models.NotificationPreference, error) { + if userPrefs, ok := m.prefs[userID]; ok { + if p, ok := userPrefs[notifType]; ok { + return p, nil + } + } + return nil, nil +} + +func (m *mockPrefStore) ListForUser(_ context.Context, userID string) ([]models.NotificationPreference, error) { + var result []models.NotificationPreference + if userPrefs, ok := m.prefs[userID]; ok { + for _, p := range userPrefs { + result = append(result, *p) + } + } + return result, nil +} + +func (m *mockPrefStore) Upsert(_ context.Context, pref *models.NotificationPreference) error { + if m.prefs[pref.UserID] == nil { + m.prefs[pref.UserID] = make(map[string]*models.NotificationPreference) + } + m.prefs[pref.UserID][pref.Type] = pref + return nil +} + +func (m *mockPrefStore) Delete(_ context.Context, userID, notifType string) error { + if userPrefs, ok := m.prefs[userID]; ok { + delete(userPrefs, notifType) + } + return nil +} + +func (m *mockPrefStore) set(userID, notifType string, inApp, email bool) { + if m.prefs[userID] == nil { + m.prefs[userID] = make(map[string]*models.NotificationPreference) + } + m.prefs[userID][notifType] = &models.NotificationPreference{ + UserID: userID, + Type: notifType, + InApp: inApp, + Email: email, + } +} + +func TestResolvePrefs_SystemDefault(t *testing.T) { + svc := &Service{prefStore: newMockPrefStore()} + p := svc.resolvePrefs(context.Background(), "user1", "kb.ready") + if !p.InApp || p.Email { + t.Errorf("expected system default (in_app=true, email=false), got %+v", p) + } +} + +func TestResolvePrefs_NoPrefStore(t *testing.T) { + svc := &Service{prefStore: nil} + p := svc.resolvePrefs(context.Background(), "user1", "kb.ready") + if !p.InApp || p.Email { + t.Errorf("expected system default when no pref store, got %+v", p) + } +} + +func TestResolvePrefs_SpecificType(t *testing.T) { + ps := newMockPrefStore() + ps.set("user1", "kb.ready", true, true) + svc := &Service{prefStore: ps} + + p := svc.resolvePrefs(context.Background(), "user1", "kb.ready") + if !p.InApp || !p.Email { + t.Errorf("expected specific pref (in_app=true, email=true), got %+v", p) + } +} + +func TestResolvePrefs_WildcardFallback(t *testing.T) { + ps := newMockPrefStore() + ps.set("user1", "*", false, true) // user turned off in-app, turned on email globally + svc := &Service{prefStore: ps} + + // No specific "kb.ready" pref → falls to wildcard + p := svc.resolvePrefs(context.Background(), "user1", "kb.ready") + if p.InApp || !p.Email { + t.Errorf("expected wildcard pref (in_app=false, email=true), got %+v", p) + } +} + +func TestResolvePrefs_SpecificOverridesWildcard(t *testing.T) { + ps := newMockPrefStore() + ps.set("user1", "*", false, true) // wildcard: no in-app, yes email + ps.set("user1", "kb.ready", true, false) // specific: yes in-app, no email + svc := &Service{prefStore: ps} + + p := svc.resolvePrefs(context.Background(), "user1", "kb.ready") + if !p.InApp || p.Email { + t.Errorf("specific should override wildcard, got %+v", p) + } +} + +func TestResolvePrefs_DifferentUsersIsolated(t *testing.T) { + ps := newMockPrefStore() + ps.set("user1", "kb.ready", false, true) + svc := &Service{prefStore: ps} + + // user2 has no prefs → system default + p := svc.resolvePrefs(context.Background(), "user2", "kb.ready") + if !p.InApp || p.Email { + t.Errorf("user2 should get system default, got %+v", p) + } +} + +func TestResolvePrefs_DisableBoth(t *testing.T) { + ps := newMockPrefStore() + ps.set("user1", "kb.ready", false, false) + svc := &Service{prefStore: ps} + + p := svc.resolvePrefs(context.Background(), "user1", "kb.ready") + if p.InApp || p.Email { + t.Errorf("expected both disabled, got %+v", p) + } +} diff --git a/server/notifications/sources.go b/server/notifications/sources.go new file mode 100644 index 0000000..ea73202 --- /dev/null +++ b/server/notifications/sources.go @@ -0,0 +1,146 @@ +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Role Fallback ─────────────────────────── +// Subscribes to the existing "role.fallback" EventBus event (emitted by +// capabilities/resolver.go since v0.17.0) and creates notifications for +// all admin users. + +// roleFallbackPayload matches the payload emitted by the role resolver. +type roleFallbackPayload struct { + Role string `json:"role"` + Primary string `json:"primary_model"` + Fallback string `json:"fallback_model"` + Error string `json:"error"` +} + +// RoleFallbackHandler returns an events.Handler that creates admin +// notifications when a model role falls back. +func RoleFallbackHandler(svc *Service, stores store.Stores) events.Handler { + return func(e events.Event) { + var p roleFallbackPayload + if err := json.Unmarshal(e.Payload, &p); err != nil { + log.Printf("[notifications] failed to parse role.fallback payload: %v", err) + return + } + + title := fmt.Sprintf("Model fallback: %s → %s", p.Primary, p.Fallback) + if p.Primary == "" { + title = fmt.Sprintf("Role '%s' fell back to %s", p.Role, p.Fallback) + } + + body := "" + if p.Error != "" { + body = fmt.Sprintf("Primary model error: %s", p.Error) + } + + // Notify all admin users + ctx := context.Background() + admins, _, err := stores.Users.List(ctx, store.ListOptions{Limit: 500}) + if err != nil { + log.Printf("[notifications] failed to list users for role.fallback: %v", err) + return + } + + for _, u := range admins { + if u.Role != "admin" { + continue + } + n := models.Notification{ + UserID: u.ID, + Type: models.NotifTypeRoleFallback, + Title: title, + Body: body, + } + if err := svc.Notify(ctx, &n); err != nil { + log.Printf("[notifications] failed to create role.fallback notification for %s: %v", u.ID, err) + } + } + } +} + +// ── Knowledge Base ────────────────────────── +// Called directly from the KB handler after indexing completes or fails. +// Not a bus subscriber — the KB handler calls these functions. + +// NotifyKBReady creates a notification when a knowledge base finishes indexing. +func NotifyKBReady(svc *Service, userID, kbID, kbName string, chunkCount int) { + if svc == nil { + return + } + n := models.Notification{ + UserID: userID, + Type: models.NotifTypeKBReady, + Title: fmt.Sprintf("Knowledge base \"%s\" ready (%d chunks)", kbName, chunkCount), + ResourceType: models.ResourceTypeKnowledgeBase, + ResourceID: kbID, + } + if err := svc.Notify(context.Background(), &n); err != nil { + log.Printf("[notifications] kb.ready failed for user %s: %v", userID, err) + } +} + +// NotifyKBError creates a notification when KB indexing fails. +func NotifyKBError(svc *Service, userID, kbID, kbName, errMsg string) { + if svc == nil { + return + } + n := models.Notification{ + UserID: userID, + Type: models.NotifTypeKBError, + Title: fmt.Sprintf("Knowledge base \"%s\" indexing failed", kbName), + Body: errMsg, + ResourceType: models.ResourceTypeKnowledgeBase, + ResourceID: kbID, + } + if err := svc.Notify(context.Background(), &n); err != nil { + log.Printf("[notifications] kb.error failed for user %s: %v", userID, err) + } +} + +// ── Group Membership ──────────────────────── +// Called directly from the group handler on add/remove. + +// NotifyGroupMemberAdded creates a notification when a user is added to a group. +func NotifyGroupMemberAdded(svc *Service, userID, groupID, groupName string) { + if svc == nil { + return + } + n := models.Notification{ + UserID: userID, + Type: models.NotifTypeGrantChanged, + Title: fmt.Sprintf("You were added to group \"%s\"", groupName), + ResourceType: models.ResourceTypeGroup, + ResourceID: groupID, + } + if err := svc.Notify(context.Background(), &n); err != nil { + log.Printf("[notifications] grant.changed (add) failed for user %s: %v", userID, err) + } +} + +// NotifyGroupMemberRemoved creates a notification when a user is removed from a group. +func NotifyGroupMemberRemoved(svc *Service, userID, groupID, groupName string) { + if svc == nil { + return + } + n := models.Notification{ + UserID: userID, + Type: models.NotifTypeGrantChanged, + Title: fmt.Sprintf("You were removed from group \"%s\"", groupName), + ResourceType: models.ResourceTypeGroup, + ResourceID: groupID, + } + if err := svc.Notify(context.Background(), &n); err != nil { + log.Printf("[notifications] grant.changed (remove) failed for user %s: %v", userID, err) + } +} diff --git a/server/notifications/templates.go b/server/notifications/templates.go new file mode 100644 index 0000000..599ee07 --- /dev/null +++ b/server/notifications/templates.go @@ -0,0 +1,108 @@ +package notifications + +import ( + "bytes" + "html/template" + "strings" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +// ── Template Data ─────────────────────────── + +type emailData struct { + Title string + Body string + Type string + ResourceType string + InstanceName string +} + +// ── HTML Template ─────────────────────────── + +var htmlTmpl = template.Must(template.New("email").Parse(` + + + +
+

{{.InstanceName}}

+
+
+

{{.Title}}

+ {{if .Body}}

{{.Body}}

{{end}} +
+

+ This notification was sent by {{.InstanceName}}. You can adjust your notification preferences in Settings. +

+ +`)) + +// ── Plaintext Template ────────────────────── + +var textTmpl = template.Must(template.New("email_text").Parse(`{{.InstanceName}} — Notification + +{{.Title}} +{{if .Body}} +{{.Body}} +{{end}} +--- +Adjust your notification preferences in Settings. +`)) + +// ── Render Functions ──────────────────────── + +// RenderHTML renders an HTML email body for a notification. +func RenderHTML(n *models.Notification, instanceName string) string { + if instanceName == "" { + instanceName = "Chat Switchboard" + } + data := emailData{ + Title: n.Title, + Body: n.Body, + Type: n.Type, + ResourceType: n.ResourceType, + InstanceName: instanceName, + } + var buf bytes.Buffer + if err := htmlTmpl.Execute(&buf, data); err != nil { + return "

" + template.HTMLEscapeString(n.Title) + "

" + } + return buf.String() +} + +// RenderText renders a plaintext email body for a notification. +func RenderText(n *models.Notification, instanceName string) string { + if instanceName == "" { + instanceName = "Chat Switchboard" + } + data := emailData{ + Title: n.Title, + Body: n.Body, + Type: n.Type, + ResourceType: n.ResourceType, + InstanceName: instanceName, + } + var buf bytes.Buffer + if err := textTmpl.Execute(&buf, data); err != nil { + return n.Title + } + return buf.String() +} + +// SubjectForNotification returns an email subject line. +func SubjectForNotification(n *models.Notification, instanceName string) string { + if instanceName == "" { + instanceName = "Chat Switchboard" + } + prefix := "[" + instanceName + "] " + switch { + case strings.HasPrefix(n.Type, "kb."): + return prefix + "Knowledge Base: " + n.Title + case strings.HasPrefix(n.Type, "role."): + return prefix + "Model Alert: " + n.Title + case strings.HasPrefix(n.Type, "grant."): + return prefix + "Access Change: " + n.Title + default: + return prefix + n.Title + } +} diff --git a/server/notifications/templates_test.go b/server/notifications/templates_test.go new file mode 100644 index 0000000..f75849e --- /dev/null +++ b/server/notifications/templates_test.go @@ -0,0 +1,69 @@ +package notifications + +import ( + "strings" + "testing" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +func TestRenderHTML_Basic(t *testing.T) { + n := &models.Notification{ + Title: "KB Ready", + Body: "Your knowledge base has been processed.", + Type: "kb.ready", + } + html := RenderHTML(n, "TestInstance") + if !strings.Contains(html, "KB Ready") { + t.Error("expected title in HTML output") + } + if !strings.Contains(html, "TestInstance") { + t.Error("expected instance name in HTML output") + } + if !strings.Contains(html, "knowledge base") { + t.Error("expected body in HTML output") + } +} + +func TestRenderHTML_DefaultInstanceName(t *testing.T) { + n := &models.Notification{Title: "Test"} + html := RenderHTML(n, "") + if !strings.Contains(html, "Chat Switchboard") { + t.Error("expected default instance name") + } +} + +func TestRenderText_Basic(t *testing.T) { + n := &models.Notification{ + Title: "Model Fallback", + Body: "GPT-4 fell back to GPT-3.5.", + Type: "role.fallback", + } + text := RenderText(n, "MyInstance") + if !strings.Contains(text, "Model Fallback") { + t.Error("expected title in text output") + } + if !strings.Contains(text, "GPT-4 fell back") { + t.Error("expected body in text output") + } +} + +func TestSubjectForNotification(t *testing.T) { + tests := []struct { + notifType string + title string + want string + }{ + {"kb.ready", "KB Processed", "[Test] Knowledge Base: KB Processed"}, + {"role.fallback", "Fallback", "[Test] Model Alert: Fallback"}, + {"grant.changed", "Added to team", "[Test] Access Change: Added to team"}, + {"custom.type", "Hello", "[Test] Hello"}, + } + for _, tt := range tests { + n := &models.Notification{Type: tt.notifType, Title: tt.title} + got := SubjectForNotification(n, "Test") + if got != tt.want { + t.Errorf("SubjectForNotification(%q, %q) = %q, want %q", tt.notifType, tt.title, got, tt.want) + } + } +} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index e8ea2b3..8ebec77 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -40,6 +40,8 @@ type Stores struct { ResourceGrants ResourceGrantStore Memories MemoryStore Projects ProjectStore + Notifications NotificationStore + NotifPrefs NotificationPreferenceStore } // ========================================= @@ -216,6 +218,9 @@ type ChannelStore interface { // Channel models SetModel(ctx context.Context, cm *models.ChannelModel) error GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) + GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) + UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error + DeleteModel(ctx context.Context, id string) error // Ownership check UserOwns(ctx context.Context, channelID, userID string) (bool, error) @@ -476,6 +481,35 @@ type ResourceGrantStore interface { UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) } +// ========================================= +// NOTIFICATION STORE (v0.20.0) +// ========================================= + +type NotificationStore interface { + Create(ctx context.Context, n *models.Notification) error + ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) + MarkRead(ctx context.Context, id, userID string) error + MarkAllRead(ctx context.Context, userID string) error + Delete(ctx context.Context, id, userID string) error + UnreadCount(ctx context.Context, userID string) (int, error) + DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) +} + +// ========================================= +// NOTIFICATION PREFERENCES STORE (v0.20.0) +// ========================================= + +type NotificationPreferenceStore interface { + // Get returns the preference for a specific user + type. Returns nil if not set. + Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error) + // ListForUser returns all preferences set by a user. + ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error) + // Upsert creates or updates a preference row. + Upsert(ctx context.Context, pref *models.NotificationPreference) error + // Delete removes a preference (falls back to default). + Delete(ctx context.Context, userID, notifType string) error +} + // ========================================= // SHARED TYPES // ========================================= diff --git a/server/store/postgres/channel.go b/server/store/postgres/channel.go index 79b5b4c..597957f 100644 --- a/server/store/postgres/channel.go +++ b/server/store/postgres/channel.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "encoding/json" + "fmt" + "strings" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -212,6 +214,51 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model return result, rows.Err() } +func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) { + var cm models.ChannelModel + err := DB.QueryRowContext(ctx, ` + SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''), + COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default + FROM channel_models WHERE id = $1`, id).Scan( + &cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, + &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault) + if err != nil { + return nil, err + } + return &cm, nil +} + +func (s *ChannelStore) UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error { + if len(fields) == 0 { + return nil + } + // Build SET clause dynamically + sets := make([]string, 0, len(fields)) + args := make([]interface{}, 0, len(fields)+1) + i := 1 + for col, val := range fields { + sets = append(sets, col+" = $"+fmt.Sprintf("%d", i)) + args = append(args, val) + i++ + } + args = append(args, id) + query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = $" + fmt.Sprintf("%d", i) + _, err := DB.ExecContext(ctx, query, args...) + return err +} + +func (s *ChannelStore) DeleteModel(ctx context.Context, id string) error { + res, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE id = $1`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) { var exists bool err := DB.QueryRowContext(ctx, diff --git a/server/store/postgres/notification.go b/server/store/postgres/notification.go new file mode 100644 index 0000000..6daa55b --- /dev/null +++ b/server/store/postgres/notification.go @@ -0,0 +1,133 @@ +package postgres + +import ( + "context" + "database/sql" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +type NotificationStore struct{} + +func NewNotificationStore() *NotificationStore { return &NotificationStore{} } + +func (s *NotificationStore) Create(ctx context.Context, n *models.Notification) error { + return DB.QueryRowContext(ctx, ` + INSERT INTO notifications (user_id, type, title, body, resource_type, resource_id, is_read) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_at`, + n.UserID, n.Type, n.Title, n.Body, + nullStr(n.ResourceType), nullStr(n.ResourceID), n.IsRead, + ).Scan(&n.ID, &n.CreatedAt) +} + +func (s *NotificationStore) ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) { + if limit <= 0 { + limit = 20 + } + + // Count + countQ := `SELECT COUNT(*) FROM notifications WHERE user_id = $1` + args := []interface{}{userID} + if unreadOnly { + countQ += ` AND is_read = false` + } + + var total int + if err := DB.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil { + return nil, 0, err + } + + // Fetch + q := `SELECT id, user_id, type, title, body, resource_type, resource_id, is_read, created_at + FROM notifications WHERE user_id = $1` + if unreadOnly { + q += ` AND is_read = false` + } + q += ` ORDER BY created_at DESC LIMIT $2 OFFSET $3` + + rows, err := DB.QueryContext(ctx, q, userID, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var items []models.Notification + for rows.Next() { + var n models.Notification + var resType, resID sql.NullString + if err := rows.Scan( + &n.ID, &n.UserID, &n.Type, &n.Title, &n.Body, + &resType, &resID, &n.IsRead, &n.CreatedAt, + ); err != nil { + return nil, 0, err + } + n.ResourceType = NullableString(resType) + n.ResourceID = NullableString(resID) + items = append(items, n) + } + if items == nil { + items = []models.Notification{} + } + return items, total, rows.Err() +} + +func (s *NotificationStore) MarkRead(ctx context.Context, id, userID string) error { + res, err := DB.ExecContext(ctx, + `UPDATE notifications SET is_read = true WHERE id = $1 AND user_id = $2`, + id, userID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *NotificationStore) MarkAllRead(ctx context.Context, userID string) error { + _, err := DB.ExecContext(ctx, + `UPDATE notifications SET is_read = true WHERE user_id = $1 AND is_read = false`, + userID) + return err +} + +func (s *NotificationStore) Delete(ctx context.Context, id, userID string) error { + res, err := DB.ExecContext(ctx, + `DELETE FROM notifications WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *NotificationStore) UnreadCount(ctx context.Context, userID string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND is_read = false`, + userID).Scan(&count) + return count, err +} + +func (s *NotificationStore) DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM notifications WHERE created_at < $1`, before) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// nullStr returns sql.NullString for optional string fields. +func nullStr(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} diff --git a/server/store/postgres/notification_prefs.go b/server/store/postgres/notification_prefs.go new file mode 100644 index 0000000..5fae120 --- /dev/null +++ b/server/store/postgres/notification_prefs.go @@ -0,0 +1,69 @@ +package postgres + +import ( + "context" + "database/sql" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +type NotificationPreferenceStore struct{} + +func NewNotificationPreferenceStore() *NotificationPreferenceStore { + return &NotificationPreferenceStore{} +} + +func (s *NotificationPreferenceStore) Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error) { + var p models.NotificationPreference + err := DB.QueryRowContext(ctx, ` + SELECT id, user_id, type, in_app, email + FROM notification_preferences + WHERE user_id = $1 AND type = $2`, userID, notifType).Scan( + &p.ID, &p.UserID, &p.Type, &p.InApp, &p.Email) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (s *NotificationPreferenceStore) ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, user_id, type, in_app, email + FROM notification_preferences + WHERE user_id = $1 + ORDER BY type`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.NotificationPreference + for rows.Next() { + var p models.NotificationPreference + if err := rows.Scan(&p.ID, &p.UserID, &p.Type, &p.InApp, &p.Email); err != nil { + return nil, err + } + result = append(result, p) + } + return result, rows.Err() +} + +func (s *NotificationPreferenceStore) Upsert(ctx context.Context, pref *models.NotificationPreference) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO notification_preferences (user_id, type, in_app, email) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, type) DO UPDATE SET + in_app = $3, email = $4`, + pref.UserID, pref.Type, pref.InApp, pref.Email) + return err +} + +func (s *NotificationPreferenceStore) Delete(ctx context.Context, userID, notifType string) error { + _, err := DB.ExecContext(ctx, ` + DELETE FROM notification_preferences + WHERE user_id = $1 AND type = $2`, userID, notifType) + return err +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index f528aea..2154044 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -11,27 +11,29 @@ import ( func NewStores(db *sql.DB) store.Stores { SetDB(db) return store.Stores{ - Providers: NewProviderStore(), - Catalog: NewCatalogStore(), - Personas: NewPersonaStore(), - Policies: NewPolicyStore(), - UserSettings: NewUserModelSettingsStore(), - Users: NewUserStore(), - Teams: NewTeamStore(), - Channels: NewChannelStore(), - Messages: NewMessageStore(), - Audit: NewAuditStore(), - Notes: NewNoteStore(), - NoteLinks: NewNoteLinkStore(), - GlobalConfig: NewGlobalConfigStore(), - Usage: NewUsageStore(), - Pricing: NewPricingStore(), - Extensions: NewExtensionStore(), - Attachments: NewAttachmentStore(), + Providers: NewProviderStore(), + Catalog: NewCatalogStore(), + Personas: NewPersonaStore(), + Policies: NewPolicyStore(), + UserSettings: NewUserModelSettingsStore(), + Users: NewUserStore(), + Teams: NewTeamStore(), + Channels: NewChannelStore(), + Messages: NewMessageStore(), + Audit: NewAuditStore(), + Notes: NewNoteStore(), + NoteLinks: NewNoteLinkStore(), + GlobalConfig: NewGlobalConfigStore(), + Usage: NewUsageStore(), + Pricing: NewPricingStore(), + Extensions: NewExtensionStore(), + Attachments: NewAttachmentStore(), KnowledgeBases: NewKnowledgeBaseStore(), Groups: NewGroupStore(), ResourceGrants: NewResourceGrantStore(), Memories: NewMemoryStore(), Projects: NewProjectStore(), + Notifications: NewNotificationStore(), + NotifPrefs: NewNotificationPreferenceStore(), } } diff --git a/server/store/postgres/user_settings.go b/server/store/postgres/user_settings.go index 20ee71e..1457350 100644 --- a/server/store/postgres/user_settings.go +++ b/server/store/postgres/user_settings.go @@ -14,8 +14,8 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) { rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, - sort_order, created_at, updated_at + SELECT id, user_id, model_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens, + COALESCE(sort_order, 0), created_at, updated_at FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID) if err != nil { return nil, err @@ -91,7 +91,7 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string // Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT _, err := DB.ExecContext(ctx, ` INSERT INTO user_model_settings (user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order) - VALUES ($1, $2, $3, $4, $5, $6) + VALUES ($1, $2, COALESCE($3, false), $4, $5, COALESCE($6, 0)) ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = COALESCE($3, user_model_settings.hidden), diff --git a/server/store/sqlite/channel.go b/server/store/sqlite/channel.go index 6c671e2..0cdae04 100644 --- a/server/store/sqlite/channel.go +++ b/server/store/sqlite/channel.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -218,6 +219,48 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model return result, rows.Err() } +func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) { + var cm models.ChannelModel + err := DB.QueryRowContext(ctx, ` + SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''), + COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default + FROM channel_models WHERE id = ?`, id).Scan( + &cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID, + &cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault) + if err != nil { + return nil, err + } + return &cm, nil +} + +func (s *ChannelStore) UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error { + if len(fields) == 0 { + return nil + } + sets := make([]string, 0, len(fields)) + args := make([]interface{}, 0, len(fields)+1) + for col, val := range fields { + sets = append(sets, col+" = ?") + args = append(args, val) + } + args = append(args, id) + query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = ?" + _, err := DB.ExecContext(ctx, query, args...) + return err +} + +func (s *ChannelStore) DeleteModel(ctx context.Context, id string) error { + res, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE id = ?`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) { var exists bool err := DB.QueryRowContext(ctx, diff --git a/server/store/sqlite/notification.go b/server/store/sqlite/notification.go new file mode 100644 index 0000000..c5d56d6 --- /dev/null +++ b/server/store/sqlite/notification.go @@ -0,0 +1,146 @@ +package sqlite + +import ( + "context" + "database/sql" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +type NotificationStore struct{} + +func NewNotificationStore() *NotificationStore { return &NotificationStore{} } + +func (s *NotificationStore) Create(ctx context.Context, n *models.Notification) error { + if n.ID == "" { + n.ID = store.NewID() + } + now := time.Now().UTC().Format(timeFmt) + + _, err := DB.ExecContext(ctx, ` + INSERT INTO notifications (id, user_id, type, title, body, resource_type, resource_id, is_read, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + n.ID, n.UserID, n.Type, n.Title, n.Body, + nullStr(n.ResourceType), nullStr(n.ResourceID), boolToInt(n.IsRead), now, + ) + if err != nil { + return err + } + n.CreatedAt, _ = time.Parse(timeFmt, now) + return nil +} + +func (s *NotificationStore) ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) { + if limit <= 0 { + limit = 20 + } + + // Count + countQ := `SELECT COUNT(*) FROM notifications WHERE user_id = ?` + countArgs := []interface{}{userID} + if unreadOnly { + countQ += ` AND is_read = 0` + } + + var total int + if err := DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total); err != nil { + return nil, 0, err + } + + // Fetch + q := `SELECT id, user_id, type, title, body, resource_type, resource_id, is_read, created_at + FROM notifications WHERE user_id = ?` + if unreadOnly { + q += ` AND is_read = 0` + } + q += ` ORDER BY created_at DESC LIMIT ? OFFSET ?` + + rows, err := DB.QueryContext(ctx, q, userID, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var items []models.Notification + for rows.Next() { + var n models.Notification + var resType, resID sql.NullString + var isRead int + if err := rows.Scan( + &n.ID, &n.UserID, &n.Type, &n.Title, &n.Body, + &resType, &resID, &isRead, st(&n.CreatedAt), + ); err != nil { + return nil, 0, err + } + n.ResourceType = NullableString(resType) + n.ResourceID = NullableString(resID) + n.IsRead = isRead != 0 + items = append(items, n) + } + if items == nil { + items = []models.Notification{} + } + return items, total, rows.Err() +} + +func (s *NotificationStore) MarkRead(ctx context.Context, id, userID string) error { + res, err := DB.ExecContext(ctx, + `UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?`, + id, userID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *NotificationStore) MarkAllRead(ctx context.Context, userID string) error { + _, err := DB.ExecContext(ctx, + `UPDATE notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0`, + userID) + return err +} + +func (s *NotificationStore) Delete(ctx context.Context, id, userID string) error { + res, err := DB.ExecContext(ctx, + `DELETE FROM notifications WHERE id = ? AND user_id = ?`, id, userID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *NotificationStore) UnreadCount(ctx context.Context, userID string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0`, + userID).Scan(&count) + return count, err +} + +func (s *NotificationStore) DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM notifications WHERE created_at < ?`, + before.UTC().Format(timeFmt)) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// nullStr returns sql.NullString for optional string fields. +func nullStr(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} diff --git a/server/store/sqlite/notification_prefs.go b/server/store/sqlite/notification_prefs.go new file mode 100644 index 0000000..b139309 --- /dev/null +++ b/server/store/sqlite/notification_prefs.go @@ -0,0 +1,70 @@ +package sqlite + +import ( + "context" + "database/sql" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +type NotificationPreferenceStore struct{} + +func NewNotificationPreferenceStore() *NotificationPreferenceStore { + return &NotificationPreferenceStore{} +} + +func (s *NotificationPreferenceStore) Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error) { + var p models.NotificationPreference + err := DB.QueryRowContext(ctx, ` + SELECT id, user_id, type, in_app, email + FROM notification_preferences + WHERE user_id = ? AND type = ?`, userID, notifType).Scan( + &p.ID, &p.UserID, &p.Type, &p.InApp, &p.Email) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (s *NotificationPreferenceStore) ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, user_id, type, in_app, email + FROM notification_preferences + WHERE user_id = ? + ORDER BY type`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.NotificationPreference + for rows.Next() { + var p models.NotificationPreference + if err := rows.Scan(&p.ID, &p.UserID, &p.Type, &p.InApp, &p.Email); err != nil { + return nil, err + } + result = append(result, p) + } + return result, rows.Err() +} + +func (s *NotificationPreferenceStore) Upsert(ctx context.Context, pref *models.NotificationPreference) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO notification_preferences (id, user_id, type, in_app, email) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (user_id, type) DO UPDATE SET + in_app = excluded.in_app, email = excluded.email`, + store.NewID(), pref.UserID, pref.Type, pref.InApp, pref.Email) + return err +} + +func (s *NotificationPreferenceStore) Delete(ctx context.Context, userID, notifType string) error { + _, err := DB.ExecContext(ctx, ` + DELETE FROM notification_preferences + WHERE user_id = ? AND type = ?`, userID, notifType) + return err +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 678ebe9..037c87c 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -33,5 +33,7 @@ func NewStores(db *sql.DB) store.Stores { ResourceGrants: NewResourceGrantStore(), Memories: NewMemoryStore(), Projects: NewProjectStore(), + Notifications: NewNotificationStore(), + NotifPrefs: NewNotificationPreferenceStore(), } } diff --git a/server/store/sqlite/user_settings.go b/server/store/sqlite/user_settings.go index 6baa7f8..6bfad12 100644 --- a/server/store/sqlite/user_settings.go +++ b/server/store/sqlite/user_settings.go @@ -15,8 +15,8 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) { rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, - sort_order, created_at, updated_at + SELECT id, user_id, model_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens, + COALESCE(sort_order, 0), created_at, updated_at FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID) if err != nil { return nil, err @@ -92,7 +92,7 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string // Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT _, err := DB.ExecContext(ctx, ` INSERT INTO user_model_settings (id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0)) ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = COALESCE(excluded.hidden, user_model_settings.hidden), diff --git a/src/css/channel-models.css b/src/css/channel-models.css new file mode 100644 index 0000000..64e98fa --- /dev/null +++ b/src/css/channel-models.css @@ -0,0 +1,210 @@ +/* ========================================== + Channel Models — v0.20.0 Phase 2 + Model pills, @mention autocomplete, add dialog + ========================================== */ + +/* ── Model Pills (chat header) ───────────── */ + +.ch-model-pills { + display: none; /* shown by JS when roster > 1 */ + align-items: center; + gap: 4px; + padding: 0 8px; + flex-shrink: 1; + overflow-x: auto; + scrollbar-width: none; +} +.ch-model-pills::-webkit-scrollbar { display: none; } + +.ch-model-pill { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px 8px; + border-radius: 12px; + background: var(--bg-secondary, #f0f0f0); + border: 1px solid var(--border-color, #ddd); + font-size: 0.75rem; + white-space: nowrap; + cursor: default; + transition: background 0.15s; +} +.ch-model-pill.ch-model-default { + background: var(--accent-bg, #e8f0fe); + border-color: var(--accent-color, #4a8af4); +} +.ch-model-pill-name { + cursor: pointer; + user-select: none; +} +.ch-model-pill-name:hover { + text-decoration: underline; +} +.ch-model-pill-remove { + background: none; + border: none; + cursor: pointer; + font-size: 0.65rem; + color: var(--text-secondary, #888); + padding: 0 2px; + line-height: 1; + opacity: 0.6; + transition: opacity 0.15s; +} +.ch-model-pill-remove:hover { + opacity: 1; + color: var(--error-color, #d32f2f); +} + +.ch-model-add-btn { + background: none; + border: 1px dashed var(--border-color, #ccc); + border-radius: 12px; + padding: 2px 8px; + font-size: 0.75rem; + cursor: pointer; + color: var(--text-secondary, #888); + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} +.ch-model-add-btn:hover { + color: var(--text-primary, #333); + border-color: var(--text-primary, #333); +} + +/* ── Add Model Dialog ────────────────────── */ + +.ch-model-add-dialog { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.4); + z-index: 1100; +} +.ch-model-add-inner { + background: var(--bg-primary, #fff); + border-radius: 12px; + padding: 24px; + min-width: 320px; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0,0,0,0.2); +} +.ch-model-add-inner h3 { + margin: 0 0 16px; + font-size: 1rem; +} +.ch-model-add-inner label { + display: block; + margin-bottom: 12px; + font-size: 0.85rem; + color: var(--text-secondary, #666); +} +.ch-model-add-inner select, +.ch-model-add-inner input[type="text"] { + display: block; + width: 100%; + margin-top: 4px; + padding: 8px; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.9rem; + background: var(--bg-primary, #fff); + color: var(--text-primary, #333); + box-sizing: border-box; +} +.ch-model-add-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; +} + +/* ── @mention Autocomplete ───────────────── */ + +.mention-ac { + position: fixed; + z-index: 1050; + background: var(--bg-primary, #fff); + border: 1px solid var(--border-color, #ddd); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + max-height: 200px; + overflow-y: auto; + padding: 4px 0; +} +.mention-ac-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + gap: 12px; + transition: background 0.1s; +} +.mention-ac-item:hover, +.mention-ac-item.mention-ac-active { + background: var(--bg-secondary, #f5f5f5); +} +.mention-ac-name { + font-weight: 500; + font-size: 0.85rem; +} +.mention-ac-model { + font-size: 0.75rem; + color: var(--text-secondary, #999); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; +} + +/* ── Model Attribution (multi-model messages) ─ */ + +.msg-model-label { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + background: var(--bg-secondary, #f0f0f0); + color: var(--text-secondary, #666); + margin-left: 6px; + vertical-align: middle; +} + +/* Model separator in multi-model stream */ +.model-stream-divider { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 0 4px; + font-size: 0.8rem; + color: var(--text-secondary, #888); + font-weight: 500; +} +.model-stream-divider::before, +.model-stream-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-color, #eee); +} + +/* ── Mobile ──────────────────────────────── */ + +@media (max-width: 768px) { + .ch-model-pills { + max-width: 50vw; + } + .ch-model-add-inner { + min-width: auto; + margin: 0 16px; + } + .mention-ac { + left: 8px !important; + right: 8px; + min-width: auto !important; + } +} diff --git a/src/css/notification-prefs.css b/src/css/notification-prefs.css new file mode 100644 index 0000000..e5a47c6 --- /dev/null +++ b/src/css/notification-prefs.css @@ -0,0 +1,64 @@ +/* ========================================== + Notification Preferences — v0.20.0 Phase 3 + ========================================== */ + +.notif-pref-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} +.notif-pref-table th { + text-align: left; + padding: 6px 8px; + border-bottom: 2px solid var(--border-color, #ddd); + font-size: 0.8rem; + color: var(--text-secondary, #888); + font-weight: 500; +} +.notif-pref-table th:nth-child(2), +.notif-pref-table th:nth-child(3) { + text-align: center; + width: 70px; +} +.notif-pref-table td { + padding: 8px; + border-bottom: 1px solid var(--border-color, #eee); +} +.notif-pref-toggle { + text-align: center; +} +.notif-pref-label { + font-size: 0.85rem; +} + +/* ── SMTP Config (Admin panel) ────────────── */ + +.smtp-config-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.smtp-config-grid .form-group { + margin-bottom: 0; +} +.smtp-config-grid .form-group.full-width { + grid-column: 1 / -1; +} +.smtp-test-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; +} +.smtp-test-status { + font-size: 0.85rem; + color: var(--text-secondary, #888); +} +.smtp-test-status.success { color: var(--success-color, #2e7d32); } +.smtp-test-status.error { color: var(--error-color, #d32f2f); } + +@media (max-width: 600px) { + .smtp-config-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/css/notifications.css b/src/css/notifications.css new file mode 100644 index 0000000..573e27b --- /dev/null +++ b/src/css/notifications.css @@ -0,0 +1,249 @@ +/* ========================================== + Notifications (v0.20.0) + ========================================== */ + +/* ── Bell + Badge ──────────────────────────── */ + +.notif-wrap { + position: relative; + display: flex; + align-items: center; + margin-left: auto; + margin-right: 8px; +} + +.notif-bell { + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; + color: var(--text-secondary, #888); + display: flex; + align-items: center; + position: relative; + transition: background 0.15s; +} + +.notif-bell:hover { + background: var(--hover-bg, rgba(128, 128, 128, 0.1)); + color: var(--text-primary, #ddd); +} + +.notif-badge { + position: absolute; + top: 0; + right: 0; + min-width: 16px; + height: 16px; + line-height: 16px; + font-size: 10px; + font-weight: 700; + text-align: center; + border-radius: 8px; + background: var(--accent-red, #e55); + color: #fff; + padding: 0 4px; + pointer-events: none; +} + +/* ── Dropdown ──────────────────────────────── */ + +.notif-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + width: 360px; + max-height: 480px; + background: var(--bg-surface, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + z-index: 1000; + overflow: hidden; +} + +.notif-dd-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color, #333); + font-weight: 600; + font-size: 13px; +} + +.notif-mark-all { + background: none; + border: none; + color: var(--accent, #5b9); + cursor: pointer; + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; +} + +.notif-mark-all:hover { + background: var(--hover-bg, rgba(128, 128, 128, 0.1)); +} + +.notif-dd-list { + overflow-y: auto; + max-height: 380px; +} + +.notif-dd-footer { + padding: 8px 14px; + border-top: 1px solid var(--border-color, #333); + text-align: center; +} + +.notif-view-all { + background: none; + border: none; + color: var(--accent, #5b9); + cursor: pointer; + font-size: 12px; +} + +.notif-view-all:hover { + text-decoration: underline; +} + +/* ── Notification Item ─────────────────────── */ + +.notif-item, +.notif-panel-item { + display: flex; + align-items: flex-start; + padding: 10px 14px; + cursor: pointer; + transition: background 0.15s; + border-bottom: 1px solid var(--border-color, #222); +} + +.notif-item:hover, +.notif-panel-item:hover { + background: var(--hover-bg, rgba(128, 128, 128, 0.08)); +} + +.notif-item.notif-unread, +.notif-panel-item.notif-unread { + background: var(--notif-unread-bg, rgba(91, 187, 153, 0.06)); +} + +.notif-dot { + flex-shrink: 0; + width: 18px; + font-size: 10px; + margin-top: 3px; + color: var(--accent, #5b9); +} + +.notif-item:not(.notif-unread) .notif-dot, +.notif-panel-item:not(.notif-unread) .notif-dot { + color: var(--text-tertiary, #555); +} + +.notif-body { + flex: 1; + min-width: 0; +} + +.notif-title { + font-size: 13px; + line-height: 1.4; + color: var(--text-primary, #ddd); +} + +.notif-detail { + font-size: 12px; + color: var(--text-secondary, #888); + margin-top: 2px; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notif-time { + font-size: 11px; + color: var(--text-tertiary, #666); + margin-top: 3px; +} + +.notif-empty { + padding: 32px 14px; + text-align: center; + color: var(--text-secondary, #888); + font-size: 13px; +} + +/* ── Panel ─────────────────────────────────── */ + +.notif-panel { + height: 100%; + display: flex; + flex-direction: column; +} + +.notif-panel-toolbar { + display: flex; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #333); +} + +.notif-panel-list { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.notif-panel-row { + display: flex; + align-items: flex-start; + width: 100%; +} + +.notif-delete { + flex-shrink: 0; + background: none; + border: none; + color: var(--text-tertiary, #555); + cursor: pointer; + padding: 2px 6px; + font-size: 12px; + border-radius: 4px; + opacity: 0; + transition: opacity 0.15s; +} + +.notif-panel-item:hover .notif-delete { + opacity: 1; +} + +.notif-delete:hover { + color: var(--accent-red, #e55); + background: var(--hover-bg, rgba(128, 128, 128, 0.1)); +} + +.notif-panel-more { + padding: 8px 12px; + text-align: center; + border-top: 1px solid var(--border-color, #333); +} + +/* ── Mobile ────────────────────────────────── */ + +@media (max-width: 768px) { + .notif-dropdown { + position: fixed; + top: 48px; + left: 8px; + right: 8px; + width: auto; + max-height: calc(100vh - 96px); + } +} diff --git a/src/index.html b/src/index.html index 65aeeff..81909d1 100644 --- a/src/index.html +++ b/src/index.html @@ -21,6 +21,9 @@ + + + @@ -136,7 +139,15 @@
+
+
+ +
+
@@ -394,6 +405,7 @@ +
+ + @@ -935,6 +955,30 @@

🔐 Encryption

Loading…
+
+

Email Notifications (SMTP)

+ + +
@@ -1131,6 +1175,9 @@ + + + diff --git a/src/js/api.js b/src/js/api.js index 92da289..2e312a8 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -161,6 +161,18 @@ const API = { generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); }, deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); }, + // Channel models (v0.20.0 — multi-model @mention routing) + listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); }, + addChannelModel(channelId, data) { return this._post(`/api/v1/channels/${channelId}/models`, data); }, + updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); }, + deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); }, + + // Notification preferences (v0.20.0 Phase 3) + listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); }, + setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); }, + deleteNotifPref(type) { return this._del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`); }, + adminTestEmail() { return this._post('/api/v1/admin/notifications/test-email', {}); }, + // Projects (v0.19.0) listProjects(includeArchived) { const q = includeArchived ? '?include_archived=true' : ''; @@ -748,6 +760,7 @@ const API = { async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); }, async _put(path, body) { return this._authed(path, 'PUT', body); }, async _del(path) { return this._authed(path, 'DELETE'); }, + async _patch(path, body) { return this._authed(path, 'PATCH', body); }, async _authed(path, method = 'GET', body) { try { diff --git a/src/js/app.js b/src/js/app.js index dd352c8..8dc91a3 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -431,6 +431,7 @@ function initListeners() { _registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry _registerNotesPanel(); // from notes.js — register notes with PanelRegistry _registerProjectPanel(); // from projects-ui.js — register project detail panel + if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0 _initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize _initSidePanelResize(); // from panels.js _initDualDivider(); // from panels.js — dual-view split drag diff --git a/src/js/channel-models.js b/src/js/channel-models.js new file mode 100644 index 0000000..66ca75c --- /dev/null +++ b/src/js/channel-models.js @@ -0,0 +1,340 @@ +// ========================================== +// Chat Switchboard – Channel Models (v0.20.0) +// ========================================== +// Multi-model management per channel. +// - Model pills in chat header (add/remove/set default) +// - @mention autocomplete in chat input +// - Model attribution on assistant messages +// ========================================== + +const ChannelModels = { + + _roster: [], // current channel's model roster + _channelId: null, // current channel ID + _acVisible: false, // autocomplete dropdown visible + + // ── Init / Lifecycle ──────────────────── + + init() { + // Close autocomplete on click-outside + document.addEventListener('click', (e) => { + if (!e.target.closest('.mention-ac')) { + this.hideAutocomplete(); + } + }); + }, + + // ── Roster Management ─────────────────── + + async load(channelId) { + this._channelId = channelId; + if (!channelId) { + this._roster = []; + this.renderPills(); + return; + } + try { + const roster = await API.listChannelModels(channelId); + this._roster = Array.isArray(roster) ? roster : []; + } catch (e) { + console.debug('Channel models not loaded:', e.message); + this._roster = []; + } + this.renderPills(); + }, + + getRoster() { return this._roster; }, + + getDefault() { + return this._roster.find(m => m.is_default) || this._roster[0] || null; + }, + + // ── Model Pills UI ────────────────────── + + renderPills() { + const container = document.getElementById('channelModelPills'); + if (!container) return; + + if (this._roster.length <= 1) { + container.style.display = 'none'; + container.innerHTML = ''; + return; + } + + container.style.display = ''; + const html = this._roster.map(m => ` + + ${esc(m.display_name)} + + + `).join('') + ` + + `; + container.innerHTML = html; + }, + + async remove(recordId) { + if (!this._channelId) return; + try { + const resp = await API.deleteChannelModel(this._channelId, recordId); + this._roster = resp.models || []; + this.renderPills(); + } catch (e) { + UI.toast('Failed to remove model: ' + e.message, 'error'); + } + }, + + async setDefault(recordId) { + if (!this._channelId) return; + try { + const resp = await API.updateChannelModel(this._channelId, recordId, { is_default: true }); + this._roster = resp.models || []; + this.renderPills(); + } catch (e) { + UI.toast('Failed to set default: ' + e.message, 'error'); + } + }, + + // ── Add Model Dialog ──────────────────── + + showAddDialog() { + if (!App.models || App.models.length === 0) { + UI.toast('No models available — configure a provider first', 'warning'); + return; + } + + // Filter out models already in the roster + const rosterModelIds = new Set(this._roster.map(m => m.model_id)); + const available = App.models.filter(m => !m.isPreset && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id)); + + if (available.length === 0) { + UI.toast('All available models are already added to this channel', 'info'); + return; + } + + const html = ` +
+
+

Add Model to Channel

+ + +
+ + +
+
+
`; + + document.body.insertAdjacentHTML('beforeend', html); + + // Auto-fill display name from selected model + const select = document.getElementById('chModelAddSelect'); + const nameInput = document.getElementById('chModelAddName'); + nameInput.value = _shortName(select.options[0]?.dataset.name || ''); + select.addEventListener('change', () => { + nameInput.value = _shortName(select.options[select.selectedIndex]?.dataset.name || ''); + }); + + // Focus and select + nameInput.focus(); + nameInput.select(); + }, + + closeAddDialog() { + document.getElementById('chModelAddDialog')?.remove(); + }, + + async submitAdd() { + const select = document.getElementById('chModelAddSelect'); + const nameInput = document.getElementById('chModelAddName'); + if (!select || !nameInput) return; + + const modelId = select.value; + const configId = select.options[select.selectedIndex]?.dataset.config || ''; + const displayName = nameInput.value.trim(); + + if (!displayName) { + UI.toast('Display name is required', 'warning'); + return; + } + + try { + const resp = await API.addChannelModel(this._channelId, { + model_id: modelId, + provider_config_id: configId, + display_name: displayName, + is_default: this._roster.length === 0 + }); + this._roster = resp.models || []; + this.renderPills(); + this.closeAddDialog(); + } catch (e) { + UI.toast('Failed to add model: ' + e.message, 'error'); + } + }, + + // ── @mention Autocomplete ─────────────── + + /** + * Called from the chat input's keyup/input handler. + * Shows a dropdown of channel models when user types @. + */ + onInput(inputEl) { + if (this._roster.length < 2) return; // no autocomplete for single-model channels + + const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value; + const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart; + + // Find the @token being typed (search backward from cursor) + const before = text.slice(0, cursorPos); + const atIdx = before.lastIndexOf('@'); + if (atIdx < 0) { this.hideAutocomplete(); return; } + + // @ must be at start or preceded by whitespace + if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') { + this.hideAutocomplete(); + return; + } + + const partial = before.slice(atIdx + 1).toLowerCase(); + const matches = this._roster.filter(m => + m.display_name.toLowerCase().startsWith(partial) || + m.display_name.toLowerCase().replace(/-/g, ' ').startsWith(partial.replace(/-/g, ' ')) + ); + + if (matches.length === 0) { this.hideAutocomplete(); return; } + + this.showAutocomplete(inputEl, atIdx, cursorPos, matches); + }, + + showAutocomplete(inputEl, atIdx, cursorPos, matches) { + this.hideAutocomplete(); + + const wrap = document.createElement('div'); + wrap.className = 'mention-ac'; + wrap.id = 'mentionAc'; + + // Position relative to input + const inputRect = (inputEl.el || inputEl).getBoundingClientRect(); + wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px'; + wrap.style.left = inputRect.left + 'px'; + wrap.style.minWidth = '200px'; + + matches.forEach((m, i) => { + const item = document.createElement('div'); + item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : ''); + item.dataset.name = m.display_name; + item.innerHTML = `${esc(m.display_name)}${esc(m.model_id)}`; + item.addEventListener('click', () => { + this._insertMention(inputEl, atIdx, cursorPos, m.display_name); + }); + wrap.appendChild(item); + }); + + document.body.appendChild(wrap); + this._acVisible = true; + }, + + hideAutocomplete() { + document.getElementById('mentionAc')?.remove(); + this._acVisible = false; + }, + + isAutocompleteVisible() { return this._acVisible; }, + + /** + * Handle arrow keys and Enter when autocomplete is visible. + * Returns true if the event was consumed. + */ + handleKey(e) { + if (!this._acVisible) return false; + const ac = document.getElementById('mentionAc'); + if (!ac) return false; + + const items = ac.querySelectorAll('.mention-ac-item'); + let activeIdx = Array.from(items).findIndex(el => el.classList.contains('mention-ac-active')); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + items[activeIdx]?.classList.remove('mention-ac-active'); + activeIdx = (activeIdx + 1) % items.length; + items[activeIdx]?.classList.add('mention-ac-active'); + return true; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + items[activeIdx]?.classList.remove('mention-ac-active'); + activeIdx = (activeIdx - 1 + items.length) % items.length; + items[activeIdx]?.classList.add('mention-ac-active'); + return true; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const active = items[activeIdx]; + if (active) { + active.click(); + } + return true; + } + if (e.key === 'Escape') { + this.hideAutocomplete(); + return true; + } + return false; + }, + + _insertMention(inputEl, atIdx, cursorPos, displayName) { + const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value; + const before = text.slice(0, atIdx); + const after = text.slice(cursorPos); + const newText = before + '@' + displayName + ' ' + after; + + if (typeof inputEl.setValue === 'function') { + inputEl.setValue(newText); + } else { + inputEl.value = newText; + } + + // Position cursor after the inserted mention + const newPos = atIdx + 1 + displayName.length + 1; + if (typeof inputEl.setCursorPos === 'function') { + inputEl.setCursorPos(newPos); + } else { + inputEl.selectionStart = inputEl.selectionEnd = newPos; + } + + this.hideAutocomplete(); + inputEl.focus?.(); + }, + + // ── Model Display Name Resolution ─────── + + /** + * Resolve a model_id to a display name using the current roster. + * Falls back to App.models lookup, then the raw model_id. + */ + resolveDisplayName(modelId) { + if (!modelId) return 'Assistant'; + const cm = this._roster.find(m => m.model_id === modelId); + if (cm?.display_name) return cm.display_name; + const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId); + return m?.name || modelId; + } +}; + +// ── Helpers ────────────────────────────────── + +function _shortName(fullName) { + // "Claude 3 Opus (Anthropic)" → "Claude-3-Opus" + return fullName + .replace(/\s*\([^)]*\)\s*$/, '') // strip "(provider)" + .trim() + .replace(/\s+/g, '-'); // spaces → hyphens +} diff --git a/src/js/chat.js b/src/js/chat.js index 2786b66..84aaf3a 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -37,12 +37,37 @@ const ChatInput = { return this._textarea; }, + /** Get the visible input element (for positioning, getBoundingClientRect) */ + get el() { + if (this._editor) return this._editor.getView().dom; + return this._textarea; + }, + /** Get the wrapper DOM element (for resize, etc.) */ getWrapDom() { if (this._editor) return this._editor.getView().dom; return this._textarea; }, + /** Get cursor byte offset (for @mention autocomplete) */ + getCursorPos() { + if (this._editor) { + const view = this._editor.getView(); + return view.state.selection.main.head; + } + return this._textarea?.selectionStart || 0; + }, + + /** Set cursor byte offset (for @mention autocomplete) */ + setCursorPos(pos) { + if (this._editor) { + const view = this._editor.getView(); + view.dispatch({ selection: { anchor: pos } }); + } else if (this._textarea) { + this._textarea.selectionStart = this._textarea.selectionEnd = pos; + } + }, + /** Initialize — try CM6, fall back to textarea */ init() { this._textarea = document.getElementById('messageInput'); @@ -54,20 +79,35 @@ const ChatInput = { this._editor = CM.chatInput(wrap, { placeholder: 'Send a message...', onSubmit: () => sendMessage(), - onChange: () => updateInputTokens(), + onChange: () => { + updateInputTokens(); + // @mention autocomplete (v0.20.0) + if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput); + }, maxHeight: 200, }); + // @mention autocomplete: intercept keys on CM6 contentDOM (v0.20.0) + this._editor.getView().contentDOM.addEventListener('keydown', (e) => { + if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) { + e.preventDefault(); + e.stopPropagation(); + } + }); DebugLog?.push?.('chat', '[CM6] Chat input initialized'); } else { // Fallback: textarea with manual event handling DebugLog?.push?.('chat', '[CM6] Not available — using textarea fallback'); this._textarea.addEventListener('keydown', (e) => { + // @mention autocomplete intercepts arrow/enter/escape (v0.20.0) + if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); this._textarea.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 200) + 'px'; updateInputTokens(); + // @mention autocomplete (v0.20.0) + if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this); }); } }, @@ -202,6 +242,9 @@ async function selectChat(chatId) { // Restore the last-used model/preset for this chat _restoreChatModel(chatId, chat); + // Load multi-model roster for @mention routing (v0.20.0) + if (typeof ChannelModels !== 'undefined') ChannelModels.load(chatId); + // Load attachments for message rendering (non-blocking, best-effort) await loadChannelAttachments(chatId); @@ -827,6 +870,9 @@ function _initChatListeners() { // Input — CM6 or textarea fallback ChatInput.init(); + // Multi-model channel roster (v0.20.0) + if (typeof ChannelModels !== 'undefined') ChannelModels.init(); + // Close modals on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', (e) => { diff --git a/src/js/notification-prefs.js b/src/js/notification-prefs.js new file mode 100644 index 0000000..e40bc7f --- /dev/null +++ b/src/js/notification-prefs.js @@ -0,0 +1,163 @@ +/* ───────────────────────────────────────────── + Notification Preferences (v0.20.0 Phase 3) + Settings → Notifications tab + ───────────────────────────────────────────── */ + +// eslint-disable-next-line no-unused-vars +const NotifPrefs = { + _loaded: false, + _prefs: [], + + // Known notification types for the UI + _types: [ + { key: 'kb.ready', label: 'Knowledge base ready' }, + { key: 'kb.error', label: 'Knowledge base error' }, + { key: 'role.fallback', label: 'Model fallback' }, + { key: 'grant.changed', label: 'Group membership change' }, + { key: '*', label: 'Default (all others)' }, + ], + + async load() { + try { + this._prefs = await API.listNotifPrefs() || []; + } catch (e) { + console.warn('[notif-prefs] failed to load:', e.message); + this._prefs = []; + } + this._loaded = true; + this.render(); + }, + + render() { + const container = document.getElementById('notifPrefsBody'); + if (!container) return; + + const prefMap = {}; + for (const p of this._prefs) { + prefMap[p.type] = p; + } + + let html = ''; + for (const t of this._types) { + const p = prefMap[t.key]; + const inApp = p ? p.in_app : true; + const email = p ? p.email : false; + + html += ` + ${esc(t.label)} + + + + + + + `; + } + + container.innerHTML = ` + + + + + + ${html} +
Notification TypeIn-AppEmail
`; + + // Attach change listeners + container.querySelectorAll('.notif-pref-row').forEach(row => { + const type = row.dataset.type; + row.querySelector('.notif-pref-inapp').addEventListener('change', (e) => { + this._save(type, { in_app: e.target.checked }); + }); + row.querySelector('.notif-pref-email').addEventListener('change', (e) => { + this._save(type, { email: e.target.checked }); + }); + }); + }, + + async _save(type, patch) { + try { + await API.setNotifPref(type, patch); + // Update cache + const existing = this._prefs.find(p => p.type === type); + if (existing) { + Object.assign(existing, patch); + } else { + this._prefs.push({ type, in_app: true, email: false, ...patch }); + } + } catch (e) { + UI.toast('Failed to save preference', 'error'); + this.render(); // revert checkboxes + } + }, +}; + +// ── Admin SMTP UI helpers ─────────────────── + +// Called from index.html onclick +// eslint-disable-next-line no-unused-vars +NotifPrefs._testEmail = async function() { + const status = document.getElementById('smtpTestStatus'); + const btn = document.getElementById('adminSmtpTestBtn'); + if (!status || !btn) return; + + btn.disabled = true; + status.textContent = 'Sending…'; + status.className = 'smtp-test-status'; + + try { + const result = await API.adminTestEmail(); + status.textContent = `✓ Sent to ${result.sent_to}`; + status.className = 'smtp-test-status success'; + } catch (e) { + status.textContent = '✗ ' + (e.message || 'Failed'); + status.className = 'smtp-test-status error'; + } finally { + btn.disabled = false; + } +}; + +NotifPrefs._initAdminSmtp = function() { + const toggle = document.getElementById('adminEmailEnabled'); + const fields = document.getElementById('smtpConfigFields'); + if (!toggle || !fields) return; + toggle.addEventListener('change', () => { + fields.style.display = toggle.checked ? '' : 'none'; + }); +}; + +NotifPrefs._loadAdminSmtp = function(cfg) { + if (!cfg) return; + const el = (id) => document.getElementById(id); + if (cfg.email_enabled) el('adminEmailEnabled').checked = true; + if (cfg.smtp_host) el('adminSmtpHost').value = cfg.smtp_host; + if (cfg.smtp_port) el('adminSmtpPort').value = cfg.smtp_port; + if (cfg.smtp_user) el('adminSmtpUser').value = cfg.smtp_user; + // Don't populate password (security) + if (cfg.smtp_from) el('adminSmtpFrom').value = cfg.smtp_from; + if (cfg.smtp_tls != null) el('adminSmtpTls').value = String(cfg.smtp_tls); + + // Show/hide SMTP fields + const fields = document.getElementById('smtpConfigFields'); + if (fields) fields.style.display = cfg.email_enabled ? '' : 'none'; +}; + +NotifPrefs._getAdminSmtp = function() { + const el = (id) => document.getElementById(id); + const config = { + email_enabled: el('adminEmailEnabled')?.checked || false, + smtp_host: el('adminSmtpHost')?.value?.trim() || '', + smtp_port: parseInt(el('adminSmtpPort')?.value) || 587, + smtp_user: el('adminSmtpUser')?.value?.trim() || '', + smtp_from: el('adminSmtpFrom')?.value?.trim() || '', + smtp_tls: el('adminSmtpTls')?.value === 'true', + }; + // Only include password if user typed something new + const pw = el('adminSmtpPassword')?.value; + if (pw) config.smtp_password = pw; + return config; +}; diff --git a/src/js/notifications.js b/src/js/notifications.js new file mode 100644 index 0000000..3c20524 --- /dev/null +++ b/src/js/notifications.js @@ -0,0 +1,431 @@ +// ========================================== +// Chat Switchboard – Notifications (v0.20.0) +// ========================================== +// In-app notification bell with dropdown and +// side panel. Real-time push via WebSocket. +// +// Dependencies: API, Events, PanelRegistry, UI +// ========================================== + +const Notifications = { + + _items: [], // cached notifications (latest first) + _unreadCount: 0, + _loaded: false, // full list fetched at least once + _dropdownOpen: false, + + // ── Init ───────────────────────────────── + + async init() { + this._bindEvents(); + this._registerPanel(); + await this._fetchUnreadCount(); + }, + + // ── Data ───────────────────────────────── + + async _fetchUnreadCount() { + try { + const data = await API._get('/notifications/unread-count'); + this._unreadCount = data.count || 0; + this._renderBadge(); + } catch (e) { + // Silently fail — badge just stays at 0 + } + }, + + async _fetchList(opts = {}) { + const limit = opts.limit || 20; + const offset = opts.offset || 0; + const unreadOnly = opts.unreadOnly || false; + try { + const data = await API._get( + `/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`); + return data; + } catch (e) { + return { data: [], total: 0 }; + } + }, + + async _loadLatest() { + const result = await this._fetchList({ limit: 10 }); + this._items = result.data || []; + this._loaded = true; + return result; + }, + + // ── Badge ──────────────────────────────── + + _renderBadge() { + const badge = document.getElementById('notifBadge'); + if (!badge) return; + if (this._unreadCount > 0) { + badge.textContent = this._unreadCount > 9 ? '9+' : String(this._unreadCount); + badge.style.display = ''; + } else { + badge.style.display = 'none'; + } + }, + + // ── Dropdown ───────────────────────────── + + async toggleDropdown() { + if (this._dropdownOpen) { + this._closeDropdown(); + return; + } + + // Lazy-load on first open + if (!this._loaded) { + await this._loadLatest(); + } + + const dd = document.getElementById('notifDropdown'); + if (!dd) return; + + dd.innerHTML = this._renderDropdownContent(); + dd.style.display = 'block'; + this._dropdownOpen = true; + + // Click-outside handler + setTimeout(() => { + document.addEventListener('click', this._outsideClickHandler); + }, 0); + }, + + _closeDropdown() { + const dd = document.getElementById('notifDropdown'); + if (dd) dd.style.display = 'none'; + this._dropdownOpen = false; + document.removeEventListener('click', this._outsideClickHandler); + }, + + _outsideClickHandler(e) { + const wrap = document.getElementById('notifWrap'); + if (wrap && !wrap.contains(e.target)) { + Notifications._closeDropdown(); + } + }, + + _renderDropdownContent() { + const items = this._items; + if (!items.length) { + return `
No notifications
`; + } + + let html = `
+ Notifications + +
`; + + for (const n of items.slice(0, 10)) { + const unread = !n.is_read; + const dot = unread ? '●' : '○'; + const cls = unread ? 'notif-item notif-unread' : 'notif-item'; + const ago = _timeAgo(n.created_at); + html += `
+ ${dot} +
+
${_esc(n.title)}
+ ${n.body ? `
${_esc(n.body)}
` : ''} +
${ago}
+
+
`; + } + + html += `
`; + html += ``; + + return html; + }, + + // ── Item Actions ───────────────────────── + + async _onItemClick(el) { + const id = el.dataset.id; + const resType = el.dataset.resourceType; + const resId = el.dataset.resourceId; + + // Mark read + if (el.classList.contains('notif-unread')) { + await this._markRead(id); + el.classList.remove('notif-unread'); + el.querySelector('.notif-dot').textContent = '○'; + } + + // Navigate + this._navigate(resType, resId); + this._closeDropdown(); + }, + + _navigate(resourceType, resourceId) { + if (!resourceType || !resourceId) return; + switch (resourceType) { + case 'channel': + if (typeof selectChat === 'function') selectChat(resourceId); + break; + case 'knowledge_base': + // Open admin panel → KB section (if admin) + if (API.isAdmin && typeof showAdmin === 'function') { + showAdmin('ai', 'knowledge-bases'); + } + break; + case 'project': + // Open project detail panel + if (typeof openProjectPanel === 'function') openProjectPanel(resourceId); + break; + case 'group': + if (API.isAdmin && typeof showAdmin === 'function') { + showAdmin('people', 'groups'); + } + break; + } + }, + + async _markRead(id) { + try { + await API._authed(`/notifications/${id}/read`, 'PATCH'); + this._unreadCount = Math.max(0, this._unreadCount - 1); + const item = this._items.find(n => n.id === id); + if (item) item.is_read = true; + this._renderBadge(); + } catch (e) { /* best effort */ } + }, + + async markAllRead() { + try { + await API._post('/notifications/mark-all-read'); + this._unreadCount = 0; + this._items.forEach(n => n.is_read = true); + this._renderBadge(); + // Re-render dropdown if open + if (this._dropdownOpen) { + const dd = document.getElementById('notifDropdown'); + if (dd) dd.innerHTML = this._renderDropdownContent(); + } + // Re-render panel if open + this._renderPanelContent(); + } catch (e) { /* best effort */ } + }, + + // ── Side Panel ─────────────────────────── + + _registerPanel() { + const body = document.getElementById('sidePanelBody'); + if (!body || typeof PanelRegistry === 'undefined') return; + + const el = document.createElement('div'); + el.className = 'side-panel-page'; + el.id = 'sidePanelNotifications'; + el.style.display = 'none'; + el.innerHTML = `
+
+ + +
+
+ +
`; + body.appendChild(el); + + PanelRegistry.register('notifications', { + element: el, + label: 'Notifications', + onOpen: () => this._onPanelOpen(), + saveState: () => ({ scrollTop: el.querySelector('.notif-panel-list')?.scrollTop || 0 }), + restoreState: (s) => { + const list = el.querySelector('.notif-panel-list'); + if (list && s.scrollTop) list.scrollTop = s.scrollTop; + }, + }); + }, + + _panelOffset: 0, + _panelTotal: 0, + + async _onPanelOpen() { + this._panelOffset = 0; + const result = await this._fetchList({ limit: 30 }); + this._panelTotal = result.total || 0; + this._panelOffset = (result.data || []).length; + this._panelItems = result.data || []; + this._renderPanelContent(); + }, + + _panelItems: [], + + _renderPanelContent() { + const list = document.getElementById('notifPanelList'); + if (!list) return; + + if (!this._panelItems.length) { + list.innerHTML = '
No notifications yet
'; + return; + } + + let html = ''; + for (const n of this._panelItems) { + const unread = !n.is_read; + const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item'; + const dot = unread ? '●' : '○'; + const ago = _timeAgo(n.created_at); + html += `
+
+ ${dot} +
+
${_esc(n.title)}
+ ${n.body ? `
${_esc(n.body)}
` : ''} +
${ago}
+
+ +
+
`; + } + list.innerHTML = html; + + // Show/hide load more + const moreBtn = document.getElementById('notifPanelMore'); + if (moreBtn) { + moreBtn.style.display = this._panelOffset < this._panelTotal ? '' : 'none'; + } + }, + + async _onPanelItemClick(el) { + const id = el.dataset.id; + const resType = el.dataset.resourceType; + const resId = el.dataset.resourceId; + + if (el.classList.contains('notif-unread')) { + await this._markRead(id); + el.classList.remove('notif-unread'); + el.querySelector('.notif-dot').textContent = '○'; + const pItem = this._panelItems.find(n => n.id === id); + if (pItem) pItem.is_read = true; + } + + this._navigate(resType, resId); + }, + + async _deleteItem(id) { + try { + await API._del(`/notifications/${id}`); + // Check if it was unread before removal + const item = this._panelItems.find(n => n.id === id); + if (item && !item.is_read) { + this._unreadCount = Math.max(0, this._unreadCount - 1); + this._renderBadge(); + } + this._panelItems = this._panelItems.filter(n => n.id !== id); + this._items = this._items.filter(n => n.id !== id); + this._panelTotal = Math.max(0, this._panelTotal - 1); + this._renderPanelContent(); + } catch (e) { /* best effort */ } + }, + + async loadMore() { + const result = await this._fetchList({ limit: 30, offset: this._panelOffset }); + const newItems = result.data || []; + this._panelItems = this._panelItems.concat(newItems); + this._panelOffset += newItems.length; + this._renderPanelContent(); + }, + + async clearAll() { + if (typeof showConfirm === 'function') { + const ok = await showConfirm('Delete all notifications?', 'This cannot be undone.'); + if (!ok) return; + } + // Delete visible items (API doesn't have bulk delete, so mark all read and let retention handle it) + await this.markAllRead(); + }, + + openPanel() { + this._closeDropdown(); + if (typeof PanelRegistry !== 'undefined') { + PanelRegistry.open('notifications'); + } + }, + + // ── WebSocket Events ───────────────────── + + _bindEvents() { + if (typeof Events === 'undefined') return; + + // New notification from server + Events.on('notification.new', (payload) => { + // Prepend to cached list + this._items.unshift(payload); + if (this._items.length > 50) this._items.length = 50; + this._unreadCount++; + this._renderBadge(); + + // Update dropdown if open + if (this._dropdownOpen) { + const dd = document.getElementById('notifDropdown'); + if (dd) dd.innerHTML = this._renderDropdownContent(); + } + + // Update panel if it has items + if (this._panelItems.length) { + this._panelItems.unshift(payload); + this._panelTotal++; + this._panelOffset++; + this._renderPanelContent(); + } + + // Toast for high-priority types + const highPriority = ['kb.error', 'role.fallback']; + if (highPriority.includes(payload.type)) { + if (typeof UI !== 'undefined' && UI.toast) { + UI.toast(payload.title, 'warning'); + } + } + }); + + // Badge sync across tabs (from markRead / markAllRead in another tab) + Events.on('notification.read', (payload) => { + if (payload.action === 'mark_all_read') { + this._unreadCount = 0; + this._items.forEach(n => n.is_read = true); + this._panelItems.forEach(n => n.is_read = true); + } else if (payload.id) { + this._unreadCount = Math.max(0, this._unreadCount - 1); + const item = this._items.find(n => n.id === payload.id); + if (item) item.is_read = true; + const pItem = this._panelItems.find(n => n.id === payload.id); + if (pItem) pItem.is_read = true; + } + this._renderBadge(); + if (this._dropdownOpen) { + const dd = document.getElementById('notifDropdown'); + if (dd) dd.innerHTML = this._renderDropdownContent(); + } + this._renderPanelContent(); + }); + }, +}; + +// ── Helpers ────────────────────────────────── + +function _esc(s) { + if (!s) return ''; + const el = document.createElement('span'); + el.textContent = s; + return el.innerHTML; +} + +function _timeAgo(isoStr) { + if (!isoStr) return ''; + const d = new Date(isoStr); + const now = Date.now(); + const sec = Math.floor((now - d.getTime()) / 1000); + if (sec < 60) return 'just now'; + if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; + if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`; + if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`; + return d.toLocaleDateString(); +} diff --git a/src/js/settings-handlers.js b/src/js/settings-handlers.js index c2abb7e..6e74b83 100644 --- a/src/js/settings-handlers.js +++ b/src/js/settings-handlers.js @@ -256,6 +256,12 @@ async function handleSaveAdminSettings() { }}); await API.adminUpdateSetting('memory_extraction_enabled', { value: memExtractionEnabled }); + // Email / SMTP config (v0.20.0 Phase 3) + if (typeof NotifPrefs !== 'undefined' && NotifPrefs._getAdminSmtp) { + const smtpConfig = NotifPrefs._getAdminSmtp(); + await API.adminUpdateSetting('notifications', { value: smtpConfig }); + } + UI.toast('Settings saved', 'success'); // Live-apply: refresh policies and dependent UI await initBanners(); diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js index dd5ffd7..48996d3 100644 --- a/src/js/ui-admin.js +++ b/src/js/ui-admin.js @@ -981,6 +981,13 @@ Object.assign(UI, { const memAutoApprove = document.getElementById('adminMemoryAutoApprove'); if (memAutoApprove) memAutoApprove.checked = !!memExtractionCfg.auto_approve; + // Email / SMTP config (v0.20.0 Phase 3) + const notifCfg = getSetting('notifications', {}) || {}; + if (typeof NotifPrefs !== 'undefined') { + NotifPrefs._loadAdminSmtp(notifCfg); + NotifPrefs._initAdminSmtp(); + } + if (vaultEl) { try { const vault = await API.adminGetVaultStatus(); diff --git a/src/js/ui-core.js b/src/js/ui-core.js index 5dd78d6..a4ac4b6 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -598,24 +598,39 @@ const UI = { const label = modelName || 'Assistant'; const currentModel = App.findModel(App.settings.model); const streamAvatar = currentModel?.presetAvatar || null; - const div = document.createElement('div'); - div.className = 'message assistant'; - div.innerHTML = ` -
-
${avatarHTML('assistant', streamAvatar)}
-
-
${esc(label)}
- -
-
-
`; - container.appendChild(div); + + // Multi-model state: when model_start events arrive, we create + // separate message bubbles per model. Without them, single-bubble. + let multiModel = false; + let activeContentEl = null; + let activeToolsEl = null; + let content = ''; + let reasoning = ''; + + // Create initial single-model bubble (replaced if multi-model) + const _createBubble = (bubbleLabel) => { + const div = document.createElement('div'); + div.className = 'message assistant'; + div.innerHTML = ` +
+
${avatarHTML('assistant', streamAvatar)}
+
+
${esc(bubbleLabel)}
+ +
+
+
`; + container.appendChild(div); + activeContentEl = div.querySelector('.msg-text'); + activeToolsEl = div.querySelector('.msg-tools'); + return div; + }; + + _createBubble(label); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; - let content = ''; - let reasoning = ''; let currentEvent = ''; // SSE event type while (true) { @@ -640,16 +655,34 @@ const UI = { try { const parsed = JSON.parse(data); + // ── Multi-model: model_start creates a new bubble ── + if (currentEvent === 'model_start') { + if (!multiModel) { + // First model_start: remove the initial empty bubble + multiModel = true; + container.lastElementChild?.remove(); + } + content = ''; + reasoning = ''; + const dn = parsed.display_name || parsed.model_id || 'Model'; + _createBubble(dn); + currentEvent = ''; + continue; + } + + if (currentEvent === 'model_end') { + currentEvent = ''; + continue; + } + if (currentEvent === 'tool_use') { - // parsed = array of tool calls - this._renderToolUse(parsed); + this._renderToolUseInEl(activeToolsEl, parsed); currentEvent = ''; continue; } if (currentEvent === 'tool_result') { - // parsed = { tool_call_id, name, content, is_error } - this._renderToolResult(parsed); + this._renderToolResultInEl(activeToolsEl, parsed); currentEvent = ''; continue; } @@ -658,9 +691,8 @@ const UI = { const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || ''; if (reasoningDelta) { reasoning += reasoningDelta; - // Render accumulated reasoning + content together const full = (reasoning ? '' + reasoning + '' : '') + content; - document.getElementById('streamContent').innerHTML = formatMessage(full); + if (activeContentEl) activeContentEl.innerHTML = formatMessage(full); this._scrollToBottom(); } @@ -669,7 +701,7 @@ const UI = { if (delta) { content += delta; const full = (reasoning ? '' + reasoning + '' : '') + content; - document.getElementById('streamContent').innerHTML = formatMessage(full); + if (activeContentEl) activeContentEl.innerHTML = formatMessage(full); this._scrollToBottom(); // Live-update preview panel if open (debounced) @@ -685,6 +717,43 @@ const UI = { return content; }, + // Tool rendering helpers that take explicit element refs (for multi-model) + _renderToolUseInEl(toolsEl, toolCalls) { + if (!toolsEl) { + // Fallback to id-based (single model compat) + this._renderToolUse(toolCalls); + return; + } + toolsEl.style.display = ''; + for (const tc of toolCalls) { + const name = tc.function?.name || tc.name || 'unknown'; + const toolDiv = document.createElement('div'); + toolDiv.className = 'tool-activity'; + toolDiv.dataset.toolId = tc.id; + toolDiv.innerHTML = ` + 🔧 + ${esc(name)} + running…`; + toolsEl.appendChild(toolDiv); + } + this._scrollToBottom(); + }, + + _renderToolResultInEl(toolsEl, result) { + if (!toolsEl) { + this._renderToolResult(result); + return; + } + const el = toolsEl.querySelector(`[data-tool-id="${result.tool_call_id}"]`); + if (el) { + const statusEl = el.querySelector('.tool-status'); + if (statusEl) { + statusEl.textContent = result.is_error ? 'error' : 'done'; + statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`; + } + } + }, + _renderToolUse(toolCalls) { const el = document.getElementById('streamTools'); if (!el) return; @@ -693,7 +762,7 @@ const UI = { const name = tc.function?.name || tc.name || 'unknown'; const toolDiv = document.createElement('div'); toolDiv.className = 'tool-activity'; - toolDiv.id = `tool_${tc.id}`; + toolDiv.dataset.toolId = tc.id; toolDiv.innerHTML = ` 🔧 ${esc(name)} @@ -704,7 +773,8 @@ const UI = { }, _renderToolResult(result) { - const el = document.getElementById(`tool_${result.tool_call_id}`); + // Search all tool-activity elements by data-tool-id + const el = document.querySelector(`[data-tool-id="${result.tool_call_id}"]`); if (el) { const statusEl = el.querySelector('.tool-status'); if (statusEl) { @@ -1052,6 +1122,11 @@ const UI = { if (c) c.innerHTML = '
Memory UI failed to load.
'; } } + if (tab === 'notifPrefs') { + if (typeof NotifPrefs !== 'undefined') { + NotifPrefs.load(); + } + } }, // ── Export ────────────────────────────────