Changeset 0.24.2 (#158)
This commit is contained in:
713
docs/archive/DESIGN-0.20.0.md
Normal file
713
docs/archive/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.
|
||||
976
docs/archive/DESIGN-0.21.0.md
Normal file
976
docs/archive/DESIGN-0.21.0.md
Normal file
@@ -0,0 +1,976 @@
|
||||
# Design — v0.21.x: Workspaces + Extension Surfaces
|
||||
|
||||
**Status:** Planning
|
||||
**Depends on:** v0.20.0 (notifications), v0.17.2 (CM6), v0.11.0 (extension foundation)
|
||||
**Feeds into:** v0.22.0 (smart routing), v0.23.0 (multi-participant channels)
|
||||
|
||||
---
|
||||
|
||||
## Guiding Principle
|
||||
|
||||
The **workspace** is a platform primitive — not an editor-mode concept. Even in
|
||||
pure chat mode, uploading a zip, having the AI operate on real files at real
|
||||
paths, and downloading the result as an archive is a first-class workflow. Every
|
||||
surface (chat, editor, article, future modes) benefits from workspace access.
|
||||
|
||||
This changes the layering: workspace infrastructure lands first as foundational
|
||||
storage, then surfaces consume it.
|
||||
|
||||
---
|
||||
|
||||
## Release Decomposition
|
||||
|
||||
```
|
||||
v0.21.0 Workspace Storage (platform primitive)
|
||||
│
|
||||
v0.21.1 Workspace Tools + Channel/Project Binding
|
||||
│
|
||||
v0.21.2 Workspace Indexing + Semantic Search
|
||||
│
|
||||
v0.21.3 Surface Infrastructure + REPL
|
||||
│
|
||||
v0.21.4 Git Integration
|
||||
│
|
||||
v0.21.5 Editor Surface (Development Mode)
|
||||
│
|
||||
v0.21.6 Article Surface + Document Output Pipeline
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.21.0 — Workspace Storage
|
||||
|
||||
Pure backend. No tools, no UI, no surfaces. Just the storage primitive with
|
||||
full CRUD API and archive support.
|
||||
|
||||
### Data Model
|
||||
|
||||
**`workspaces` table**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | UUID | PK |
|
||||
| `owner_type` | TEXT | `user`, `project`, `channel`, `team` |
|
||||
| `owner_id` | UUID | FK polymorphic |
|
||||
| `name` | TEXT | display name |
|
||||
| `root_path` | TEXT | PVC-relative path: `workspaces/{id}` |
|
||||
| `max_bytes` | BIGINT | quota (NULL = system default) |
|
||||
| `status` | TEXT | `active`, `archived`, `deleting` |
|
||||
| `created_at` | TIMESTAMPTZ | |
|
||||
| `updated_at` | TIMESTAMPTZ | |
|
||||
|
||||
Index: `(owner_type, owner_id)` — lookup workspace(s) for a given owner.
|
||||
|
||||
**`workspace_files` table** (metadata index — filesystem is source of truth)
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | UUID | PK |
|
||||
| `workspace_id` | UUID | FK → workspaces |
|
||||
| `path` | TEXT | relative path from workspace root: `src/main.go` |
|
||||
| `is_directory` | BOOLEAN | |
|
||||
| `content_type` | TEXT | MIME type (detected or inferred) |
|
||||
| `size_bytes` | BIGINT | 0 for dirs |
|
||||
| `sha256` | TEXT | content hash (NULL for dirs) |
|
||||
| `created_at` | TIMESTAMPTZ | |
|
||||
| `updated_at` | TIMESTAMPTZ | |
|
||||
|
||||
Index: `UNIQUE (workspace_id, path)` — one entry per path.
|
||||
Index: `(workspace_id, content_type)` — filter by type.
|
||||
|
||||
Design note: the DB index is a metadata cache. The PVC filesystem is the source
|
||||
of truth. Syncing happens on write operations and optionally via a reconcile
|
||||
endpoint. This avoids the complexity of fsnotify while keeping queries fast.
|
||||
|
||||
### Filesystem Layout (PVC)
|
||||
|
||||
```
|
||||
/data/storage/
|
||||
attachments/ # existing (v0.12.0)
|
||||
processing/ # existing extraction queue
|
||||
workspaces/ # NEW
|
||||
{workspace_id}/
|
||||
.workspace.json # metadata: owner, quotas, created_at
|
||||
files/ # actual file tree root
|
||||
src/
|
||||
main.go
|
||||
README.md
|
||||
```
|
||||
|
||||
Why a separate `files/` subdirectory: keeps workspace metadata (`.workspace.json`,
|
||||
future `.git/` in v0.21.4) out of the user's file namespace.
|
||||
|
||||
### Store Interface
|
||||
|
||||
```go
|
||||
type WorkspaceStore interface {
|
||||
// CRUD
|
||||
Create(ctx context.Context, w *models.Workspace) error
|
||||
GetByID(ctx context.Context, id string) (*models.Workspace, error)
|
||||
Update(ctx context.Context, id string, patch models.WorkspacePatch) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Ownership lookup
|
||||
GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error)
|
||||
ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error)
|
||||
|
||||
// File index
|
||||
UpsertFile(ctx context.Context, f *models.WorkspaceFile) error
|
||||
DeleteFile(ctx context.Context, workspaceID, path string) error
|
||||
DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error
|
||||
GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error)
|
||||
ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error)
|
||||
|
||||
// Stats
|
||||
GetDiskUsage(ctx context.Context, workspaceID string) (int64, error)
|
||||
}
|
||||
```
|
||||
|
||||
### Workspace FS (Filesystem Operations)
|
||||
|
||||
Separate from the store — this operates on PVC, not DB.
|
||||
|
||||
```go
|
||||
type WorkspaceFS struct {
|
||||
basePath string // e.g. /data/storage/workspaces
|
||||
store WorkspaceStore // for metadata sync
|
||||
}
|
||||
|
||||
// File CRUD — all paths relative to workspace root
|
||||
func (fs *WorkspaceFS) ReadFile(ctx context.Context, w *models.Workspace, path string) (io.ReadCloser, int64, error)
|
||||
func (fs *WorkspaceFS) WriteFile(ctx context.Context, w *models.Workspace, path string, r io.Reader, size int64) error
|
||||
func (fs *WorkspaceFS) DeleteFile(ctx context.Context, w *models.Workspace, path string) error
|
||||
func (fs *WorkspaceFS) Mkdir(ctx context.Context, w *models.Workspace, path string) error
|
||||
func (fs *WorkspaceFS) Stat(ctx context.Context, w *models.Workspace, path string) (*models.WorkspaceFile, error)
|
||||
func (fs *WorkspaceFS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error)
|
||||
|
||||
// Tree: returns full file listing with sizes (for file tree UI)
|
||||
func (fs *WorkspaceFS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error)
|
||||
|
||||
// Archive operations
|
||||
func (fs *WorkspaceFS) ExtractArchive(ctx context.Context, w *models.Workspace, r io.Reader, format string) (int, error)
|
||||
func (fs *WorkspaceFS) CreateArchive(ctx context.Context, w *models.Workspace, format string) (io.ReadCloser, error)
|
||||
|
||||
// Maintenance
|
||||
func (fs *WorkspaceFS) Reconcile(ctx context.Context, w *models.Workspace) error // sync FS → DB index
|
||||
func (fs *WorkspaceFS) Destroy(ctx context.Context, w *models.Workspace) error // rm -rf + DB cleanup
|
||||
```
|
||||
|
||||
WriteFile updates the DB index (upsert workspace_files) after successful write.
|
||||
DeleteFile removes the DB row. Reconcile walks the FS and patches any drift.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```
|
||||
POST /api/v1/workspaces # create workspace
|
||||
GET /api/v1/workspaces/:id # get metadata
|
||||
PATCH /api/v1/workspaces/:id # update name/quota
|
||||
DELETE /api/v1/workspaces/:id # delete (async cleanup)
|
||||
|
||||
GET /api/v1/workspaces/:id/files?path=&recursive= # list files
|
||||
GET /api/v1/workspaces/:id/files/read?path= # read file content
|
||||
PUT /api/v1/workspaces/:id/files/write?path= # write file (body = content)
|
||||
DELETE /api/v1/workspaces/:id/files?path= # delete file/dir
|
||||
POST /api/v1/workspaces/:id/files/mkdir?path= # create directory
|
||||
|
||||
POST /api/v1/workspaces/:id/archive/upload # upload + extract zip/tar.gz
|
||||
GET /api/v1/workspaces/:id/archive/download?format= # download as zip/tar.gz
|
||||
|
||||
POST /api/v1/workspaces/:id/reconcile # force FS → DB sync
|
||||
GET /api/v1/workspaces/:id/stats # disk usage, file count
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
- Workspace access inherits from owner: if you can access the project/channel,
|
||||
you can access its workspace.
|
||||
- Path traversal guard: all paths are cleaned and must resolve within the
|
||||
workspace root. Symlinks are rejected.
|
||||
- File size limit: configurable per-workspace `max_bytes` with system default
|
||||
(`WORKSPACE_MAX_BYTES`, default 500MB).
|
||||
- Archive extraction bomb protection: max files (10,000), max total extracted
|
||||
size (workspace quota), max single file (100MB).
|
||||
|
||||
### Migrations
|
||||
|
||||
- `xxx_workspace_tables.sql` (Postgres + SQLite): `workspaces`, `workspace_files`
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `models.Workspace`, `models.WorkspaceFile`, `models.WorkspacePatch` structs
|
||||
- [ ] `WorkspaceStore` interface in `store/interfaces.go`
|
||||
- [ ] Postgres implementation: `store/postgres/workspace.go`
|
||||
- [ ] SQLite implementation: `store/sqlite/workspace.go`
|
||||
- [ ] `WorkspaceFS` in new `server/workspace/` package
|
||||
- [ ] Path traversal validation (must not escape workspace root)
|
||||
- [ ] Archive extract (zip, tar.gz) with bomb protection
|
||||
- [ ] Archive create (zip, tar.gz)
|
||||
- [ ] MIME type detection on write (`net/http.DetectContentType` + extension fallback)
|
||||
- [ ] Workspace CRUD handlers (6 endpoints)
|
||||
- [ ] File CRUD handlers (5 endpoints)
|
||||
- [ ] Archive handlers (2 endpoints)
|
||||
- [ ] Reconcile + stats handlers (2 endpoints)
|
||||
- [ ] `Stores.Workspaces` wired in `store/postgres/stores.go` + `store/sqlite/stores.go`
|
||||
- [ ] Integration tests: workspace CRUD, file CRUD, archive round-trip, path traversal rejection
|
||||
- [ ] PVC directory creation on startup (`/data/storage/workspaces/`)
|
||||
|
||||
---
|
||||
|
||||
## v0.21.1 — Workspace Tools + Channel/Project Binding
|
||||
|
||||
Make workspaces useful in chat mode. AI can operate on files through tool calls.
|
||||
Channels and projects get workspace bindings.
|
||||
|
||||
### Tools (category: `workspace`)
|
||||
|
||||
**`workspace_ls`** — list files in workspace
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "src/",
|
||||
"recursive": false
|
||||
}
|
||||
```
|
||||
|
||||
Returns: array of `{ path, is_directory, content_type, size_bytes }`.
|
||||
|
||||
**`workspace_read`** — read a file
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "src/main.go"
|
||||
}
|
||||
```
|
||||
|
||||
Returns: file content as text (with truncation guard for large files, e.g. 50KB
|
||||
soft limit with "file truncated" indicator). Binary files return a base64
|
||||
summary or "binary file, N bytes" message.
|
||||
|
||||
**`workspace_write`** — create or overwrite a file
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "src/main.go",
|
||||
"content": "package main\n\nfunc main() {\n}\n"
|
||||
}
|
||||
```
|
||||
|
||||
Returns: confirmation with path and size. Creates parent directories
|
||||
automatically.
|
||||
|
||||
**`workspace_rm`** — delete a file or directory
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "src/old_module/",
|
||||
"recursive": false
|
||||
}
|
||||
```
|
||||
|
||||
Returns: confirmation. Recursive required for non-empty directories.
|
||||
|
||||
**`workspace_mv`** — rename or move a file
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "src/old.go",
|
||||
"destination": "src/new.go"
|
||||
}
|
||||
```
|
||||
|
||||
**`workspace_patch`** — apply a text diff/patch to a file
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "src/main.go",
|
||||
"operations": [
|
||||
{ "find": "oldFunction()", "replace": "newFunction()" },
|
||||
{ "find": "// TODO: remove", "replace": "" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `find`/`replace` approach (same pattern as `str_replace` in many AI
|
||||
coding tools) is more reliable for LLMs than unified diffs. Each operation's
|
||||
`find` string must match exactly once in the file.
|
||||
|
||||
### Channel Workspace Binding
|
||||
|
||||
**Schema change:** `channels` table gains optional `workspace_id` FK.
|
||||
|
||||
**Behavior:**
|
||||
- When a workspace is bound to a channel, workspace tools are injected into
|
||||
the tool set for that channel's completions (same pattern as KB tools via
|
||||
persona binding).
|
||||
- New channel setting: "Workspace" toggle in channel settings.
|
||||
- Creating a workspace from channel settings auto-binds it.
|
||||
|
||||
**Archive upload flow (chat mode):**
|
||||
1. User uploads a zip/tar.gz to a channel with a workspace.
|
||||
2. Upload handler detects archive MIME type + workspace binding.
|
||||
3. Extracts into workspace (not attachment store).
|
||||
4. Workspace tools become available for AI to operate on files.
|
||||
5. User or AI can call archive download to get results.
|
||||
|
||||
Non-archive files still go to attachment store as before (backward compatible).
|
||||
|
||||
### Project Workspace Binding
|
||||
|
||||
**Schema change:** `projects` table gains optional `workspace_id` FK.
|
||||
|
||||
**Behavior:**
|
||||
- Project detail panel gets a "Files" tab showing workspace file tree.
|
||||
- All channels in a project inherit the project workspace (unless the channel
|
||||
has its own).
|
||||
- Workspace resolution: channel workspace > project workspace (same override
|
||||
pattern as persona/KB resolution).
|
||||
|
||||
### Frontend
|
||||
|
||||
- Channel settings: workspace toggle, create/bind workspace.
|
||||
- Project detail panel: Files tab with tree view (reusable component for
|
||||
editor surface later).
|
||||
- Chat bar: workspace indicator when active (similar to KB indicator).
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `workspace_ls` tool implementation
|
||||
- [ ] `workspace_read` tool with text truncation guard + binary detection
|
||||
- [ ] `workspace_write` tool with auto-mkdir
|
||||
- [ ] `workspace_rm` tool with recursive guard
|
||||
- [ ] `workspace_mv` tool
|
||||
- [ ] `workspace_patch` tool with find/replace operations
|
||||
- [ ] Tool registration in `workspace` category
|
||||
- [ ] Tool injection in completion handler when workspace bound
|
||||
- [ ] `channels.workspace_id` column (migration, both dialects)
|
||||
- [ ] `projects.workspace_id` column (migration, both dialects)
|
||||
- [ ] Archive-to-workspace upload handler (detect archive + workspace → extract)
|
||||
- [ ] Workspace resolution: channel → project fallback
|
||||
- [ ] Channel settings UI: workspace toggle
|
||||
- [ ] Project detail panel: Files tab
|
||||
- [ ] Chat bar: workspace indicator
|
||||
- [ ] Integration tests: tool execution, workspace resolution, archive upload flow
|
||||
- [ ] Update `ExecutionContext` with workspace ID
|
||||
|
||||
---
|
||||
|
||||
## v0.21.2 — Workspace Indexing + Semantic Search
|
||||
|
||||
Embed text files in workspace for semantic search. Reuses the existing
|
||||
`knowledge/` chunker and embedder — they're already decoupled from KB-specific
|
||||
concerns.
|
||||
|
||||
### Data Model
|
||||
|
||||
**`workspace_chunks` table**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | UUID | PK |
|
||||
| `workspace_id` | UUID | FK → workspaces |
|
||||
| `file_id` | UUID | FK → workspace_files |
|
||||
| `chunk_index` | INT | ordinal within file |
|
||||
| `content` | TEXT | chunk text |
|
||||
| `token_count` | INT | estimated |
|
||||
| `embedding` | vector(3072) / JSON | pgvector or JSON text (SQLite) |
|
||||
| `metadata` | JSONB / JSON | extensible: line_start, heading, etc. |
|
||||
|
||||
Index: pgvector IVFFlat on `embedding` (Postgres), app-level cosine (SQLite).
|
||||
Index: `(file_id)` — re-index on file change.
|
||||
|
||||
### Indexing Pipeline
|
||||
|
||||
```
|
||||
file write/upload
|
||||
→ is text file? (same textMIMETypes + code extensions)
|
||||
→ extract content (already in memory from write, or read from FS)
|
||||
→ chunk (reuse knowledge.SplitText with configurable ChunkConfig)
|
||||
→ embed (reuse knowledge.Embedder)
|
||||
→ store in workspace_chunks (DELETE old chunks for file_id, INSERT new)
|
||||
→ update workspace_files.sha256 (content-addressed skip on no-change)
|
||||
```
|
||||
|
||||
**Code-aware MIME types** (extend `textMIMETypes`):
|
||||
|
||||
The existing KB ingester recognizes `text/plain`, `text/markdown`, `text/csv`,
|
||||
`text/html`. For workspace indexing, we also recognize source code by extension:
|
||||
|
||||
```go
|
||||
var indexableExtensions = map[string]bool{
|
||||
".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true,
|
||||
".tsx": true, ".rs": true, ".c": true, ".cpp": true, ".h": true,
|
||||
".hpp": true, ".java": true, ".rb": true, ".php": true, ".swift": true,
|
||||
".kt": true, ".scala": true, ".sh": true, ".bash": true, ".zsh": true,
|
||||
".sql": true, ".yaml": true, ".yml": true, ".toml": true, ".json": true,
|
||||
".xml": true, ".css": true, ".scss": true, ".less": true,
|
||||
".md": true, ".txt": true, ".csv": true, ".html": true, ".htm": true,
|
||||
".dockerfile": true, ".tf": true, ".hcl": true, ".proto": true,
|
||||
".graphql": true, ".vue": true, ".svelte": true,
|
||||
".pl": true, ".pm": true, // Perl
|
||||
".lua": true, ".zig": true, ".nim": true, ".ex": true, ".exs": true,
|
||||
}
|
||||
```
|
||||
|
||||
**Content-addressed skip:** if `workspace_files.sha256` matches the new write,
|
||||
skip re-chunking and re-embedding. This makes bulk operations (archive extract)
|
||||
efficient — only changed files get re-indexed.
|
||||
|
||||
**Background indexing:** file writes trigger async indexing (same goroutine
|
||||
pattern as KB ingestion with semaphore). The write API returns immediately; the
|
||||
`workspace_files` row gets an `index_status` column: `pending`, `indexing`,
|
||||
`ready`, `error`, `skipped` (binary/non-text).
|
||||
|
||||
### Code-Aware Chunking
|
||||
|
||||
For source code, the default chunker works reasonably well but we add a
|
||||
code-specific separator hierarchy:
|
||||
|
||||
```go
|
||||
func CodeChunkConfig() ChunkConfig {
|
||||
return ChunkConfig{
|
||||
ChunkSize: 1500, // larger chunks for code (functions can be long)
|
||||
ChunkOverlap: 200,
|
||||
Separators: []string{"\n\nfunc ", "\n\nclass ", "\n\ndef ", "\n\n", "\n", " "},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a simple heuristic — it splits on function/class boundaries when
|
||||
possible, falling back to paragraph and line boundaries. It doesn't require
|
||||
an AST parser and handles most languages well enough for search.
|
||||
|
||||
### Search Tool
|
||||
|
||||
**`workspace_search`** — semantic search across workspace files
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "database connection pooling",
|
||||
"top_k": 10,
|
||||
"file_pattern": "*.go"
|
||||
}
|
||||
```
|
||||
|
||||
Returns: array of `{ file_path, chunk_content, score, line_hint }`.
|
||||
|
||||
The `file_pattern` is optional glob filtering (applied post-search on the
|
||||
`workspace_files.path` join). `line_hint` is approximate line number from
|
||||
chunk metadata.
|
||||
|
||||
### Workspace Store Extensions
|
||||
|
||||
```go
|
||||
// Added to WorkspaceStore interface
|
||||
InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error
|
||||
DeleteChunksByFile(ctx context.Context, fileID string) error
|
||||
SimilaritySearch(ctx context.Context, workspaceID string, embedding []float64, topK int) ([]models.WorkspaceChunkResult, error)
|
||||
UpdateFileIndexStatus(ctx context.Context, fileID, status string) error
|
||||
```
|
||||
|
||||
### `workspace_files` Schema Addition
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `index_status` | TEXT | `pending`, `indexing`, `ready`, `error`, `skipped` |
|
||||
| `chunk_count` | INT | number of chunks after indexing |
|
||||
|
||||
### Indexing Configuration
|
||||
|
||||
- **Per-workspace toggle:** `workspaces.indexing_enabled` (default: `true`
|
||||
when embedding role is configured, `false` otherwise).
|
||||
- **System setting:** `WORKSPACE_INDEXING_ENABLED` env var (global kill switch).
|
||||
- **Concurrency:** shared semaphore with KB ingestion (don't overwhelm the
|
||||
embedding provider).
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `workspace_chunks` table (Postgres + SQLite migrations)
|
||||
- [ ] `workspace_files.index_status` and `chunk_count` columns
|
||||
- [ ] `WorkspaceChunk`, `WorkspaceChunkResult` model structs
|
||||
- [ ] Store methods: `InsertChunks`, `DeleteChunksByFile`, `SimilaritySearch`, `UpdateFileIndexStatus`
|
||||
- [ ] Postgres implementation with pgvector
|
||||
- [ ] SQLite implementation with app-level cosine
|
||||
- [ ] `workspace.Indexer` — reuses `knowledge.SplitText` + `knowledge.Embedder`
|
||||
- [ ] Indexable type detection: MIME type + extension map
|
||||
- [ ] Code-aware chunk config
|
||||
- [ ] Content-addressed skip (sha256 check)
|
||||
- [ ] Background indexing goroutine with semaphore
|
||||
- [ ] `workspace_search` tool
|
||||
- [ ] `workspace_search` glob filtering on file path
|
||||
- [ ] Hook indexer into `WorkspaceFS.WriteFile` and `ExtractArchive`
|
||||
- [ ] Workspace index status endpoint: `GET /api/v1/workspaces/:id/index-status`
|
||||
- [ ] Integration tests: index pipeline, semantic search, content-addressed skip
|
||||
- [ ] Notification on index complete/error (reuse notification service)
|
||||
|
||||
---
|
||||
|
||||
## v0.21.3 — Surface Infrastructure + REPL
|
||||
|
||||
Pure UI architecture. No workspace dependency. Can develop in parallel with
|
||||
v0.21.0–v0.21.2 on the frontend side.
|
||||
|
||||
### Surface Registration API
|
||||
|
||||
Extends the existing `ctx.*` extension API from v0.11.0.
|
||||
|
||||
```js
|
||||
Extensions.register({
|
||||
id: 'my-mode',
|
||||
init(ctx) {
|
||||
ctx.surfaces.register('my-surface', {
|
||||
label: 'My Mode',
|
||||
icon: 'code', // lucide icon name
|
||||
regions: ['surface-main', 'sidebar-content'],
|
||||
activate() { /* take over regions */ },
|
||||
deactivate() { /* restore regions */ }
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Region Management
|
||||
|
||||
```js
|
||||
ctx.ui.replace(regionId, element) // saves current content, swaps in element
|
||||
ctx.ui.restore(regionId) // restores saved content
|
||||
```
|
||||
|
||||
Regions are DOM containers identified by `data-surface-region` attributes.
|
||||
`replace()` detaches the current children (preserved in memory, not destroyed),
|
||||
inserts the new element. `restore()` re-attaches the saved children.
|
||||
|
||||
This is critical for CM6 — editor instances preserve state across mode switches
|
||||
because the DOM nodes aren't destroyed, just detached.
|
||||
|
||||
### Surface Regions
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ [surface-header] │
|
||||
├──────────────┬───────────────────────────────────┤
|
||||
│ │ │
|
||||
│ [sidebar- │ [surface-main] │
|
||||
│ content] │ (chat messages / editor / │
|
||||
│ │ article / custom) │
|
||||
│ │ │
|
||||
│ ├───────────────────────────────────┤
|
||||
│ │ [surface-footer] │
|
||||
│ │ (chat input / editor toolbar) │
|
||||
└──────────────┴───────────────────────────────────┘
|
||||
```
|
||||
|
||||
The sidebar-nav region (mode selector) is managed by the surface system itself,
|
||||
not by individual extensions.
|
||||
|
||||
### Mode Selector
|
||||
|
||||
Appears in the sidebar below the brand logo, above chat history, when ≥1
|
||||
extension surface is registered. Shows icon buttons for each mode. Chat is
|
||||
always the first (default) mode.
|
||||
|
||||
```
|
||||
[💬] [</>] [📄] ← mode selector (chat, editor, article)
|
||||
──────────────
|
||||
Chat History... ← sidebar-content (replaced per mode)
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
```js
|
||||
Events.on('surface.activated', ({ surface, previous }) => { ... });
|
||||
Events.on('surface.deactivated', ({ surface }) => { ... });
|
||||
```
|
||||
|
||||
### REPL / Debug Console (#70)
|
||||
|
||||
Fourth tab in debug modal (Ctrl+Shift+L). See roadmap for full spec:
|
||||
|
||||
- `AsyncFunction` wrapper for top-level `await`
|
||||
- Injected globals: `API`, `Events`, `Extensions`, `DebugLog`, `Surfaces`
|
||||
- Pretty-print results (collapsible JSON, red errors with stack)
|
||||
- Command history: ↑/↓ with `sessionStorage`
|
||||
- Tab-completion on live object graphs
|
||||
- Event label hints on `Events.publish('`/`Events.on('`
|
||||
- Multi-line: Shift+Enter for newline, Enter to run
|
||||
- Admin-gated OR `?debug=1` URL param
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `data-surface-region` attributes on index.html containers
|
||||
- [ ] `surfaces.js` — SurfaceRegistry: register, activate, deactivate, getCurrent
|
||||
- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` with DOM preservation
|
||||
- [ ] `ctx.surfaces.register()` API for extensions
|
||||
- [ ] Mode selector component in sidebar
|
||||
- [ ] Chat as default surface (implicit, always registered)
|
||||
- [ ] `surface.activated` / `surface.deactivated` EventBus events
|
||||
- [ ] REPL tab: AsyncFunction wrapper, global injection
|
||||
- [ ] REPL tab: pretty-print results
|
||||
- [ ] REPL tab: command history (sessionStorage)
|
||||
- [ ] REPL tab: tab-completion on object graphs
|
||||
- [ ] REPL tab: event label hints
|
||||
- [ ] REPL tab: admin gate
|
||||
- [ ] REPL tab: toolbar (clear, copy, help)
|
||||
- [ ] Mobile: mode selector collapses to hamburger/bottom nav
|
||||
- [ ] Integration with extension loader (surfaces from manifest)
|
||||
- [ ] Documentation in EXTENSIONS.md §6 update
|
||||
|
||||
---
|
||||
|
||||
## v0.21.4 — Git Integration
|
||||
|
||||
Layer git operations onto workspace. A workspace can optionally track a git
|
||||
remote. Credentials go through the vault.
|
||||
|
||||
### Schema Changes
|
||||
|
||||
**`workspaces` table additions:**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `git_remote_url` | TEXT | nullable — HTTPS or SSH URL |
|
||||
| `git_branch` | TEXT | nullable — default branch after clone |
|
||||
| `git_credential_id` | UUID | nullable — FK to git_credentials |
|
||||
| `git_last_sync` | TIMESTAMPTZ | nullable — last pull/push |
|
||||
|
||||
**`git_credentials` table:**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | UUID | PK |
|
||||
| `user_id` | UUID | FK → users (owner) |
|
||||
| `name` | TEXT | display name: "GitHub PAT", "GitLab token" |
|
||||
| `auth_type` | TEXT | `https_pat`, `https_basic`, `ssh_key` |
|
||||
| `encrypted_data` | BYTEA | AES-256-GCM (same vault pattern as BYOK) |
|
||||
| `created_at` | TIMESTAMPTZ | |
|
||||
|
||||
Encrypted data contains: `{ "token": "..." }` for PAT, `{ "username": "...",
|
||||
"password": "..." }` for basic, `{ "private_key": "...", "passphrase": "..." }`
|
||||
for SSH.
|
||||
|
||||
### Git Operations (server-side)
|
||||
|
||||
Uses `os/exec` calling `git` binary (not a Go git library — keeps binary size
|
||||
down and avoids CGO). The git binary is already in the Docker image for other
|
||||
purposes.
|
||||
|
||||
```go
|
||||
type GitOps struct {
|
||||
fs *WorkspaceFS
|
||||
credentials CredentialResolver // vault decryption
|
||||
}
|
||||
|
||||
func (g *GitOps) Clone(ctx context.Context, w *models.Workspace, url, branch string, credID string) error
|
||||
func (g *GitOps) Pull(ctx context.Context, w *models.Workspace) error
|
||||
func (g *GitOps) Push(ctx context.Context, w *models.Workspace, message string) error
|
||||
func (g *GitOps) Status(ctx context.Context, w *models.Workspace) (*GitStatus, error)
|
||||
func (g *GitOps) Diff(ctx context.Context, w *models.Workspace, path string) (string, error)
|
||||
func (g *GitOps) Commit(ctx context.Context, w *models.Workspace, message string, paths []string) error
|
||||
func (g *GitOps) Log(ctx context.Context, w *models.Workspace, n int) ([]GitLogEntry, error)
|
||||
func (g *GitOps) BranchList(ctx context.Context, w *models.Workspace) ([]string, string, error)
|
||||
func (g *GitOps) Checkout(ctx context.Context, w *models.Workspace, branch string) error
|
||||
```
|
||||
|
||||
Credential injection: temporary `.git-credentials` file or `GIT_ASKPASS` script
|
||||
created for the duration of the operation, then securely wiped. SSH keys use
|
||||
`GIT_SSH_COMMAND` with a temp key file.
|
||||
|
||||
### Git Tools (category: `workspace`)
|
||||
|
||||
**`git_status`** — show working tree status
|
||||
**`git_diff`** — show diff for file or all changes
|
||||
**`git_commit`** — stage and commit files
|
||||
**`git_log`** — recent commit history
|
||||
**`git_branch`** — list/switch branches
|
||||
|
||||
These are only injected when the workspace has `git_remote_url` set.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```
|
||||
POST /api/v1/workspaces/:id/git/clone # { url, branch, credential_id }
|
||||
POST /api/v1/workspaces/:id/git/pull
|
||||
POST /api/v1/workspaces/:id/git/push
|
||||
GET /api/v1/workspaces/:id/git/status
|
||||
GET /api/v1/workspaces/:id/git/diff?path=
|
||||
POST /api/v1/workspaces/:id/git/commit # { message, paths }
|
||||
GET /api/v1/workspaces/:id/git/log?n=
|
||||
GET /api/v1/workspaces/:id/git/branches
|
||||
POST /api/v1/workspaces/:id/git/checkout # { branch }
|
||||
|
||||
# Credential management
|
||||
POST /api/v1/git-credentials # create
|
||||
GET /api/v1/git-credentials # list (user-scoped)
|
||||
DELETE /api/v1/git-credentials/:id # delete
|
||||
```
|
||||
|
||||
### Post-Operation Hooks
|
||||
|
||||
After `clone`, `pull`, `checkout`: trigger workspace reconcile (sync FS → DB
|
||||
index) and re-index changed files (v0.21.2). This ensures the file tree and
|
||||
search index stay current after git operations.
|
||||
|
||||
### Frontend
|
||||
|
||||
- Workspace settings: git remote URL, credential picker, clone button.
|
||||
- Git credential management in user Settings.
|
||||
- Future (editor surface): git status indicators in file tree, commit UI.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `git_credentials` table (Postgres + SQLite migrations)
|
||||
- [ ] `workspaces` git columns (migration)
|
||||
- [ ] `GitCredentialStore` interface + both implementations
|
||||
- [ ] Vault integration for credential encryption/decryption
|
||||
- [ ] `workspace.GitOps` with exec-based git operations
|
||||
- [ ] Credential injection: temp `.git-credentials` / `GIT_SSH_COMMAND`
|
||||
- [ ] Clone with depth option (shallow clone for large repos)
|
||||
- [ ] Pull, push, status, diff, commit, log, branch, checkout
|
||||
- [ ] Post-operation reconcile + re-index hook
|
||||
- [ ] Git tool implementations (5 tools)
|
||||
- [ ] Git API endpoints (9 endpoints)
|
||||
- [ ] Credential CRUD endpoints (3 endpoints)
|
||||
- [ ] Workspace settings UI: git config section
|
||||
- [ ] User Settings: git credentials management
|
||||
- [ ] Integration tests: clone, commit, push/pull cycle
|
||||
- [ ] `.gitignore` respect in workspace indexing (skip ignored files)
|
||||
- [ ] Security: reject `file://` and local path URLs in clone
|
||||
|
||||
---
|
||||
|
||||
## v0.21.5 — Editor Surface (Development Mode)
|
||||
|
||||
The IDE-like experience. Built entirely as a browser-tier extension consuming
|
||||
workspace primitives from v0.21.0–v0.21.4 and surface infrastructure from
|
||||
v0.21.3.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [surface-header] workspace name | branch | status │
|
||||
├──────────────┬───────────────────┬───────────────────┤
|
||||
│ File Tree │ Code Editor │ AI Chat Panel │
|
||||
│ (sidebar) │ (CM6, main) │ (resizable) │
|
||||
│ │ │ │
|
||||
│ 📁 src/ │ 1│ package main │ You: refactor │
|
||||
│ 📄 main.go│ 2│ │ the handler... │
|
||||
│ 📄 util.go│ 3│ func main() { │ │
|
||||
│ 📁 tests/ │ 4│ ... │ AI: I'll update │
|
||||
│ 📄 go.mod │ 5│ } │ main.go... │
|
||||
│ │ │ │
|
||||
├──────────────┴───────────────────┴───────────────────┤
|
||||
│ [surface-footer] file: src/main.go | Ln 4, Col 12 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
**File Tree** (sidebar-content region)
|
||||
- Tree view backed by `workspace_ls` API (or cached from `Tree()`)
|
||||
- File icons by extension/MIME type
|
||||
- Click to open in editor
|
||||
- Right-click context menu: rename, delete, new file, new folder
|
||||
- Git status indicators (if git-enabled): modified, staged, untracked
|
||||
- Drag-drop file reorder (within folders)
|
||||
|
||||
**Code Editor** (surface-main region)
|
||||
- CM6 `codeEditor()` factory (already exists from v0.17.2)
|
||||
- Language auto-detection from file extension
|
||||
- Tab bar for multiple open files
|
||||
- Modified indicator (dot on tab)
|
||||
- Save: Ctrl/Cmd+S → `workspace_write` API
|
||||
- Auto-save on tab switch / mode switch (configurable)
|
||||
|
||||
**AI Chat Panel** (split pane, resizable)
|
||||
- Reuses the existing chat rendering pipeline
|
||||
- Same channel context — conversation history carries over between modes
|
||||
- Workspace tools auto-injected
|
||||
- Tool call results update the editor live (if the modified file is open)
|
||||
|
||||
**Status Bar** (surface-footer region)
|
||||
- Current file path
|
||||
- Cursor position (line, column)
|
||||
- Language mode
|
||||
- Encoding
|
||||
- Git branch (if applicable)
|
||||
|
||||
### Extension Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "editor-mode",
|
||||
"name": "Development",
|
||||
"version": "1.0.0",
|
||||
"type": "browser",
|
||||
"surfaces": [{
|
||||
"id": "editor",
|
||||
"label": "Editor",
|
||||
"icon": "code-2",
|
||||
"regions": ["surface-main", "surface-footer", "sidebar-content", "surface-header"]
|
||||
}],
|
||||
"permissions": ["workspace"]
|
||||
}
|
||||
```
|
||||
|
||||
### Live Update on Tool Calls
|
||||
|
||||
When the AI calls `workspace_write` or `workspace_patch` and the target file
|
||||
is open in the editor, the editor content updates live. Implementation:
|
||||
|
||||
1. Tool call completes → workspace write handler fires EventBus event
|
||||
`workspace.file.changed` with `{ workspaceId, path }`.
|
||||
2. Editor surface subscribes to this event.
|
||||
3. On match (file is open), fetch fresh content from API and update CM6
|
||||
state via `view.dispatch({ changes })`.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `editor-mode` extension directory structure
|
||||
- [ ] File tree component (tree view, icons, context menu)
|
||||
- [ ] Tab bar component (open files, modified indicator)
|
||||
- [ ] Split pane layout (editor + chat, resizable divider)
|
||||
- [ ] CM6 editor integration with language detection
|
||||
- [ ] Save flow: Ctrl/Cmd+S → API → DB index update
|
||||
- [ ] Auto-save on tab/mode switch
|
||||
- [ ] Live update on `workspace.file.changed` event
|
||||
- [ ] Status bar: file, cursor, language, branch
|
||||
- [ ] Surface registration via manifest
|
||||
- [ ] Git status indicators in file tree (if v0.21.4 landed)
|
||||
- [ ] Mobile: single-pane mode (editor or chat, toggle)
|
||||
- [ ] Keyboard shortcuts: Ctrl+P (quick open), Ctrl+Shift+F (workspace search)
|
||||
- [ ] Quick open: fuzzy file finder using `workspace_files` index
|
||||
|
||||
---
|
||||
|
||||
## v0.21.6 — Article Surface + Document Output Pipeline
|
||||
|
||||
Writing-focused surface. The conversation is secondary — the document is the
|
||||
artifact.
|
||||
|
||||
### Document Output Pipeline
|
||||
|
||||
Before article mode, we need a way for extensions to produce downloadable
|
||||
output. This is the pipeline:
|
||||
|
||||
```
|
||||
extension generates content
|
||||
→ POST /api/v1/workspaces/:id/files/write (save to workspace)
|
||||
→ or POST /api/v1/workspaces/:id/export (convert + save)
|
||||
→ user downloads from workspace or gets direct download link
|
||||
```
|
||||
|
||||
Export endpoint supports format conversion (markdown → HTML, markdown → PDF via
|
||||
pandoc if available, markdown → DOCX). This is a stretch goal — initial version
|
||||
just saves to workspace and lets users download the raw file.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [surface-header] Document Title [Export ▾] │
|
||||
├──────────────┬───────────────────────────────────────┤
|
||||
│ Outline │ Rich Text Editor (CM6 markdown) │
|
||||
│ │ │
|
||||
│ § Intro │ # My Article │
|
||||
│ § Methods │ │
|
||||
│ § Data │ Compelling introduction paragraph │
|
||||
│ § Analysis│ about the topic at hand... │
|
||||
│ § Results │ │
|
||||
│ § Discussion│ ## Methods │
|
||||
│ │ │
|
||||
│──────────────│ ### Data Collection │
|
||||
│ AI Assistant │ │
|
||||
│ │ We gathered data from... │
|
||||
│ "Expand the │ │
|
||||
│ methods │ │
|
||||
│ section..." │ │
|
||||
├──────────────┴───────────────────────────────────────┤
|
||||
│ [surface-footer] Words: 1,234 | Reading: ~5 min │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
**Outline** (sidebar-content, top section)
|
||||
- Auto-generated from heading structure in document
|
||||
- Click to scroll to section
|
||||
- Drag to reorder sections
|
||||
|
||||
**AI Assistant** (sidebar-content, bottom section)
|
||||
- Lightweight chat interface for document-focused prompts
|
||||
- Tools: `workspace_read`, `workspace_write`, `workspace_search`
|
||||
- Additional article tools:
|
||||
- `suggest_outline` — propose structure for a topic
|
||||
- `expand_section` — flesh out a heading with content
|
||||
- `check_citations` — verify URLs are reachable (via url_fetch)
|
||||
|
||||
**Rich Text Editor** (surface-main)
|
||||
- CM6 `noteEditor()` factory (from v0.17.3) with enhanced live preview
|
||||
- Heading decorations, blockquotes, code blocks, links
|
||||
- Focus mode: dim everything except current section
|
||||
|
||||
**Export** (surface-header dropdown)
|
||||
- Download as Markdown (.md)
|
||||
- Download as HTML (rendered)
|
||||
- Download as PDF (if pandoc available)
|
||||
- Copy to clipboard (markdown)
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `article-mode` extension directory structure
|
||||
- [ ] Outline component (auto-generated from headings, click-to-scroll)
|
||||
- [ ] Sidebar split: outline (top) + AI assistant (bottom)
|
||||
- [ ] Rich text editor with enhanced markdown live preview
|
||||
- [ ] Export dropdown: markdown, HTML, PDF
|
||||
- [ ] Article-specific tools: `suggest_outline`, `expand_section`
|
||||
- [ ] Focus mode (dim non-active sections)
|
||||
- [ ] Word count + reading time in status bar
|
||||
- [ ] Surface registration via manifest
|
||||
- [ ] Auto-save document to workspace
|
||||
- [ ] Mobile: outline collapses to top dropdown
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
Each sub-release gets its own migration file(s), both Postgres and SQLite.
|
||||
Migrations run on backend startup (existing pattern). Numbering continues
|
||||
from v0.20.0's last migration.
|
||||
|
||||
### Test Strategy
|
||||
|
||||
- Backend integration tests against both Postgres and SQLite for every store
|
||||
method and handler (existing CI matrix pattern).
|
||||
- Workspace FS tests: use a temp directory, not the real PVC.
|
||||
- Tool execution tests: mock workspace with pre-populated files.
|
||||
- Surface tests: manual testing + REPL verification (surface infrastructure
|
||||
is frontend-only, no backend tests).
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- Channels without workspaces work exactly as before.
|
||||
- Projects without workspaces work exactly as before.
|
||||
- Archive uploads to channels without workspaces go to attachment store.
|
||||
- Workspace tools only appear when a workspace is bound.
|
||||
|
||||
### Documentation Updates
|
||||
|
||||
Each sub-release updates:
|
||||
- `VERSION` → `0.21.N`
|
||||
- `CHANGELOG.md` — detailed entry
|
||||
- `ARCHITECTURE.md` — workspace section, surface system section
|
||||
- `EXTENSIONS.md` — §6 update for surface implementation
|
||||
- `ROADMAP.md` — mark completed items
|
||||
|
||||
### Deployment
|
||||
|
||||
- PVC: ensure `workspaces/` directory is created at startup
|
||||
(same pattern as `attachments/` directory creation).
|
||||
- Git binary: verify `git` is in the Docker image (add to Dockerfile
|
||||
if not present).
|
||||
- Embedding role: workspace indexing gracefully degrades when no embedding
|
||||
model is configured (files still work, search doesn't).
|
||||
- Quota defaults: `WORKSPACE_MAX_BYTES=524288000` (500MB),
|
||||
`WORKSPACE_MAX_FILES=10000`.
|
||||
Reference in New Issue
Block a user