Changeset 0.20.0 (#85)
This commit is contained in:
@@ -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).
|
||||
|
||||
713
docs/DESIGN-0.20.0.md
Normal file
713
docs/DESIGN-0.20.0.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user