Changeset 0.15.0 (#71)

This commit is contained in:
2026-02-26 21:19:55 +00:00
parent e2149e249d
commit 2d79ff593b
35 changed files with 3218 additions and 504 deletions

274
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,274 @@
# Architecture — Chat Switchboard v0.11
## Deployment Modes
Three Docker images support different deployment scenarios:
| Image | Dockerfile | Contents | Use Case |
|-------|-----------|----------|----------|
| **Unified** | `Dockerfile` | nginx + Go backend | Dev, docker-compose, single-node |
| **Backend** | `server/Dockerfile` | Go binary only | K8s — scale API independently |
| **Frontend** | `Dockerfile.frontend` | nginx + static files | K8s — scale FE independently |
**Unified** bundles everything in one container. Nginx serves static files and proxies `/api/*` and `/ws` to the Go backend running on `:8080`. Good for development and small deployments.
**Split (Backend + Frontend)** for production K8s: Ingress routes `/api/*` and `/ws` to the backend Service, everything else to the frontend Service. The frontend entrypoint (`docker-entrypoint-fe.sh`) handles `BASE_PATH` injection into `index.html` and dynamic nginx config generation at startup. Supports branding volume mounts at `/branding/`.
```
┌─────────────────────────┐
│ Ingress / Traefik │
│ ├─ /api/* → be-svc:8080 │
│ ├─ /ws → be-svc:8080 │
│ └─ /* → fe-svc:80 │
└─────────────────────────┘
│ │
┌──────────▼──┐ ┌───────▼────────┐
│ Backend │ │ Frontend │
│ (Go :8080) │ │ (nginx :80) │
│ replicas:N │ │ replicas:M │
└──────┬──────┘ └────────────────┘
┌──────▼──────┐
│ PostgreSQL │
└─────────────┘
```
## Design Principles
1. **Persona-as-Trust-Boundary**: A persona (model + config + prompt) is the unit of access control. Users interact with personas, not raw provider configs. Admins control which models are visible; team admins control which personas their team can use.
2. **Roles vs Teams**: Clean separation between vertical permissions (Roles: admin, user) and horizontal visibility (Teams: organizational units). A user's role determines what they can *do*; their team membership determines what they can *see*.
3. **Store Layer**: All database access goes through typed Go interfaces (`store.Stores`). Handlers never write raw SQL. This enables future portability (SQLite for dev, Postgres for prod) and testability (mock stores).
4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope.
5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh.
6. **Channels as Execution Context**: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure.
## Workflow Architecture (Future — v0.21.0+)
The platform's existing primitives (teams, personas, channels, tools, notes)
compose into a workflow engine where the channel is the execution context
that moves through defined stages.
### Conceptual Model
```
Team
└─ Workflow (team-admin defined)
├─ name, description
├─ entry conditions (public link, team-internal, API trigger)
└─ Stages[]
├─ persona_id (which AI drives this stage)
├─ assignment_team_id (who can be assigned)
├─ form_template (structured note schema, optional)
└─ transitions[] (conditions → next stage)
Channel (workflow instance)
├─ workflow_id + current_stage
├─ participants[]
│ ├─ anonymous visitor (mTLS fingerprint / session token)
│ ├─ AI persona (per-stage, from workflow definition)
│ └─ assigned team member (claimed or auto-routed)
├─ messages (existing tree structure)
├─ notes (channel-scoped artifacts, intake forms)
└─ tool activity (existing execution framework)
```
### How Existing Primitives Map
| Existing Primitive | Workflow Role |
|-------------------|---------------|
| **Persona** (system prompt + model) | Drives a workflow stage — the AI knows what to collect, when to route |
| **Team** (members + roles) | Owns the workflow definition; members are assignable to channels |
| **Channel** (messages + tree) | Execution instance of a workflow; full conversation history |
| **Notes + Tools** | Structured data collection; the persona's system prompt *is* the form definition, the note *is* the filled form |
| **EventBus + WebSocket** | Real-time notifications for assignment, stage transitions, new messages |
### Design Constraints for Current Development
These invariants keep the workflow path open without building it prematurely:
- **Channels**: Don't assume single-owner. If touching channel queries, keep
room for `team_id`, `type` (beyond `direct`), and multi-participant access
patterns alongside `user_id`.
- **Notes**: Currently user-scoped. Future channel-scoped notes (attached to
a conversation, not a personal notebook) need a `channel_id` FK option.
- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown"
— a workflow stage references a persona programmatically.
- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
`TeamID` and `WorkflowID` — the struct is easily extended.
- **Auth**: mTLS anonymous users need identity to participate in channels.
Lightest version: a `participants` table that can reference a `user_id` or
an opaque session identifier (cert fingerprint). Don't assume every channel
participant has a row in `users`.
## Package Structure
```
server/
├── main.go # Wiring: stores → handlers → routes
├── config/config.go # Env-based configuration
├── database/
│ ├── database.go # Connection management
│ ├── migrate.go # Auto-migration on startup
│ └── migrations/
│ └── 001_v09_schema.sql # Consolidated schema
├── store/
│ ├── interfaces.go # Store interfaces + shared types
│ └── postgres/ # Postgres implementations
│ ├── stores.go # NewStores() constructor
│ ├── provider.go # ProviderStore
│ ├── catalog.go # CatalogStore
│ ├── persona.go # PersonaStore
│ ├── user.go # UserStore
│ ├── team.go # TeamStore
│ ├── policy.go # PolicyStore
│ ├── audit.go # AuditStore
│ └── ...
├── models/models.go # Shared domain types
├── capabilities/
│ ├── intrinsic.go # Heuristic detection + resolution
│ └── resolver.go # ModelsForUser() unified resolver
├── handlers/ # HTTP handlers (Gin)
│ ├── auth.go # Login, register, refresh, logout
│ ├── admin.go # User/config/model management
│ ├── channels.go # Channel CRUD
│ ├── messages.go # Message CRUD + forking
│ ├── completion.go # Chat completions (SSE streaming)
│ ├── capabilities.go # Model list + ResolveModelCaps
│ ├── presets.go # Persona CRUD (all scopes)
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
│ ├── teams.go # Team management
│ └── ...
├── providers/ # LLM provider adapters
│ ├── provider.go # Provider interface
│ ├── anthropic.go
│ ├── openai.go
│ ├── openrouter.go
│ └── venice.go
├── middleware/ # Auth, admin, CORS, rate limiting
├── events/ # EventBus + WebSocket hub
└── tools/ # Built-in tool definitions (notes)
```
## Store Layer Pattern
Every store follows the same pattern:
```go
// Interface in store/interfaces.go
type FooStore interface {
Create(ctx context.Context, f *models.Foo) error
GetByID(ctx context.Context, id string) (*models.Foo, error)
Update(ctx context.Context, id string, patch models.FooPatch) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, opts ListOptions) ([]models.Foo, int, error)
}
// Implementation in store/postgres/foo.go
type fooStore struct{ db *sql.DB }
func (s *fooStore) Create(ctx context.Context, f *models.Foo) error { ... }
```
Handlers receive `store.Stores` (a bundle of all store interfaces):
```go
type AdminHandler struct { stores store.Stores }
func (h *AdminHandler) CreateUser(c *gin.Context) {
// ...
err := h.stores.Users.Create(c.Request.Context(), user)
}
```
## Capabilities Resolution Chain
When the system needs to know what a model can do (vision? tools? thinking?):
```
1. model_catalog DB (exact match: model_id + provider_config_id)
↓ miss
2. model_catalog DB (any provider: same model_id)
↓ miss
3. Heuristic inference (name-based: "gpt-4-vision" → vision=true)
```
The `capabilities.ModelsForUser()` function combines catalog entries, team personas, and user preferences into a single unified model list for the frontend.
## Scope / Ownership Model
```
scope='global' → owner_id=NULL → Admin-managed, visible to all
scope='team' → owner_id=team.id → Team-admin-managed, visible to team
scope='personal' → owner_id=user.id → User-managed, visible to owner
```
Used by: `provider_configs`, `personas`, `model_catalog` (visibility column adds `enabled`/`disabled`/`team` states on top).
## Schema Migration
Single consolidated migration (`001_v09_schema.sql`) replaces the previous 21 incremental migrations. The Go backend auto-migrates on startup:
1. Creates `schema_migrations` table if absent
2. Checks which migration files have been applied
3. Applies any new `.sql` files in order
## Frontend Architecture
Vanilla JavaScript, no build step. Five files with clear responsibilities:
| File | Role |
|------|------|
| `api.js` | HTTP client with token refresh. All backend calls. |
| `app.js` | Application state machine. Business logic. |
| `ui.js` | DOM rendering. All `document.createElement` calls. |
| `events.js` | Labeled event bus with WebSocket bridge. |
| `debug.js` | Admin debug panel (model list, stats, config). |
Communication: `app.js` calls `API.*` methods, updates state, then calls `UI.*` methods to render. `events.js` handles real-time updates via WebSocket. No framework, no virtual DOM, no reactive bindings.
## Security Model
- **Auth**: JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable)
- **Admin Bootstrap**: `SWITCHBOARD_ADMIN_USERNAME`/`PASSWORD` env vars create/update admin on every startup (K8s secret pattern)
- **API Key Storage**: Provider API keys encrypted with AES-256-GCM. Two-tier: org keys use `ENCRYPTION_KEY` env var, personal BYOK keys use per-user encryption keys (admins cannot recover)
- **Policies**: Boolean flags in `global_settings` table control registration, BYOK, team providers, etc.
- **Audit**: All admin operations logged to `audit_log` with actor, action, resource type/ID, and diff
- **Banner**: Environment classification banner configurable via admin settings (text, color, position)
- **CORS**: Configurable allowed origins via env var
- **No sensitive terminology**: Banner system avoids classification-related terms for security compliance
## File Storage
Blob storage for attachments, extracted text, and (future) knowledge base documents.
**Interface**: `storage.ObjectStore``Put`, `Get`, `Delete`, `DeletePrefix`, `Exists`, `Healthy`, `Stats`, `Backend`. All handlers use the interface; backend is selected at startup.
**Backends**:
| Backend | Config | Use Case |
|---------|--------|----------|
| **PVC** | `STORAGE_BACKEND=pvc` + `STORAGE_PATH` | Single-node, dev, docker-compose. Local filesystem with atomic writes (temp+rename). |
| **S3** | `STORAGE_BACKEND=s3` + `S3_ENDPOINT`, `S3_BUCKET`, credentials | Multi-node production. MinIO, Ceph RGW, AWS S3. Uses minio-go v7. |
| **Auto** | `STORAGE_BACKEND=` (empty) | Tries PVC at `STORAGE_PATH`; disables if not writable. |
**Key layout**: `attachments/{channel_id}/{attachment_id}_{filename}`. S3 prefix (`S3_PREFIX`) prepended for shared buckets.
**PVC always mounted**: Even with S3 backend, the PVC mount at `STORAGE_PATH` is needed for the extraction queue's local scratch directory (`processing/{id}/status.json`). With S3, the PVC can be small (1Gi) — only transient coordination state, not blobs.
**Admin panel**: Settings → Storage tab shows backend type, health status, file count, total size, endpoint/bucket (S3) or path (PVC), orphan detection and cleanup.
## Backward Compatibility
v0.9 maintains backward-compatible API routes:
| Old Route | New Handler | Notes |
|-----------|-------------|-------|
| `/api/v1/presets` | PersonaHandler | Returns both `personas` and `presets` keys |
| `/api/v1/api-configs` | ProviderConfigHandler | Unchanged route, new implementation |
| `/api/v1/models` | ModelHandler.ListEnabledModels | Alias for `/models/enabled` |
JSON field rename: `api_config_id``provider_config_id` in channel and completion request/response bodies. Frontend updated to match.

View File

@@ -0,0 +1,93 @@
# v0.15.0 Phase 1 — Changeset Notes
## Files included in this zip (new or fully replaced)
```
server/treepath/path.go NEW — PathMessage, GetActivePath, etc.
server/treepath/summary.go NEW — IsSummaryMessage, FindSummaryBoundary
server/treepath/cursor.go NEW — UpdateCursor, NextSiblingIndex
server/treepath/siblings.go NEW — GetSiblingCount, GetSiblings, FindLeafFromMessage
server/compaction/compaction.go NEW — Service, Compact, CheckRateLimit
server/handlers/tree.go REPLACED — thin wrappers delegating to treepath
server/handlers/summarize.go REPLACED — thin HTTP wrapper using compaction.Service
src/js/settings-handlers.js REPLACED — bug fixes for display name + role config
```
## Surgical edits needed in existing files
### 1. `server/handlers/completion.go`
**Delete** the `isSummaryMessage` function (lines ~832841). It is now
defined in `treepath/summary.go` and aliased in `handlers/tree.go`.
Without this deletion, the build fails with a duplicate function error.
```go
// DELETE this entire function from completion.go:
// isSummaryMessage checks if a PathMessage has summary metadata.
func isSummaryMessage(m *PathMessage) bool {
if m.Metadata == nil {
return false
}
var meta map[string]interface{}
if err := json.Unmarshal(*m.Metadata, &meta); err != nil {
return false
}
return meta["type"] == "summary"
}
```
No other changes needed in completion.go — the `isSummaryMessage` calls
throughout the file now resolve via the alias in `handlers/tree.go`.
### 2. `server/main.go`
**Change** the summarize handler wiring (~line 252). Old constructor
takes `(stores, roleResolver)`, new one takes `(*compaction.Service)`.
```go
// ── BEFORE ──
// Summarize & Continue
summarize := handlers.NewSummarizeHandler(stores, roleResolver)
protected.POST("/channels/:id/summarize", summarize.Summarize)
// ── AFTER ──
// Compaction service (shared by manual summarize handler + future auto-scanner)
compactionSvc := compaction.NewService(stores, roleResolver)
// Summarize & Continue (manual compaction via HTTP)
summarize := handlers.NewSummarizeHandler(compactionSvc)
protected.POST("/channels/:id/summarize", summarize.Summarize)
```
**Add import** at top of main.go:
```go
"git.gobha.me/xcaliber/chat-switchboard/compaction"
```
## Bug fixes included
### Display Name not saved or used
`handleSaveSettings()` now also saves `display_name` and `email` via
`API.updateProfile()`. Updates `API.user` and calls `UI.updateUser()` so
the sidebar reflects the change immediately.
### User utility model role not displayed after save
`_initUserRolePrimitive()``fetchModels` callback now calls the
global `fetchModels()` (which refreshes `App.models` from the server)
before returning the model list. This ensures recently added personal
provider models appear in the role config dropdown after tab switch or
save-refresh.
## Verification
After applying all changes:
1. `cd server && go build .` — should compile cleanly
2. Existing integration tests should pass (no behavior change)
3. Manual test: Settings → Profile → change display name → Save → verify
sidebar updates and value persists on reload
4. Manual test: Settings → Model Roles → pick personal provider + model →
Save → verify dropdown shows saved values after refresh

View File

@@ -0,0 +1,186 @@
# v0.15.0 Phase 2 — Changeset Notes
## Files included in this zip (new)
```
server/compaction/estimator.go NEW — EstimateTokens, EstimatePath, EstimatePathFromSummary
server/compaction/estimator_test.go NEW — unit tests (no DB required)
server/compaction/scanner.go NEW — Scanner, scan loop, candidate query, shouldCompact
```
## Surgical edits needed in existing files
### 1. `server/main.go`
Phase 1 already introduced `compactionSvc`. Now add the scanner below it
(after the `compactionSvc` line, before route registration):
```go
import "git.gobha.me/xcaliber/chat-switchboard/compaction" // already present from Phase 1
// ── Auto-Compaction Scanner ───────────
compactionScanner := compaction.NewScanner(compactionSvc, stores, compaction.ScannerConfig{
Interval: 5 * time.Minute,
Concurrency: 2,
})
compactionScanner.Start()
defer compactionScanner.Stop() // drain in-flight compactions on shutdown
```
Place `defer compactionScanner.Stop()` near the existing
`defer kbIngester.Wait()` line.
**Note:** The scanner re-reads all config from `global_settings` each
tick. The `ScannerConfig` struct only sets the ticker interval and
semaphore size at startup. Everything else (enabled, threshold, cooldown)
is dynamic.
---
### 2. `src/index.html`
Add an auto-compaction section to the admin Settings tab. Insert **before**
the Encryption section (before `<section class="admin-section">` with
`🔐 Encryption`):
```html
<section class="settings-section">
<h3>Auto-Compaction</h3>
<label class="checkbox-label"><input type="checkbox" id="adminCompactionEnabled"> Enable automatic conversation compaction</label>
<p class="section-hint">When enabled, long conversations are automatically summarized in the background using the utility model. Ships off by default — requires a utility model role to be configured.</p>
<div id="compactionConfigFields" style="display:none;margin-top:8px">
<div class="form-row">
<div class="form-group">
<label>Threshold</label>
<div class="form-row" style="gap:4px;align-items:center">
<input type="number" id="adminCompactionThreshold" value="70" min="10" max="95" step="5" style="width:70px">
<span class="form-hint">% of context window</span>
</div>
</div>
<div class="form-group">
<label>Cooldown</label>
<div class="form-row" style="gap:4px;align-items:center">
<input type="number" id="adminCompactionCooldown" value="30" min="5" max="360" step="5" style="width:70px">
<span class="form-hint">minutes per channel</span>
</div>
</div>
</div>
</div>
</section>
```
---
### 3. `src/js/ui-admin.js` — `loadAdminSettings()`
Add these lines **after** the Web Search config loading block
(after `document.getElementById('searxngConfigFields').style.display = ...`):
```javascript
// Auto-Compaction (global_settings)
const compactionCfg = getSetting('auto_compaction', {}) || {};
const compactionEnabled = document.getElementById('adminCompactionEnabled');
if (compactionEnabled) {
compactionEnabled.checked = !!compactionCfg.enabled;
document.getElementById('compactionConfigFields').style.display =
compactionCfg.enabled ? '' : 'none';
}
const compThreshold = document.getElementById('adminCompactionThreshold');
if (compThreshold) compThreshold.value = String(compactionCfg.threshold || 70);
const compCooldown = document.getElementById('adminCompactionCooldown');
if (compCooldown) compCooldown.value = String(compactionCfg.cooldown || 30);
```
---
### 4. `src/js/settings-handlers.js` — `handleSaveAdminSettings()`
Add these lines **after** the `search_config` save block (before
`UI.toast('Settings saved', 'success')`):
```javascript
// Auto-Compaction config → global_settings
const compactionEnabled = document.getElementById('adminCompactionEnabled')?.checked || false;
const compactionThreshold = parseInt(document.getElementById('adminCompactionThreshold')?.value) || 70;
const compactionCooldown = parseInt(document.getElementById('adminCompactionCooldown')?.value) || 30;
await API.adminUpdateSetting('auto_compaction', { value: {
enabled: compactionEnabled,
threshold: compactionThreshold,
cooldown: compactionCooldown,
}});
// Persist individual keys for scanner (reads these independently)
await API.adminUpdateSetting('auto_compaction_enabled', { value: compactionEnabled });
await API.adminUpdateSetting('auto_compaction_threshold', { value: compactionThreshold / 100 });
await API.adminUpdateSetting('auto_compaction_cooldown_minutes', { value: compactionCooldown });
```
---
### 5. `src/js/settings-handlers.js` — `_initSettingsListeners()`
Add a toggle listener for the compaction checkbox, alongside the existing
banner toggle. Add **after** the `adminBannerEnabled` change listener:
```javascript
document.getElementById('adminCompactionEnabled')?.addEventListener('change', (e) => {
document.getElementById('compactionConfigFields').style.display = e.target.checked ? '' : 'none';
});
```
---
### 6. `src/js/ui-core.js` — `_summaryHTML()`
Show "(auto)" label on auto-triggered summaries. The `trigger` field is
already in message metadata from Phase 1.
**Replace** the `_summaryHTML` method:
```javascript
_summaryHTML(msg) {
const meta = msg.metadata || {};
const count = meta.summarized_count || '?';
const model = meta.utility_model || 'utility model';
const trigger = meta.trigger === 'auto' ? ' · auto' : '';
return `
<div class="message-summary" data-msg-id="${esc(msg.id || '')}">
<div class="summary-header">
<span>📝 Conversation summary (${count} messages, by ${esc(model)}${trigger})</span>
</div>
<div class="msg-text">${formatMessage(msg.content)}</div>
</div>`;
},
```
---
## How the scanner reads settings
The scanner calls `GlobalConfig.Get()` **every tick** for these keys:
| Key | Read by | How stored |
|-----|---------|------------|
| `auto_compaction_enabled` | `isEnabled()` | `{value: true/false}` |
| `auto_compaction_threshold` | `getThreshold()` | `{value: 0.70}` (float 0-1) |
| `auto_compaction_cooldown_minutes` | `getCooldownDuration()` | `{value: 30}` |
The admin UI stores a combined `auto_compaction` key (for display) AND
the individual keys (for scanner consumption). This avoids the scanner
needing to parse a composite object.
Interval and concurrency are set at startup via `ScannerConfig` and
require a restart to change. This is intentional — they affect the
goroutine pool size and ticker, which shouldn't be hot-swapped.
## Verification
1. `cd server && go test ./compaction/` — estimator tests pass (no DB)
2. `cd server && go build .` — compiles cleanly
3. Manual test: Admin → Settings → enable auto-compaction → Save →
check server logs for `🔍 compaction scanner started`
4. Manual test: with utility role configured and a long conversation,
verify auto-compaction fires after the threshold is exceeded
5. Verify cooldown: same channel should not re-compact within 30 minutes
6. Verify kill switch: disable auto-compaction → scanner stops processing
on next tick

View File

@@ -0,0 +1,106 @@
# v0.15.0 Phase 4 — Guard Rail + Integration Tests
## Files
```
server/compaction/compaction.go UPDATED — context budget guard rail
server/compaction/compaction_test.go NEW — scanner integration tests (DB required)
server/compaction/testmain_test.go NEW — TestMain for compaction package
server/database/seed_helpers.go NEW — SeedTestMessage, SeedTestMessages, SeedTestCursor
server/handlers/live_compaction_test.go NEW — e2e tests with Venice/Qwen3-4B
```
## Changes
### 1. Context Budget Guard Rail (`compaction.go`)
Added between prompt construction and LLM call. Prevents sending
truncated input to small utility models.
**How it works:**
1. Estimates total prompt tokens (system + conversation content)
2. Resolves the utility model's `max_context` from catalog
3. Reserves 20% for output (the summary itself)
4. If `estimated_prompt > max_context × 0.80`, returns `ErrContextBudget`
**Resolution chain** for utility model context:
- `Resolver.GetConfig()` → primary binding → `Catalog.GetByModelID()`
- Fallback: `Catalog.GetByModelIDAny()` (different provider)
- Hard fallback: `DefaultBudget` (128K)
The scanner's `doCompact()` already handles errors gracefully — a budget
error just logs a warning and retries next cycle (by which time the
conversation may have been manually compacted or the admin may have
upgraded the utility model).
**New export:** `ErrContextBudget` — callers can use `errors.Is()` to
distinguish budget failures from LLM errors.
### 2. Test Seed Helpers (`database/seed_helpers.go`)
New file in the `database` package alongside `testhelper.go`:
- `SeedTestMessage(t, channelID, parentID, role, content) → msgID`
- `SeedTestMessages(t, channelID, count, contentSize) → []msgIDs`
Creates a linear alternating user/assistant chain
- `SeedTestCursor(t, channelID, userID, leafID)`
Sets the active leaf for tree path resolution
### 3. Scanner Integration Tests (`compaction/compaction_test.go`)
All DB-dependent tests use `database.RequireTestDB(t)` and skip
gracefully when no DB is configured.
| Test | What it verifies |
|------|-----------------|
| `FindCandidates_ReturnsQualifying` | Channel with ≥10 msgs, ≥20K chars, proper age range appears |
| `FindCandidates_ExcludesTooFewMessages` | <10 messages → excluded |
| `FindCandidates_ExcludesTooRecent` | Updated just now → excluded (activity gap) |
| `FindCandidates_ExcludesArchived` | `is_archived=true` → excluded |
| `Cooldown` | Timestamps recorded and checked correctly |
| `InFlightDedup` | sync.Map prevents concurrent compaction of same channel |
| `ShouldCompact_ChannelOptOut` | `settings.auto_compaction=false` → rejected |
| `IsEnabled` | Reads `auto_compaction_enabled` from global_settings |
| `GetThreshold_Default` | Returns 0.70 when no override |
| `GetThreshold_GlobalOverride` | Reads from global_settings |
| `GetThreshold_ChannelOverride` | Channel setting takes precedence |
| `GetCooldownDuration_Default` | Returns 30min default |
| `GetCooldownDuration_Override` | Reads from global_settings |
| `GuardRailMath` | Validates token math for 32K vs 128K models |
### 4. Live E2E Tests (`handlers/live_compaction_test.go`)
Requires: `TEST_DATABASE_URL` + `VENICE_API_KEY`
| Test | What it verifies |
|------|-----------------|
| `CompactionFullPipeline` | Seeds 10 realistic messages → Compact() → verifies summary message in DB, metadata fields, cursor update, usage log entry |
| `CompactionContextBudgetGuardRail` | Seeds 160K chars of content → Compact() returns ErrContextBudget with 32K model |
## Running Tests
```bash
# Unit only (no DB)
cd server && go test ./compaction/ -run TestEstimate -v
# Integration (requires DB)
cd server && go test ./compaction/ -v
# Live e2e (requires DB + Venice key)
cd server && VENICE_API_KEY=... go test ./handlers/ -run TestLive_Compaction -v
```
## Surgical Edit
### `database/testhelper.go`
Add this import if not already present (the new `seed_helpers.go` file
is a separate file in the same package, so no edit needed to
`testhelper.go` itself):
```go
// No changes needed — seed_helpers.go is a new file in package database
```
The `strings` import in `seed_helpers.go` is used by `SeedTestMessages`
for `strings.Repeat`.

1304
docs/DESIGN-0.12.0.md Normal file

File diff suppressed because it is too large Load Diff

231
docs/DESIGN-0.13.1.md Normal file
View File

@@ -0,0 +1,231 @@
# DESIGN-0.13.1 — Web Search + URL Fetch + Tool Toggle
## Overview
Built-in `web_search` and `url_fetch` tools using the existing tool framework (v0.11.0),
plus a chat-bar tools toggle menu so users can enable/disable tool categories per-session.
Depends on: tool framework (v0.11.0), admin panel (v0.13.0).
---
## 1. Tool Categories
Add a `Category` field to `ToolDef` so tools self-declare their group.
The frontend uses categories for the toggle menu; the backend ignores them.
| Category | Tools | Default |
|-----------|---------------------------------------------|---------|
| search | `web_search`, `url_fetch` | ON |
| notes | `note_create`, `note_search`, `note_update`, `note_list` | ON |
| utilities | `datetime`, `calculator` | ON |
| browser | (extension-defined: `js_eval`, `regex_test`)| ON |
```go
type ToolDef struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"` // human-readable label for UI
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
Category string `json:"category,omitempty"` // NEW
}
```
## 2. Per-Request Tool Filtering
### Request field
```json
{ "disabled_tools": ["web_search", "url_fetch"] }
```
### Backend filter
`buildToolDefs()` skips tools whose name appears in `disabled_tools`.
Zero API changes — the field is optional, empty = all tools enabled.
### Frontend state
`localStorage` key: `cs-disabled-tools` → JSON array of tool names.
The tools toggle menu flips categories on/off which maps to individual tool names.
## 3. web_search Tool
### Search Provider Interface
```go
type SearchProvider interface {
Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error)
Name() string
}
type SearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
}
```
### DuckDuckGo Provider (default — no API key)
Uses the DuckDuckGo HTML search endpoint. No rate-limit key required.
Falls back gracefully if blocked (returns error result to LLM, doesn't crash).
### SearXNG Provider (self-hosted)
Configurable endpoint + optional API key. JSON API (`/search?format=json`).
Good fit for airgapped deployments.
### Admin Config
Stored in `global_config` table (existing):
- `search_provider`: `"duckduckgo"` | `"searxng"` (default: `"duckduckgo"`)
- `search_endpoint`: SearXNG URL (only used when provider=searxng)
- `search_max_results`: `5` (default)
Admin UI: System → Settings section (new "Search" subsection).
### Tool Schema
```json
{
"name": "web_search",
"description": "Search the web for current information. Returns titles, URLs, and snippets.",
"category": "search",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"max_results": { "type": "integer", "description": "Max results (1-10, default 5)" }
},
"required": ["query"]
}
}
```
## 4. url_fetch Tool
HTTP GET with content extraction. Reuses the text extraction pattern
from file handling (v0.12.0) where possible.
### Behavior
1. HTTP GET with reasonable timeout (15s) and User-Agent
2. Follow redirects (max 3)
3. Extract readable text content (strip HTML tags, scripts, styles)
4. Truncate to ~8000 chars to avoid flooding the context
5. Return title + extracted text + metadata
### Safety
- Respect robots.txt? No — the LLM is fetching on behalf of the user, not crawling.
- Block private/internal IPs (10.x, 192.168.x, 127.x, ::1) to prevent SSRF.
- Configurable domain allowlist/blocklist in admin settings (future).
### Tool Schema
```json
{
"name": "url_fetch",
"description": "Fetch and extract readable text content from a URL.",
"category": "search",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "URL to fetch" },
"max_length": { "type": "integer", "description": "Max content length in chars (default 8000)" }
},
"required": ["url"]
}
}
```
## 5. Frontend: Tools Toggle Menu
### UI Position
Chat input bar, left side, next to the attach button. Wrench/tool icon.
```
┌──────────────────────────────────────┐
│ 📎 🔧 [message input...] [Send]│
└──────────────────────────────────────┘
┌────────────────┐
│ ☑ Web Search │
│ ☑ Notes │
│ ☑ Utilities │
│ ☑ Extensions │
└────────────────┘
```
### Behavior
- Click tool icon → popup above (using existing popup-menu pattern)
- Each category row has a toggle checkbox + expand chevron
- **Category toggle**: master on/off for all tools in the category
- **Expand chevron**: shows individual tool rows indented below
- **Tri-state checkbox**: if some tools in a category are disabled, category shows indeterminate (─)
- Toggling a category adds/removes all its tool names from `cs-disabled-tools`
- Toggling an individual tool adds/removes just that name
- State persists across page reloads via localStorage
- Disabled tools sent as `disabled_tools` array in completion request
- Tool icon shows visual indicator when any category is disabled
- If model doesn't support tool_calling, icon is hidden/grayed
```
┌────────────────────────┐
│ ☑ 🔍 Web Search │ ← click to expand
│ ☑ Search │
│ ☑ Fetch URL │
│ ☑ 📝 Notes
│ ☐ 🧮 Utilities │ ← category off = all children off
│ ☑ 🧩 Extensions
└────────────────────────┘
```
### Tool Manifest
Frontend needs to know the category mapping and display names.
**API endpoint**`GET /api/v1/tools` returns `[{name, display_name, category, description}]`
The frontend caches this on login. `display_name` provides human-readable labels
(e.g. "Search" instead of "web_search", "Create" instead of "note_create").
### New API Endpoint
```
GET /api/v1/tools → [{name, description, category}]
```
Returns server-side tools + browser extension tool schemas.
Used by frontend to build the toggle menu dynamically.
## 6. File Structure
### New backend files
- `server/tools/websearch.go` — web_search tool + search provider interface
- `server/tools/urlfetch.go` — url_fetch tool
- `server/tools/search/duckduckgo.go` — DuckDuckGo provider
- `server/tools/search/searxng.go` — SearXNG provider
- `server/tools/search/provider.go` — interface + registry
### Modified backend files
- `server/tools/types.go` — add Category to ToolDef
- `server/tools/registry.go` — add AllDefinitionsFiltered(disabled)
- `server/handlers/completion.go` — accept disabled_tools, filter in buildToolDefs
- `server/handlers/messages.go` — same filtering
- `server/handlers/routes.go` — add GET /api/v1/tools endpoint
- `server/tools/datetime.go` — add Category: "utilities"
- `server/tools/calculator.go` — add Category: "utilities"
- `server/tools/notes.go` — add Category: "notes"
### New/modified frontend files
- `src/index.html` — tools toggle button in input-wrap
- `src/css/styles.css` — tools popup styling
- `src/js/chat.js` — send disabled_tools in completion, wire toggle
- `src/js/api.js` — add disabled_tools param, add getTools() method
## 7. Phased Delivery
**Phase 1: Backend tools + filtering**
- ToolDef.Category, disabled_tools filtering
- web_search + url_fetch tools
- DuckDuckGo provider
- GET /api/v1/tools endpoint
- Tests
**Phase 2: Frontend toggle**
- Tools toggle popup menu
- localStorage persistence
- Completion request wiring
- CSS
**Phase 3: Admin config + SearXNG**
- Search provider settings in admin panel
- SearXNG provider implementation
- Domain filtering

845
docs/DESIGN-0.14.0.md Normal file
View File

@@ -0,0 +1,845 @@
# DESIGN-0.14.0 — Knowledge Bases
## Overview
RAG (Retrieval-Augmented Generation) for Chat Switchboard. Users upload
documents into named knowledge bases, the backend chunks and embeds them
via the embedding model role (v0.10.0), stores vectors in pgvector, and a
`kb_search` tool lets the LLM pull relevant context at completion time.
Depends on: embedding model role (v0.10.0), file storage (v0.12.0),
tool framework (v0.11.0), admin panel (v0.13.0).
---
## 0. Pre-Req Fix: Embedding Model Selection
The embedding role dropdown is broken — `filterModels()` in
`ui-primitives.js` filters by `model_type === 'embedding'`, but many
providers don't report a type for embedding models (defaults to `'chat'`
and gets filtered out). This blocks KB setup entirely.
**Fix (ship before or with Phase 1):**
1. **Manual model ID entry** — add a text input fallback alongside the
dropdown. If the dropdown shows no results for the selected provider,
display an editable text field pre-populated with the `model_id` from
the saved binding (if any). Users type the model ID directly
(e.g. `text-embedding-3-small`). The save handler accepts either
dropdown selection or manual text entry.
2. **Tolerant type filter** — change `filterModels()` to include models
where `model_type` is empty/null/undefined when the role's type filter
is `'embedding'`. Embedding models are rare enough that showing
untyped models alongside typed ones is better than showing nothing.
```javascript
// Before (strict — breaks when model_type missing)
(m.model_type || 'chat') === typeFilter
// After (tolerant for embedding role)
typeFilter === 'embedding'
? (!m.model_type || m.model_type === 'embedding')
: (m.model_type || 'chat') === typeFilter
```
3. **Combo UI** — the slot renderer gets a hybrid dropdown+input:
```
┌──────────────────────────────────────────────┐
│ Provider: [OpenAI ▾] │
│ Model: [text-embedding-3-small ▾] [✎] │
│ ↑ toggle │
│ [text-embedding-3-small ] │ ← manual entry (shown on ✎ click)
└──────────────────────────────────────────────┘
```
The pencil icon toggles between dropdown and text input. If the dropdown
has zero options after provider change, auto-switch to manual entry.
Saved bindings with model IDs not in the dropdown auto-show in manual
mode on load.
---
## 1. Schema — Migration `008_knowledge_bases.sql`
### pgvector Extension
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
Requires `pgvector` installed on the PostgreSQL server. The migration
checks for the extension and fails with a clear error if unavailable,
rather than silently degrading. The README/deployment docs will note the
dependency.
**Dimension handling:** Embedding dimensions vary by model
(OpenAI ada-002 = 1536, text-embedding-3-small = 1536,
text-embedding-3-large = 3072, many open-source = 768 or 1024).
The dimension is stored per KB and the vector column uses the max
supported (3072), with vectors zero-padded or truncated at insert time.
Alternative: use `halfvec` for storage efficiency. We go with a single
fixed column at 3072 and document the tradeoff.
> **Decision:** 3072 is generous but future-proof. If storage becomes
> an issue, we add a `dimensions` column to `knowledge_bases` and
> migrate to per-dimension indexes later. For v0.14.0, KISS.
### Tables
```sql
-- ── Knowledge Bases ──────────────────────────
CREATE TABLE knowledge_bases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'global', -- global, team, personal
owner_id UUID, -- user_id (personal) or NULL
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
embedding_config JSONB NOT NULL DEFAULT '{}', -- snapshot: { provider_config_id, model_id, dimensions }
-- Stats (denormalized, updated on ingest)
document_count INT NOT NULL DEFAULT 0,
chunk_count INT NOT NULL DEFAULT 0,
total_bytes BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', -- active, processing, error
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT kb_scope_check CHECK (
(scope = 'global' AND owner_id IS NULL) OR
(scope = 'team' AND team_id IS NOT NULL) OR
(scope = 'personal' AND owner_id IS NOT NULL)
)
);
CREATE INDEX idx_kb_scope ON knowledge_bases(scope);
CREATE INDEX idx_kb_owner ON knowledge_bases(owner_id) WHERE owner_id IS NOT NULL;
CREATE INDEX idx_kb_team ON knowledge_bases(team_id) WHERE team_id IS NOT NULL;
-- ── KB Documents ─────────────────────────────
-- Metadata row per uploaded document. Blobs live in ObjectStore
-- under key: kb/{kb_id}/{doc_id}_{filename}
CREATE TABLE kb_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
storage_key TEXT NOT NULL, -- ObjectStore key
extracted_text TEXT, -- full extracted text (for re-chunking)
chunk_count INT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending', -- pending, chunking, embedding, ready, error
error TEXT,
uploaded_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_kbdoc_kb ON kb_documents(kb_id);
-- ── KB Chunks ────────────────────────────────
-- Chunked text + embedding vector for similarity search.
CREATE TABLE kb_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
document_id UUID NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
chunk_index INT NOT NULL, -- ordinal within document
content TEXT NOT NULL, -- chunk text
token_count INT NOT NULL DEFAULT 0,
embedding vector(3072), -- pgvector column
metadata JSONB NOT NULL DEFAULT '{}', -- { page, section, heading, ... }
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_kbchunk_kb ON kb_chunks(kb_id);
CREATE INDEX idx_kbchunk_doc ON kb_chunks(document_id);
-- IVFFlat index for similarity search
-- (created after initial data load; needs rows to train)
-- Deferred: created by ingest handler after first batch
-- CREATE INDEX idx_kbchunk_embedding ON kb_chunks
-- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- ── Channel-KB Links ─────────────────────────
-- Which KBs are active for a given channel.
CREATE TABLE channel_knowledge_bases (
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (channel_id, kb_id)
);
```
### Scope Rules
| Scope | Visible to | Created by | Embedding cost |
|----------|------------------------|-------------------|-----------------|
| global | All users | Admin | Org provider |
| team | Team members | Team admin | Team provider |
| personal | Owner only | BYOK user | User's own key |
Personal KBs follow the same BYOK pattern as personal providers — the
user's UEK decrypts their embedding provider key. Embedding costs are
logged to usage_log with `role = 'embedding'`.
---
## 2. Chunking Pipeline
### Strategy: Recursive Character Splitter
Start simple, iterate. Recursive splitting with configurable chunk size
and overlap. No semantic chunking in v0.14.0 (requires embedding every
boundary candidate — expensive, diminishing returns for a first pass).
```go
// server/knowledge/chunker.go
type ChunkConfig struct {
ChunkSize int // target chars per chunk (default 1000)
ChunkOverlap int // overlap chars between chunks (default 200)
Separators []string // split hierarchy: ["\n\n", "\n", ". ", " "]
}
type Chunk struct {
Content string
Index int // ordinal in document
TokenCount int // estimated token count
Metadata map[string]any // page, heading context
}
func SplitText(text string, cfg ChunkConfig) []Chunk
```
**Default config:** 1000 chars, 200 overlap (~250 tokens per chunk).
These defaults are stored in `knowledge_bases.embedding_config` and
overridable per-KB via the admin UI. The chunker tries separators in
order: paragraph breaks first, then line breaks, then sentences, then
words.
### Text Extraction Reuse
Documents uploaded to KBs use the same extraction pipeline as
attachments (v0.12.0). For inline-extractable types (TXT, MD, CSV),
text is read directly. For sidecar types (PDF, DOCX), the extraction
queue processes them. The extracted text is stored in
`kb_documents.extracted_text` for re-chunking if config changes.
---
## 3. Embedding Pipeline
### Flow
```
Upload → Extract Text → Chunk → Embed (batch) → Store vectors
```
### Batch Embedding
The embedding role's `Embed()` method already accepts `[]string` input.
Chunks are batched in groups of 100 (or fewer, respecting provider token
limits). Each batch is a single API call.
```go
// server/knowledge/embedder.go
type Embedder struct {
roleResolver *roles.Resolver
}
// EmbedChunks generates embeddings for a slice of chunks.
// Uses the embedding role with the resolution chain:
// personal → team → global (same as other role consumers).
func (e *Embedder) EmbedChunks(ctx context.Context, userID string,
teamID *string, chunks []Chunk) ([][]float64, error)
```
### Dimension Normalization
The embedding response includes raw vectors. If the vector dimension
is less than 3072 (the column width), zero-pad. If greater, truncate
(unlikely but defensive). Store the actual dimension in
`knowledge_bases.embedding_config.dimensions` so search queries can
account for it.
> **Note:** Mixing embedding models within a KB is invalid — vectors
> from different models aren't comparable. The `embedding_config` is
> snapshotted at KB creation. Changing the embedding model requires
> re-embedding all documents (explicit admin action).
---
## 4. Ingestion Flow
No background job system exists yet (that's v0.15.0 compaction).
Ingestion is **synchronous with progress polling** — same pattern as
the extraction queue (v0.12.0).
### API Endpoints
```
POST /api/v1/knowledge-bases — Create KB
GET /api/v1/knowledge-bases — List (scoped)
GET /api/v1/knowledge-bases/:id — Get KB details
PUT /api/v1/knowledge-bases/:id — Update name/description
DELETE /api/v1/knowledge-bases/:id — Delete KB + all docs/chunks
POST /api/v1/knowledge-bases/:id/documents — Upload document(s)
GET /api/v1/knowledge-bases/:id/documents — List documents
DELETE /api/v1/knowledge-bases/:id/documents/:docId — Delete document + chunks
GET /api/v1/knowledge-bases/:id/documents/:docId/status — Poll ingestion status
POST /api/v1/knowledge-bases/:id/rebuild — Re-chunk + re-embed all docs
POST /api/v1/knowledge-bases/:id/search — Direct search (debug/admin)
```
Admin endpoints mirror the pattern under `/api/v1/admin/knowledge-bases/`
for global KBs. Team KBs under `/api/v1/teams/:teamId/knowledge-bases/`.
Personal KBs under the base path (scoped by auth).
### Upload + Ingest Sequence
```
Client Server
│ │
├── POST /kb/:id/documents ───► │ Save blob to ObjectStore
│ (multipart/form-data) │ Create kb_documents row (status=pending)
│ │ Return { document_id, status: "pending" }
│ │
│ │── goroutine: ingestDocument()
│ │ ├─ Extract text (inline or queue)
│ │ ├─ status → "chunking"
│ │ ├─ Chunk text
│ │ ├─ status → "embedding"
│ │ ├─ Batch embed chunks
│ │ ├─ INSERT kb_chunks with vectors
│ │ ├─ Update KB stats
│ │ └─ status → "ready"
│ │
├── GET /kb/:id/docs/:d/status► │ Return current status + progress
│ (poll every 2s) │ { status, chunk_count, error }
◄───────────────────────────── │
```
The goroutine is fire-and-forget per document. The status column on
`kb_documents` is the progress indicator. Frontend polls until
`status = 'ready'` or `status = 'error'`.
**Concurrency:** A per-process semaphore (channel of size 3) limits
concurrent ingestion goroutines. Additional uploads queue behind the
semaphore. This prevents overwhelming the embedding provider with
parallel batch requests.
---
## 5. Search — `kb_search` Tool
### Tool Definition
```go
// server/tools/kbsearch.go
var kbSearchDef = ToolDef{
Name: "kb_search",
DisplayName: "Knowledge Base",
Description: "Search knowledge bases for relevant information. " +
"Returns text passages from uploaded documents that match the query.",
Category: "knowledge",
Parameters: JSONSchema(map[string]interface{}{
"query": Prop("string", "Search query — use natural language"),
"max_results": map[string]interface{}{
"type": "integer",
"description": "Maximum results to return (1-20, default 5)",
},
}, []string{"query"}),
}
```
### Execution
```go
func (t *KBSearchTool) Execute(ctx context.Context,
execCtx ExecutionContext, argsJSON string) (string, error) {
// 1. Parse args
// 2. Get active KBs for this channel
// (from channel_knowledge_bases where enabled = true)
// PLUS personal KBs owned by user (always available)
// 3. Embed the query using the embedding role
// 4. Similarity search across all active KB chunks
// 5. Format results with source attribution
}
```
### Similarity Query
```sql
SELECT c.content, c.metadata, d.filename, kb.name as kb_name,
1 - (c.embedding <=> $1::vector) AS similarity
FROM kb_chunks c
JOIN kb_documents d ON c.document_id = d.id
JOIN knowledge_bases kb ON c.kb_id = kb.id
WHERE c.kb_id = ANY($2) -- active KB IDs
AND 1 - (c.embedding <=> $1) > $3 -- similarity threshold (0.3 default)
ORDER BY c.embedding <=> $1
LIMIT $4; -- max_results
```
### Result Format
```json
{
"results": [
{
"content": "Chunk text here...",
"source": "quarterly-report.pdf",
"kb": "Q4 Reports",
"similarity": 0.87,
"metadata": { "page": 12 }
}
],
"query": "revenue growth Q4",
"searched_kbs": ["Q4 Reports", "Financial Data"]
}
```
### ExecutionContext Extension
The `kb_search` tool needs access to the store layer and role resolver,
which the current `ExecutionContext` doesn't provide. Two options:
**Option A: Expand ExecutionContext** — add optional fields:
```go
type ExecutionContext struct {
UserID string
ChannelID string
// v0.14.0 additions
Stores *store.Stores // nil for tools that don't need it
RoleResolver *roles.Resolver // nil for tools that don't need it
TeamID *string // user's team (for role resolution)
}
```
**Option B: Closure injection** — the tool struct captures dependencies
at registration time:
```go
type kbSearchTool struct {
stores store.Stores
roleResolver *roles.Resolver
}
func init() {
// Deferred registration — called from main.go after stores init
}
```
**Decision: Option B (closure injection).** It's the same pattern the
notes tool already uses (it captures the NoteStore). The `kb_search`
tool is registered after stores and role resolver init in `main.go`,
not via `init()`. Add a `RegisterLate()` function or explicit call.
---
## 6. Context Injection
When `kb_search` returns results, they flow through the existing tool
call loop in `stream_loop.go` — no special injection needed. The tool
result becomes a `role: "tool"` message in the conversation, and the
LLM sees the retrieved context naturally.
### Automatic vs. Tool-Based
Two approaches to getting KB context into the conversation:
| Approach | Pros | Cons |
|----------|------|------|
| **Tool-based** (v0.14.0) | LLM decides when to search; transparent; works with existing tool loop | Extra round-trip; LLM might not search when it should |
| **Auto-inject** (future) | Always available; no tool call overhead | Wastes tokens if not needed; opaque to user |
**v0.14.0: Tool-based only.** The LLM calls `kb_search` when it needs
information. Auto-injection (pre-pend top-K results to every prompt) is
a v0.15.x consideration when we have compaction to manage context
budget.
### System Prompt Hint
When KBs are active on a channel, append a hint to the system prompt:
```
You have access to the following knowledge bases:
- "Q4 Reports" (3 documents)
- "Engineering Wiki" (12 documents)
Use the kb_search tool to find relevant information from these sources.
```
This nudges the model to use the tool without auto-injecting content.
---
## 7. Notes Embedding
Notes already have full-text search via PostgreSQL `tsvector`. Adding
vector search means notes can be found semantically, not just by keyword.
**Approach:** Treat notes as single-chunk documents. On note
create/update, embed the full note text and store in a new column:
```sql
ALTER TABLE notes ADD COLUMN embedding vector(3072);
```
The `note_search` tool (already exists) gains an optional
`semantic: true` parameter that switches from `tsvector` to vector
similarity search. Both results can be merged and deduplicated.
**Deferred to Phase 3** — notes embedding is additive and independent
of the core KB pipeline. Ship KB search first, add note embeddings
after the pipeline is proven.
---
## 8. Per-Channel KB Toggle
### UI: Chat Sidebar
A "Knowledge" button in the chat header or input bar (next to the tools
toggle). Click opens a popup listing available KBs with toggle switches.
```
┌─────────────────────────────────┐
│ 📚 Knowledge Bases │
│ │
│ ☑ Q4 Reports (3 docs) │
│ ☑ Engineering Wiki (12 docs) │
│ ☐ HR Policies (5 docs) │
│ │
│ Manage KBs... │
└─────────────────────────────────┘
```
### State
Active KBs per channel stored in `channel_knowledge_bases`. When a user
toggles a KB on/off for a channel, the frontend calls:
```
PUT /api/v1/channels/:id/knowledge-bases
Body: { kb_ids: ["uuid1", "uuid2"] }
```
The completion handler reads active KBs to populate the system prompt
hint and to scope `kb_search` results.
### Visibility Rules
A user can toggle KBs they have access to:
- **Global KBs** — all users
- **Team KBs** — team members
- **Personal KBs** — owner only
The list endpoint returns all KBs the user can see, with `enabled`
status per channel.
---
## 9. Store Interface
```go
// server/store/interfaces.go — additions
// =========================================
// KNOWLEDGE BASE STORE
// =========================================
type KnowledgeBaseStore interface {
// KB CRUD
Create(ctx context.Context, kb *models.KnowledgeBase) error
GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
// Scoped listing
ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error)
ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error)
ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error)
ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error)
// Documents
CreateDocument(ctx context.Context, doc *models.KBDocument) error
GetDocument(ctx context.Context, id string) (*models.KBDocument, error)
ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error)
UpdateDocumentStatus(ctx context.Context, id string, status string, err *string) error
DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) // returns for storage cleanup
// Chunks
InsertChunks(ctx context.Context, chunks []models.KBChunk) error
DeleteChunksForDocument(ctx context.Context, documentID string) error
SimilaritySearch(ctx context.Context, kbIDs []string, queryVec []float64,
threshold float64, limit int) ([]models.KBSearchResult, error)
// Channel links
SetChannelKBs(ctx context.Context, channelID string, kbIDs []string) error
GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error)
GetActiveKBIDs(ctx context.Context, channelID string, userID string,
teamIDs []string) ([]string, error) // enabled + user has access
// Stats
UpdateStats(ctx context.Context, kbID string) error // recount from chunks
}
```
Add `KnowledgeBases KnowledgeBaseStore` to the `Stores` struct.
---
## 10. Models
```go
// server/models/models.go — additions
type KnowledgeBase struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Scope string `json:"scope" db:"scope"`
OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
EmbeddingConfig map[string]interface{} `json:"embedding_config" db:"embedding_config"`
DocumentCount int `json:"document_count" db:"document_count"`
ChunkCount int `json:"chunk_count" db:"chunk_count"`
TotalBytes int64 `json:"total_bytes" db:"total_bytes"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type KBDocument struct {
ID string `json:"id" db:"id"`
KBID string `json:"kb_id" db:"kb_id"`
Filename string `json:"filename" db:"filename"`
ContentType string `json:"content_type" db:"content_type"`
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
StorageKey string `json:"storage_key" db:"storage_key"`
ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"`
ChunkCount int `json:"chunk_count" db:"chunk_count"`
Status string `json:"status" db:"status"`
Error *string `json:"error,omitempty" db:"error"`
UploadedBy string `json:"uploaded_by" db:"uploaded_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type KBChunk struct {
ID string `json:"id" db:"id"`
KBID string `json:"kb_id" db:"kb_id"`
DocumentID string `json:"document_id" db:"document_id"`
ChunkIndex int `json:"chunk_index" db:"chunk_index"`
Content string `json:"content" db:"content"`
TokenCount int `json:"token_count" db:"token_count"`
Embedding []float64 `json:"-" db:"embedding"` // not serialized to API
Metadata map[string]interface{} `json:"metadata" db:"metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type KBSearchResult struct {
Content string `json:"content"`
Filename string `json:"source"`
KBName string `json:"kb"`
Similarity float64 `json:"similarity"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type ChannelKB struct {
KBID string `json:"kb_id" db:"kb_id"`
KBName string `json:"kb_name" db:"name"`
Enabled bool `json:"enabled" db:"enabled"`
DocCount int `json:"document_count" db:"document_count"`
}
```
---
## 11. File Structure
### New Go Packages
```
server/
├── knowledge/ # NEW — chunking + embedding logic
│ ├── chunker.go # recursive text splitter
│ ├── chunker_test.go
│ ├── embedder.go # batch embed via role resolver
│ └── ingest.go # orchestrator: extract → chunk → embed → store
├── handlers/
│ ├── knowledge_bases.go # NEW — KB CRUD + document upload + search
│ └── ... (existing)
├── store/
│ └── postgres/
│ ├── knowledge_bases.go # NEW — KnowledgeBaseStore impl
│ └── ... (existing)
├── tools/
│ ├── kbsearch.go # NEW — kb_search tool
│ └── ... (existing)
├── database/
│ └── migrations/
│ └── 008_knowledge_bases.sql # NEW
└── models/
└── models.go # additions (KB types)
```
### New/Modified Frontend Files
```
src/js/
├── ui-admin.js # MODIFIED — KB management section in admin panel
├── ui-primitives.js # MODIFIED — embedding dropdown fix, manual entry
├── chat.js # MODIFIED — KB toggle popup, system prompt hint
├── api.js # MODIFIED — KB API methods
└── settings-handlers.js # MODIFIED — personal KB management
```
### Go Dependencies
```
go get github.com/pgvector/pgvector-go # pgvector type support for lib/pq
```
---
## 12. Admin Panel Integration
KBs live under **AI → Knowledge Bases** in the admin panel (v0.13.0).
### Admin View
```
┌──────────────────────────────────────────────────────┐
│ AI → Knowledge Bases │
│ │
│ [+ New Knowledge Base] │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Q4 Reports global 3 docs ready │ │
│ │ Engineering Wiki global 12 docs ready │ │
│ │ Onboarding Materials team:Eng 5 docs ready │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ── Selected: Q4 Reports ────────────────────────── │
│ │
│ Name: [Q4 Reports ] │
│ Description: [Quarterly financial reports ] │
│ Scope: [Global ▾] Team: [— ▾] │
│ │
│ Embedding: OpenAI / text-embedding-3-small │
│ Chunk size: [1000] Overlap: [200] │
│ │
│ Documents: │
│ ┌────────────────────────────────────────────────┐ │
│ │ quarterly-report-q4.pdf 2.1 MB 42 chunks ✓ │ │
│ │ revenue-breakdown.xlsx 340 KB 12 chunks ✓ │ │
│ │ market-analysis.docx 1.8 MB processing... │ │
│ └────────────────────────────────────────────────┘ │
│ [📎 Upload Documents] [🔄 Rebuild All] [🗑 Delete] │
└──────────────────────────────────────────────────────┘
```
### Personal KB (Settings)
BYOK users see a "Knowledge Bases" section in Settings → Model Roles
(or a new Settings tab). Same UI, scoped to personal KBs using the
user's embedding role override.
### Team KB (Team Management)
Team admins see a "Knowledge Bases" tab in the team management modal.
Same UI pattern, scoped to the team.
---
## 13. Phased Delivery
### Phase 1: Schema + Chunker + Embed Fix
**Goal:** Database ready, chunking works, embedding dropdown fixed.
- [ ] `008_knowledge_bases.sql` migration (pgvector + tables)
- [ ] `go get pgvector-go`, update `go.mod`
- [ ] `knowledge/chunker.go` — recursive text splitter + tests
- [ ] Embedding dropdown fix in `ui-primitives.js` (tolerant filter + manual entry)
- [ ] `KnowledgeBaseStore` interface + postgres implementation (CRUD only, no vector ops)
- [ ] `KnowledgeBase`, `KBDocument`, `KBChunk` model types
- [ ] Wire `Stores.KnowledgeBases` in main.go
### Phase 2: Ingestion Pipeline
**Goal:** Documents can be uploaded, chunked, and embedded end-to-end.
- [ ] `knowledge/embedder.go` — batch embed via role resolver
- [ ] `knowledge/ingest.go` — orchestrator (extract → chunk → embed → store)
- [ ] `handlers/knowledge_bases.go` — KB CRUD endpoints
- [ ] Document upload with async ingestion goroutine
- [ ] Status polling endpoint
- [ ] `SimilaritySearch()` in store (pgvector query)
- [ ] Admin panel: KB management section (AI → Knowledge Bases)
- [ ] IVFFlat index creation (manual or after first batch)
### Phase 3: kb_search Tool + Channel Integration
**Goal:** LLMs can search KBs during conversations.
- [ ] `tools/kbsearch.go` — kb_search tool (late registration pattern)
- [ ] Per-channel KB toggle (channel_knowledge_bases)
- [ ] System prompt KB hint injection in completion handler
- [ ] KB toggle popup in chat UI
- [ ] Frontend: channel KB management
- [ ] Category "knowledge" in tools toggle menu
### Phase 4: Polish + Notes
**Goal:** Team/personal scoping works, notes get vectors.
- [ ] Team KB endpoints + team management UI tab
- [ ] Personal KB endpoints + settings UI
- [ ] KB rebuild endpoint (re-chunk + re-embed)
- [ ] Notes embedding column + semantic note search
- [ ] Audit logging for KB operations
- [ ] Usage tracking for embedding calls (role = 'embedding')
- [ ] Integration tests
---
## 14. Risks + Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| pgvector not installed on PG host | Migration fails, blocks deploy | Clear error message in migration; add to deployment docs/Dockerfile |
| Embedding provider rate limits | Ingestion stalls on large uploads | Semaphore + exponential backoff; batch size tuning |
| Dimension mismatch (model change) | Search returns garbage | Snapshot model in `embedding_config`; warn if role model ≠ KB model |
| Large documents (100+ pages) | Memory pressure during chunking | Stream text extraction; chunk incrementally |
| Vector storage size | DB bloat with many KBs | Monitor with stats endpoint; future: HNSW index for better perf |
| Embedding dropdown broken | Can't configure embedding role at all | Phase 1 priority — ship fix before KB features |
---
## 15. Future (Not v0.14.0)
- **Auto-injection:** Pre-pend top-K results to system prompt (v0.15.x, needs compaction for context budget)
- **Hybrid search:** Combine vector similarity with full-text `tsvector` search, re-rank
- **Semantic chunking:** Use embedding distance to detect topic boundaries
- **HNSW index:** Better query performance than IVFFlat for large datasets
- **Web scraping source:** Ingest URLs as KB documents (natural extension of url_fetch)
- **Scheduled re-indexing:** Periodic rebuild when source documents are updated
- **KB sharing:** Cross-team KB access grants

584
docs/DESIGN-0.15.0.md Normal file
View File

@@ -0,0 +1,584 @@
# DESIGN-0.15.0 — Compaction
## Overview
Automatic conversation compaction. A background service monitors channels
for context pressure and triggers summarization via the utility model role,
replacing the manual "Summarize & Continue" button as the primary
compaction path. Manual summarization remains available as an explicit
user action.
Depends on: utility model role (v0.10.0), summarize handler (v0.10.2).
**Design principle: don't reinvent the wheel.** The existing
`SummarizeHandler` already has the full pipeline — path loading, summary
boundary detection, prompt construction, role resolution, tree insertion,
cursor update, usage logging. This design extracts that core logic into
a shared `compaction` package and adds a background scanner on top.
---
## 1. Extract: `compaction` Package
Move the reusable summarization pipeline out of `handlers/summarize.go`
into `server/compaction/compaction.go`. The HTTP handler becomes a thin
wrapper.
### Core type
```go
package compaction
// Service provides conversation compaction (summarization).
// Used by both the HTTP handler (manual) and the background scanner (auto).
type Service struct {
stores store.Stores
resolver *roles.Resolver
}
func NewService(stores store.Stores, resolver *roles.Resolver) *Service {
return &Service{stores: stores, resolver: resolver}
}
```
### Extracted method: `Compact`
The current `SummarizeHandler.Summarize` body becomes `Service.Compact`:
```go
type CompactRequest struct {
ChannelID string
UserID string // channel owner
TeamID *string // for role resolution
Trigger string // "manual" | "auto"
}
type CompactResult struct {
SummaryID string
SummarizedCount int
Model string
UsedFallback bool
Content string
InputTokens int
OutputTokens int
}
func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error)
```
The method performs the same steps as today's handler:
1. Load active path via `getActivePath` (already exported within `handlers`)
2. Find summary boundary, collect messages to summarize
3. Guard: minimum 4 messages since last summary
4. Build summarization prompt
5. Call `resolver.Complete(ctx, RoleUtility, ...)`
6. Insert summary tree node with metadata
7. Update cursor
8. Log usage
The only new metadata field is `"trigger"` (`"manual"` or `"auto"`) so
the frontend can distinguish how the summary was created.
### Refactored handler
`handlers/summarize.go` shrinks to:
```go
func (h *SummarizeHandler) Summarize(c *gin.Context) {
// ownership check, rate limit check (unchanged)
// ...
result, err := h.compaction.Compact(c.Request.Context(), compaction.CompactRequest{
ChannelID: channelID,
UserID: userID,
TeamID: teamID,
Trigger: "manual",
})
// return JSON response (unchanged)
}
```
### Tree helpers
`getActivePath`, `isSummaryMessage`, `nextSiblingIndex`, `updateCursor`
are currently unexported in `handlers/tree.go`. Two options:
**Option A** — Export them from `handlers` and import in `compaction`.
Clean but creates a dependency from `compaction``handlers`.
**Option B** — Move tree helpers into a `treepath` (or similar) package
imported by both `handlers` and `compaction`.
Recommend **Option B** to keep the dependency graph clean:
`handlers``compaction``treepath``handlers`. The `treepath`
package is pure data + SQL queries with no handler logic.
```
server/
treepath/ ← NEW: extracted from handlers/tree.go
path.go (getActivePath, getPathToLeaf, getActiveLeaf)
summary.go (isSummaryMessage, summary metadata helpers)
siblings.go (getSiblingCount, getSiblings, findLeafFromMessage)
cursor.go (updateCursor, nextSiblingIndex)
compaction/ ← NEW
compaction.go (Service, Compact)
scanner.go (Scanner, background loop)
estimator.go (token estimation)
handlers/
tree.go → thin wrappers or deleted, imports treepath
summarize.go → thin HTTP wrapper, delegates to compaction.Service
completion.go → imports treepath for path building
```
---
## 2. Server-Side Token Estimation
The background scanner needs to evaluate context pressure without making
an LLM call. The frontend already does this with a `chars / 4` heuristic
(`tokens.js`). Replicate server-side:
```go
// compaction/estimator.go
// EstimateTokens returns a rough token count using the ~4 chars/token
// heuristic. Matches the frontend Tokens.estimate() for consistency.
func EstimateTokens(text string) int {
return (len(text) + 3) / 4
}
// EstimatePath returns total estimated tokens for a message path,
// including system prompt overhead.
func EstimatePath(path []treepath.PathMessage, systemPromptLen int) int {
total := 0
if systemPromptLen > 0 {
total += EstimateTokens(string(make([]byte, systemPromptLen))) + 4
}
for _, m := range path {
total += EstimateTokens(m.Content) + 4 // +4 per-message overhead
}
return total
}
```
Not exact, but it's the same bar the user already sees in the UI. If we
need better accuracy later, swap in a tokenizer library without changing
the interface.
### Context budget resolution
The scanner needs `max_context` for whatever model the channel is using.
Resolution chain:
1. Channel has a `model` field → look up `model_catalog` for capabilities
2. Channel has a `provider_config_id` → use that provider's catalog entry
3. Fallback: use the utility role's model context window as a proxy (it's
the model that would be doing the summarization anyway)
4. Hard fallback: 128K tokens (conservative default)
```go
func (s *Scanner) getContextBudget(ctx context.Context, ch *models.Channel) int {
if ch.Model != "" {
if caps, err := s.stores.Catalog.GetCapabilities(ctx, ch.Model); err == nil {
if caps.MaxContext > 0 {
return caps.MaxContext
}
}
}
return DefaultContextBudget // 128000
}
```
---
## 3. Background Scanner
Follows the Ingester pattern from `knowledge/ingest.go`: goroutine pool
with semaphore, WaitGroup for graceful shutdown.
### Scanner type
```go
// compaction/scanner.go
type Scanner struct {
service *Service
stores store.Stores
sem chan struct{}
wg sync.WaitGroup
stopCh chan struct{}
ticker *time.Ticker
}
type ScannerConfig struct {
Enabled bool // global kill switch
Interval time.Duration // scan frequency (default: 5 minutes)
Concurrency int // max parallel compactions (default: 2)
}
func NewScanner(svc *Service, stores store.Stores, cfg ScannerConfig) *Scanner
func (sc *Scanner) Start()
func (sc *Scanner) Stop() // signals stop + drains in-flight
```
### Scan loop
```
every <interval>:
if !globalEnabled(): continue
candidates = findCandidates()
for each candidate:
sem.acquire()
go compact(candidate)
```
### Candidate query
A single SQL query finds channels that need compaction:
```sql
SELECT c.id, c.user_id, c.model, c.settings,
COUNT(m.id) as msg_count,
SUM(LENGTH(m.content)) as total_chars
FROM channels c
JOIN messages m ON m.channel_id = c.id
AND m.deleted_at IS NULL
WHERE c.deleted_at IS NULL
AND c.type = 'direct' -- only user conversations (not groups yet)
AND c.is_archived = false
-- Skip channels with very recent activity (let the user finish typing)
AND c.updated_at < NOW() - INTERVAL '2 minutes'
-- Only channels active in the last 7 days (don't compact stale channels)
AND c.updated_at > NOW() - INTERVAL '7 days'
GROUP BY c.id
HAVING COUNT(m.id) >= 10 -- minimum message count
AND SUM(LENGTH(m.content)) > 20000 -- rough: 20K chars ≈ 5K tokens minimum
ORDER BY c.updated_at DESC
LIMIT 50 -- batch size cap
```
This is a coarse filter. Each candidate then gets a precise check:
```go
func (sc *Scanner) shouldCompact(ctx context.Context, ch *models.Channel) bool {
// 1. Check channel-level opt-out
if ch.Settings != nil {
if v, ok := ch.Settings["auto_compaction"]; ok && v == false {
return false
}
}
// 2. Load active path for the channel owner
path, err := treepath.GetActivePath(ch.ID, ch.UserID)
if err != nil || len(path) < 10 {
return false
}
// 3. Find post-summary messages only
startIdx := 0
for i, m := range path {
if treepath.IsSummaryMessage(&m) {
startIdx = i + 1
}
}
postSummary := path[startIdx:]
if len(postSummary) < 8 {
return false // not enough new messages since last summary
}
// 4. Estimate tokens and check threshold
tokens := EstimatePath(postSummary, 0)
budget := sc.getContextBudget(ctx, ch)
threshold := sc.getThreshold(ch) // default 0.70
return float64(tokens) / float64(budget) >= threshold
}
```
### Threshold
Default: **0.70** (70% of context window). This is intentionally lower
than the frontend's 75% warning bar — auto-compaction should fire before
the user sees a warning, not after.
Configurable per-channel via `settings.compaction_threshold` (float) and
globally via `global_settings` key `auto_compaction_threshold`.
### Rate limiting
Auto-compaction calls use the utility role and are org-funded. They share
the existing `utility_rate_limit` budget but get a separate counter
prefix so admins can see auto vs manual usage:
```go
entry := &models.UsageEntry{
// ...
Role: &role, // "utility"
Metadata: models.JSONMap{
"trigger": "auto", // distinguishes from manual
},
}
```
If the global utility rate limit is hit, auto-compaction silently skips
the channel and retries next scan cycle. It never blocks or errors.
---
## 4. Per-Channel Configuration
Uses the existing `Channel.Settings` JSONB column. No migration needed.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `auto_compaction` | `bool` | `true` (from global) | Enable/disable for this channel |
| `compaction_threshold` | `float` | `0.70` (from global) | Context usage ratio to trigger |
### Frontend: channel settings
Add a "Compaction" section to the channel settings panel (the gear icon
in the chat header). Two controls:
```
┌─ Compaction ──────────────────────────────────┐
│ Auto-compact [✓] │
│ Threshold [70]% of context window │
└───────────────────────────────────────────────┘
```
The manual "Summarize & Continue" button remains in the context warning
bar and works regardless of auto-compaction settings.
---
## 5. Admin Controls
New `global_settings` keys:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `auto_compaction_enabled` | `bool` | `false` | Global kill switch. Ships **off** — admins opt in. |
| `auto_compaction_threshold` | `float` | `0.70` | Default threshold for channels without override |
| `auto_compaction_interval_minutes` | `int` | `5` | Scanner tick interval |
| `auto_compaction_concurrency` | `int` | `2` | Max parallel auto-compactions |
| `auto_compaction_cooldown_minutes` | `int` | `30` | Min time between auto-compactions of the same channel |
### Admin UI
Add an "Auto-Compaction" card to the existing admin AI settings panel
(alongside the utility rate limit and model roles):
```
┌─ Auto-Compaction ─────────────────────────────┐
│ Enabled [✓] │
│ Threshold [70]% │
│ Scan interval [5] minutes │
│ Concurrency [2] parallel │
│ Cooldown [30] minutes per channel │
└───────────────────────────────────────────────┘
```
---
## 6. Cooldown + Dedup
Prevent rapid re-compaction of the same channel:
1. **Cooldown** — after compacting channel X, don't re-evaluate it for
`cooldown_minutes`. Track in-memory:
```go
lastCompacted map[string]time.Time // channelID → timestamp
```
2. **In-flight dedup** — if channel X is currently being compacted (sem
acquired, goroutine running), skip it in the current scan. Track via:
```go
inFlight sync.Map // channelID → struct{}
```
3. **Stacking** — the existing summary boundary logic already handles
multiple summaries correctly. Auto-compaction produces the same tree
nodes as manual, so stacking "just works."
---
## 7. Lifecycle + Wiring
### main.go changes
```go
// After role resolver and stores setup:
compactionSvc := compaction.NewService(stores, roleResolver)
compactionScanner := compaction.NewScanner(compactionSvc, stores, compaction.ScannerConfig{
Enabled: getGlobalBool(stores, "auto_compaction_enabled", false),
Interval: time.Duration(getGlobalInt(stores, "auto_compaction_interval_minutes", 5)) * time.Minute,
Concurrency: getGlobalInt(stores, "auto_compaction_concurrency", 2),
})
compactionScanner.Start()
defer compactionScanner.Stop() // drain in-flight on shutdown
// Summarize handler now wraps compaction service:
summarize := handlers.NewSummarizeHandler(stores, roleResolver, compactionSvc)
```
Follows the same pattern as `kbIngester` with `defer kbIngester.Wait()`.
### Config reload
The scanner re-reads global settings at the start of each scan cycle.
No restart required to change thresholds, intervals, or the kill switch.
If `auto_compaction_enabled` flips to `false`, the scanner skips all
work on the next tick.
---
## 8. Observability
### Logging
```
🔍 compaction: scanning 50 candidates
🔍 compaction: channel abc123 qualifies (78% of 128K context, 42 messages post-summary)
📝 compaction: compacting channel abc123 for user xyz (trigger=auto)
✅ compaction: channel abc123 done (42 messages → 847 chars, model=claude-sonnet-4-5-20250929)
⏭ compaction: channel def456 skipped (cooldown, 12m remaining)
⏭ compaction: channel ghi789 skipped (rate limit)
```
### Audit log
Auto-compaction events are logged to `audit_log`:
```go
stores.Audit.Log(ctx, &models.AuditEntry{
Actor: "system:compaction",
Action: "compaction.auto",
Resource: "channel:" + channelID,
Details: models.JSONMap{
"summarized_count": result.SummarizedCount,
"model": result.Model,
"trigger": "auto",
},
})
```
### Usage tracking
All auto-compaction calls flow through the existing usage logging path
(same as manual summarization). The `metadata.trigger = "auto"` field
lets the admin usage dashboard distinguish auto from manual.
---
## 9. Frontend Changes
Minimal — the backend does the heavy lifting.
### Auto-compaction indicator
When loading a conversation that was auto-compacted, the frontend
already handles summary boundaries correctly (the collapsed history
toggle, the "X earlier messages summarized" bar). The only new UI is
a subtle indicator on the summary node:
```
▸ 42 earlier messages summarized (auto)
```
The `(auto)` label comes from `metadata.trigger` on the summary message.
### Channel settings
Add compaction controls to the channel settings panel (Section 4 above).
Saves to `PATCH /channels/:id` with `settings` field — no new endpoint.
### Admin panel
Add the auto-compaction card to the AI settings section (Section 5).
Saves via existing `PUT /admin/settings/:key` endpoints.
---
## 10. Migration
**No new migration required.**
- Channel compaction settings → existing `Channel.Settings` JSONB
- Admin settings → existing `global_settings` table
- Summary messages → existing `messages` table with metadata
- Usage tracking → existing `usage_log` table
- Audit logging → existing `audit_log` table
---
## 11. File Manifest
```
server/
treepath/ ← NEW package
path.go (extracted from handlers/tree.go)
summary.go (isSummaryMessage + helpers)
siblings.go (sibling queries)
cursor.go (cursor + sibling index)
compaction/ ← NEW package
compaction.go (Service, Compact, CompactRequest/Result)
scanner.go (Scanner, scan loop, candidate query)
estimator.go (EstimateTokens, EstimatePath)
handlers/
tree.go (updated: delegates to treepath)
summarize.go (updated: delegates to compaction.Service)
completion.go (updated: imports treepath)
main.go (updated: wire compaction scanner)
src/js/
ui-core.js (updated: show "(auto)" on auto-compacted summaries)
ui-settings.js (updated: channel compaction controls)
ui-admin.js (updated: auto-compaction admin card)
```
---
## 12. Implementation Order
**Phase 1: Extract** — Move tree helpers to `treepath`, core
summarization to `compaction.Service`. Refactor `SummarizeHandler` to
delegate. All existing tests must still pass. No new behavior.
**Phase 2: Estimator + Scanner** — Add `estimator.go`, `scanner.go`,
candidate query, `shouldCompact` logic. Wire into `main.go` with
`Start()`/`Stop()`. Global settings for admin controls. Ship with
`auto_compaction_enabled` defaulting to `false`.
**Phase 3: Frontend** — Channel compaction settings, admin panel card,
auto-compaction indicator on summary nodes.
**Phase 4: Soak + tune** — Enable on a test instance, watch logs, tune
default threshold and cooldown. Adjust candidate query filters if scan
is too expensive.
---
## 13. What This Intentionally Doesn't Do
- **Token-accurate estimation** — chars/4 is good enough. A real
tokenizer adds a dependency for marginal accuracy gain. Revisit if
users report summaries firing too early or too late.
- **Per-model prompt tuning** — the summarization prompt is one-size-fits
-all. Works fine across Claude/GPT/Llama. Revisit if quality varies
significantly across providers.
- **Group channel compaction** — auto-compaction targets `direct` channels
only (single-owner). Group/channel types need multi-cursor awareness
(which user's path to compact?). Deferred to v0.19.0 multi-participant.
- **Compaction snapshots for KB** — the roadmap mentions this. The
summary nodes we create here are already stored as messages with full
metadata. KB integration can query them later without changes to this
design.
- **Event-driven triggers** — a post-completion hook that checks
immediately after each message would reduce latency vs. the ticker.
Worth adding later but the ticker is simpler to ship and debug. The
2-minute activity gap in the candidate query achieves a similar effect
(don't compact mid-conversation).

688
docs/EXTENSIONS.md Normal file
View File

@@ -0,0 +1,688 @@
# Chat Switchboard — Extension System Specification
**Version:** 0.3
**Status:** Browser tier (Tier 0) implemented in v0.11.0. Starlark and Sidecar tiers planned.
**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md)
---
## 1. Philosophy
Chat Switchboard is a substrate, not an application. The core provides:
authentication, provider routing, message persistence, an event bus, and a
rendering surface. Everything else — editing, writing, cluster management,
cost tracking, custom renderers — is an extension.
The goal is that someone with a problem and some JS (or Go, or Python) can
solve it without forking the project. The "modes" — Editor, Article, Chat,
Cluster Manager — are extensions that register surfaces, tools, and event
handlers. This project doesn't have to build all of them. It has to make
them possible.
---
## 2. Extension Tiers
| | Tier 0: Browser | Tier 1: Starlark | Tier 2: Sidecar |
|---|---|---|---|
| **Runs in** | User's browser | Go server (embedded) | Separate container |
| **Language** | JavaScript | Starlark (Python subset) | Any |
| **Deployed by** | User or Admin push | Admin | Admin |
| **Latency** | Zero (client-side) | Low (in-process) | Network hop |
| **Can access** | DOM, EventBus, user context | Message data, DB reads (sandboxed) | Anything (HTTP, filesystem, network) |
| **Cannot access** | Server internals, other users' data | Network, filesystem, raw SQL | N/A (full access) |
| **Trust model** | Same-origin; user-scoped or admin-pushed | Starlark sandbox (no I/O) | Container isolation |
| **Use cases** | UI, rendering, shortcuts, client tools, modes | Routing rules, message transforms, logging | RAG, external APIs, webhooks, heavy compute |
All three tiers communicate through the EventBus. A browser extension
publishes `tool.result.{callId}` the same way a sidecar does — the bus
doesn't care where the event originated.
**Admin-pushed vs User-installed (Tier 0):**
Admin-pushed extensions load for all users (like a managed browser
extension policy). User-installed extensions load from user settings
and only affect that user's session. Both use the same runtime, same
manifest format. The difference is governance, not execution.
---
## 3. Manifest Format
Every extension, regardless of tier, is described by a manifest:
```json
{
"id": "cost-tracker",
"name": "Cost Tracker",
"version": "1.0.0",
"tier": "browser",
"author": "jeff",
"description": "Real-time token counting and cost estimation",
"permissions": [
"events:chat.message.*",
"events:model.selected",
"dom:input-area",
"storage:local"
],
"entry": "cost-tracker.js",
"hooks": {
"chat.message.send": { "priority": 10, "async": false },
"chat.message.received": { "priority": 50, "async": true }
},
"tools": [],
"surfaces": [],
"settings": {
"showInline": {
"type": "boolean",
"label": "Show cost inline",
"default": true
}
}
}
```
### 3.1 Fields
- **id**: Unique identifier. Namespaced by convention (`jeff.cost-tracker`).
- **tier**: `browser` | `starlark` | `sidecar`
- **permissions**: What the extension needs. The loader enforces these.
Undeclared access is blocked (Tier 0 via proxy, Tier 1 via sandbox,
Tier 2 via API scoping).
- **entry**: Browser: JS file path. Starlark: `.star` file. Sidecar:
endpoint URL or Docker image.
- **hooks**: EventBus events this extension subscribes to, with priority
(lower = runs first) and whether the hook is async.
- **tools**: LLM-callable tools this extension provides (see §5).
- **surfaces**: UI surfaces this extension registers (see §6).
- **settings**: User-configurable options, rendered as a form in the
extension settings UI.
---
## 4. Browser Extensions (Tier 0)
### 4.1 Lifecycle
```
Install → Load → Init → Active → Disable → Unload
```
**Install**: Admin pushes manifest + JS to server, or user adds from
settings. Stored in `extensions` table with `tier = 'browser'`.
**Load**: On page load, after `events.js` but before `app.js`, the
extension loader injects `<script>` tags for all enabled browser extensions.
Load order respects declared dependencies.
**Init**: Each extension's entry script calls `Extensions.register()`:
```js
Extensions.register({
id: 'cost-tracker',
init(ctx) {
// ctx.events — scoped EventBus (only permitted events)
// ctx.storage — scoped localStorage wrapper
// ctx.settings — this extension's settings values
// ctx.ui — DOM injection points
// ctx.api — proxied fetch() to backend (auth injected)
// ctx.model — current model ID + resolved capabilities
// ctx.user — current user info (id, username, role)
this.ctx = ctx;
ctx.events.on('chat.message.send', (msg) => {
const est = this.estimateTokens(msg.content);
ctx.ui.inject('input-area', this.renderCost(est));
});
},
destroy() {
// Cleanup: remove DOM elements, unsubscribe events
}
});
```
### 4.2 Context Object
The `ctx` object is the extension's API surface. It's scoped — an
extension that didn't declare `dom:sidebar` in permissions gets a `ctx.ui`
that throws on `inject('sidebar', ...)`.
```
ctx.events — EventBus subscribe/publish (filtered by permissions)
ctx.storage — localStorage namespace (extensions::{id}::*)
ctx.settings — Read-only settings values from manifest
ctx.ui — DOM injection into declared surfaces
ctx.api — Proxied fetch() to backend (auth headers injected)
ctx.model — Current model ID + resolved capabilities
ctx.user — Current user info (id, username, role)
```
### 4.3 Admin-Pushed vs User-Installed
- **Admin-pushed**: `extensions` row with `is_system = true`. Loaded for
all users. Users cannot disable.
- **User-installed**: Stored in user settings JSONB. Users can toggle.
- Both use the same runtime.
### 4.4 Security Model
Browser extensions run in the same origin. The security model is:
1. **Permission declaration** — extensions declare what they need; the
loader enforces it via the `ctx` proxy.
2. **Admin review** — admin-pushed extensions are implicitly trusted.
User-installed extensions get a "this extension can access: ..." prompt.
3. **Event scoping** — extensions only see events they declared in
`permissions`.
4. **CSP headers** — strict Content-Security-Policy prevents inline script
injection. Extension scripts are served from known paths only.
---
## 5. Browser-Defined Tools (The Bridge)
This is the critical innovation. The LLM tool call originates from the
provider response, arrives at the Go backend, and needs to execute in the
user's browser. The EventBus + WebSocket bridge makes this transparent.
### 5.1 The Flow
```
User sends message
→ Backend sends to LLM with tools[] from enabled extensions
→ LLM returns tool_call: { name: "read_file", args: {...} }
→ Backend checks tool registry → tool is tier:browser
→ Backend publishes via WebSocket:
event: tool.call.{callId}
data: { tool: "read_file", args: {...}, callId: "uuid" }
→ Browser extension receives event, executes tool handler
→ Extension publishes result via WebSocket:
event: tool.result.{callId}
data: { callId: "uuid", result: "file contents..." }
→ Backend receives result, feeds back to LLM as tool_result
→ LLM continues with the result
→ Response streams to user
```
### 5.2 Tool Registration
Extensions declare tools in their manifest and implement handlers:
```json
{
"tools": [
{
"name": "estimate_cost",
"description": "Estimate the token cost of a given text",
"parameters": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to estimate" }
},
"required": ["text"]
},
"tier": "browser"
}
]
}
```
```js
Extensions.register({
id: 'cost-tracker',
init(ctx) {
ctx.tools.handle('estimate_cost', async (args) => {
const tokens = this.estimateTokens(args.text);
return { tokens, estimated_cost: tokens * this.pricing / 1e6 };
});
}
});
```
### 5.3 Server-Side Tool Router
The completion handler maintains a tool registry. When building the
`tools[]` array for the LLM request:
```go
func (h *CompletionHandler) collectTools(userID string) []ToolSchema {
var tools []ToolSchema
// Tier 1: Starlark tools (server-side, execute inline)
tools = append(tools, h.starlarkTools()...)
// Tier 2: Sidecar tools (server-side, HTTP call)
tools = append(tools, h.sidecarTools()...)
// Tier 0: Browser tools (client-side, routed via WebSocket)
if h.hub.IsConnected(userID) {
tools = append(tools, h.browserTools(userID)...)
}
return tools
}
```
When a `tool_call` arrives and the tool is `tier: browser`:
```go
case "browser":
callId := uuid.New().String()
bus.Publish(events.Event{
Type: "tool.call." + callId,
Data: toolCallData,
Room: "user:" + userID,
})
result, err := bus.WaitFor("tool.result." + callId, 30*time.Second)
```
### 5.4 Timeout and Fallback
Browser tools have a 30-second timeout. If the browser disconnects or the
tool fails, the backend sends a `tool_result` with an error message to the
LLM so it can recover gracefully.
### 5.5 Why This Matters
An extension author can write a tool in 20 lines of JavaScript that the
LLM can call. No Go code. No container. No deployment. The cluster manager
extension defines `kubectl_get`, `ceph_status`, `node_drain` as browser
tools. The editor extension defines `read_file`, `write_file`,
`search_replace`. The LLM doesn't know or care where the tool executes.
---
## 6. Surfaces (Modes)
A "mode" is an extension that registers a **surface** — a UI region that
replaces or augments the default chat area.
### 6.1 Surface Regions
```
┌─────────────────────────────────────────────┐
│ sidebar-top │ surface-header │
│ │ │
│ sidebar-nav │ │
│ (mode selector) │ surface-main │
│ │ (chat, editor, article, │
│ sidebar-content │ cluster, ...) │
│ (context panel) │ │
│ │ │
│ sidebar-bottom │ surface-footer │
│ │ (input area) │
└─────────────────────────────────────────────┘
```
### 6.2 Surface Registration
```json
{
"surfaces": [
{
"id": "editor",
"label": "Editor",
"icon": "code",
"regions": ["surface-main", "surface-footer", "sidebar-content"],
"default": false
}
]
}
```
```js
Extensions.register({
id: 'editor-mode',
init(ctx) {
ctx.surfaces.register('editor', {
activate() {
ctx.ui.replace('surface-main', this.renderEditor());
ctx.ui.replace('surface-footer', this.renderEditorInput());
ctx.ui.replace('sidebar-content', this.renderFileTree());
},
deactivate() {
ctx.ui.restore('surface-main');
ctx.ui.restore('surface-footer');
ctx.ui.restore('sidebar-content');
}
});
}
});
```
### 6.3 Mode Selector
When extensions register surfaces, a mode selector appears in the sidebar.
Clicking a mode calls `activate()` on that surface and `deactivate()` on
the current one. The bus event `surface.activated` fires so other
extensions can react.
### 6.4 Core Surface
Chat mode is the default surface. It's not special architecturally — it's
the surface that's active when no extension surface is selected. Pragmatically
it stays in core because everything depends on it.
### 6.5 Planned Modes
**Editor Mode** (extension)
- File tree in sidebar, code editor in main, AI chat in split/overlay.
- Tools: `read_file`, `write_file`, `search_replace`, `git_status`,
`git_commit` — browser tools backed by a git provider API.
- This is ai-editor rebuilt properly: tool calls are server-side, logged,
token-counted, auditable.
**Article Mode** (extension)
- Outline in sidebar, rich text editor in main, AI assistant in panel.
- Tools: `fetch_url`, `summarize_section`, `check_citation`.
- The conversation is hidden; only the document matters.
**Cluster Manager Mode** (2-3 cooperating extensions)
- `k8s-manager`: kubectl tools, pod/deployment views.
- `ceph-manager`: ceph status, OSD management.
- `node-manager`: SSH commands, system metrics.
- These talk to each other via EventBus. When `k8s-manager` detects a
node is NotReady, it publishes `cluster.node.unhealthy`. `ceph-manager`
subscribes and checks OSD placement.
- The LLM sees ALL tools from ALL active cluster extensions. "Drain node-3,
ensure Ceph rebalances, then cordon it" → `kubectl_drain`, `ceph_osd_out`,
`kubectl_cordon` — each routed to the appropriate extension.
---
## 7. Extension Loader Architecture
### 7.1 Load Order
```html
<!-- Core -->
<script src="js/events.js"></script>
<script src="js/extensions.js"></script> <!-- loader + registry -->
<!-- Extensions (injected by loader) -->
<script src="/api/v1/extensions/cost-tracker/assets/main.js"></script>
<script src="/api/v1/extensions/editor-mode/assets/main.js"></script>
<!-- App (runs after extensions registered) -->
<script src="js/api.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
```
### 7.2 extensions.js (Core)
The extension loader and registry. ~200 lines. Responsibilities:
- Fetch enabled extensions from `/api/v1/extensions?tier=browser`
- Inject script tags in dependency order
- Provide `Extensions.register()` API
- Build scoped `ctx` objects per extension (permission enforcement)
- Manage surface activation/deactivation
- Collect browser tool schemas for the completion handler
- Route `tool.call.*` events to the correct handler
### 7.3 Backend Support
New tables (migration):
```sql
CREATE TABLE extensions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ext_id VARCHAR(100) NOT NULL UNIQUE, -- manifest id
name VARCHAR(200) NOT NULL,
tier VARCHAR(20) NOT NULL DEFAULT 'browser',
manifest JSONB NOT NULL,
assets_path TEXT, -- filesystem path for browser JS
endpoint TEXT, -- URL for sidecar
script TEXT, -- inline Starlark source
installed_by UUID REFERENCES users(id),
is_system BOOLEAN DEFAULT false,
is_enabled BOOLEAN DEFAULT true,
scope VARCHAR(20) DEFAULT 'global', -- global, team, personal
team_id UUID REFERENCES teams(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE extension_user_settings (
extension_id UUID REFERENCES extensions(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
settings JSONB DEFAULT '{}',
is_enabled BOOLEAN DEFAULT true,
PRIMARY KEY (extension_id, user_id)
);
```
New endpoints:
```
GET /api/v1/extensions — list enabled for current user
GET /api/v1/extensions/:id/manifest — get manifest
GET /api/v1/extensions/:id/assets/*path — serve browser JS
POST /api/v1/extensions/:id/settings — update user settings
POST /api/v1/admin/extensions — install extension
DELETE /api/v1/admin/extensions/:id — uninstall
PUT /api/v1/admin/extensions/:id — enable/disable, config
```
---
## 8. EventBus Integration
The routing table from `events/types.go` expands:
```go
var Routes = map[string]Direction{
// Core
"chat.message.*": DirBoth,
"user.presence": DirToClient,
// Tool execution
"tool.call.*": DirToClient, // Server → specific client
"tool.result.*": DirFromClient, // Client → server
// Surfaces
"surface.activated": DirLocal, // Client-only
"surface.deactivated": DirLocal,
// Extension lifecycle
"extension.loaded": DirLocal,
"extension.error": DirLocal,
// Cross-extension (cluster manager example)
"cluster.node.*": DirLocal, // Between browser extensions
"cluster.alert.*": DirBoth, // Could notify server too
}
```
Browser extensions use `Events.on()` and `Events.emit()` — the same API
the core app uses. `DirLocal` events stay in the browser. `DirBoth` and
`DirFromClient` events cross the WebSocket.
---
## 9. Built-in Tools
These ship with core because multiple modes and services depend on them:
| Tool | Tier | Description |
|---|---|---|
| `web_search` | Sidecar | Search provider abstraction (SearXNG, Brave, DuckDuckGo) |
| `url_fetch` | Server | Retrieve and extract content from a URL |
| `note_create` | Server | Create a note with title, content, folder, tags |
| `note_search` | Server | Full-text search across user's notes |
| `note_update` | Server | Update note content |
| `note_list` | Server | List notes with optional folder filter |
| `kb_search` | Server | Semantic search across knowledge bases (future) |
| `task_create` | Server | Schedule a new task (future) |
Extension-provided tools (not core, expected early extensions):
- `read_file`, `write_file`, `search_replace` (Editor mode)
- `kubectl_get`, `ceph_status`, `node_ssh` (Cluster manager)
- `generate_image` (Image gen, browser or sidecar)
- `speak_text`, `transcribe_audio` (STT/TTS, browser via Web Speech API)
---
## 10. Design Principles
1. **Extensions are first-class.** A mode or feature implemented as an
extension is indistinguishable from one built into core. No second-class
citizens.
2. **The EventBus is the spine.** Extensions don't import each other.
They publish and subscribe to events. This is how cluster manager
extensions cooperate without knowing about each other at build time.
3. **Tools are location-transparent.** The LLM sees a tool schema. It
doesn't know if the tool runs in the browser, in a Starlark sandbox,
or in a container. The routing is the platform's problem.
4. **Permissions are declared, not discovered.** An extension says what it
needs upfront. The loader enforces it. No ambient authority.
5. **The core stays small.** Auth, provider routing, message persistence,
event bus, extension loader. That's core. Everything else is an
extension — even if it ships with the project.
6. **Progressive capability.** A browser extension with zero tools and
zero surfaces is just an EventBus subscriber. Add a tool and the LLM
can call it. Add a surface and it becomes a mode. The same manifest
format scales from "show token count" to "full IDE."
---
## Appendix A: Custom Renderers
Custom renderers are the simplest useful browser extension. They intercept
message content rendering to handle specific content types — no tools, no
surfaces, just a pattern match and a render function.
```js
Extensions.register({
id: 'collapsible-code',
init(ctx) {
ctx.renderers.register('collapsible-code', {
// Match fenced code blocks with >10 lines
pattern: /^```(\w+)?\n([\s\S]{10,}?)```$/gm,
render(match, container) {
const lang = match[1] || 'text';
const code = match[2];
const lines = code.split('\n');
const details = document.createElement('details');
details.innerHTML = `
<summary>${lang}${lines.length} lines</summary>
<pre><code class="language-${lang}">${escapeHtml(code)}</code></pre>
`;
container.replaceWith(details);
}
});
}
});
```
Expected first-party renderer extensions (ship with core or as official):
| Renderer | Matches | Renders |
|---|---|---|
| `collapsible-code` | Code blocks > N lines | `<details>` with expand/collapse |
| `html-preview` | ```html blocks | Sandboxed iframe with live preview |
| `mermaid` | ```mermaid blocks | SVG diagram via mermaid.js |
| `latex-math` | `$...$` and `$$...$$` | Rendered math via KaTeX |
| `doc-preview` | Generated HTML/PDF content | Preview panel with download link |
These are all ~30-50 line browser extensions. They don't need tool calling
or surfaces. They just pattern-match content in rendered messages.
---
## Appendix B: Model Roles
Extensions (and core services like compaction) may need to call LLMs for
their own purposes — summarization, embedding, classification — independent
of the user's selected chat model. **Model roles** are named slots that
resolve to a specific provider+model at runtime.
### B.1 Role Definition
```
┌──────────────────────────────────────────────────────────────┐
│ Role │ Purpose │ Typical Model │
├──────────────────────────────────────────────────────────────┤
│ utility │ Summarization, routing │ gpt-4o-mini │
│ │ classification, triage │ deepseek-chat │
│ embedding │ Vector generation for │ text-embedding-3 │
│ │ KB search, note search │ nomic-embed-text │
└──────────────────────────────────────────────────────────────┘
```
> **Note:** The `generation` role (image/media) was removed in v0.10.2.
> Image generation will be extension-managed with its own provider config
> and API surface, not a named role slot.
### B.2 Configuration
Admin configures roles in global settings, with optional fallback chain:
```json
{
"model_roles": {
"utility": {
"primary": { "provider_config_id": "abc", "model_id": "gpt-4o-mini" },
"fallback": { "provider_config_id": "def", "model_id": "deepseek-chat" }
},
"embedding": {
"primary": { "provider_config_id": "abc", "model_id": "text-embedding-3-small" },
"fallback": null
}
}
}
```
Teams can override roles for their scope. Personal overrides TBD.
### B.3 Extension API
```js
// Browser extension requests a utility completion
const result = await ctx.models.complete('utility', {
messages: [{ role: 'user', content: 'Summarize this in 2 sentences: ...' }]
});
// Server-side: extension requests an embedding
vec, err := models.Embed(ctx, "embedding", "text to embed")
```
The caller doesn't know which model or provider is being used. The role
system resolves it, tries fallback if primary fails, and logs usage
against the role for cost tracking.
### B.4 Core Consumers
| Consumer | Role | Purpose |
|---|---|---|
| Compaction service | `utility` | Summarize long conversations |
| Smart routing | `utility` | Classify intent, pick best model |
| Summarize & Continue | `utility` | Conversation compaction (v0.10.2) |
| Knowledge bases | `embedding` | Generate vectors for similarity search |
| Note search (future) | `embedding` | Semantic search across notes |
| Image gen extension | _(extension-managed)_ | Uses own provider config, not a role |
### B.5 Relationship to Smart Routing
Model roles and smart routing are complementary:
- **Model roles**: "I need *a* cheap model for summarization" → resolves
to a specific provider+model based on admin config.
- **Smart routing**: "Route *this user's chat* to the best model" → policy
engine that picks based on capabilities, cost, latency, team rules.
Roles are for background/system tasks. Routing is for user-facing chat.
Both use the same provider infrastructure and cost tracking.

530
docs/REFACTOR-0.10.3.md Normal file
View File

@@ -0,0 +1,530 @@
# v0.10.3 — Frontend Refactor Plan
## Problem
Two monolith files accumulate every feature:
| File | Lines | Role |
|----------|------:|-----------------------------------|
| app.js | 2,940 | All application logic + listeners |
| ui.js | 2,582 | All rendering + admin + settings |
| api.js | 575 | HTTP client (fine) |
| debug.js | 580 | Debug logger (fine) |
| events.js| 327 | EventBus WebSocket (fine) |
| **Total**| **7,004** | |
The v0.10.2 syntax bug (orphan brace nesting everything from notes
through banners) is a direct consequence: in a 2,940-line file, a
misplaced insertion is invisible.
## Constraints
- **Vanilla JS** — no build step, no bundler, no ES modules.
- **Global functions** — HTML `onclick` handlers call ~25 named globals.
These must stay on `window`. No renaming.
- **Load order via `<script>` tags** — dependencies load first.
- **Service worker** — `sw.js` pre-caches the file list; must be updated.
- **Cache busting** — all `<script>` tags use `?v=%%APP_VERSION%%`.
- **Docker entrypoint** — `COPY src/` covers everything. No changes needed.
- **CI syntax check** — already runs `node --check` on `src/js/*.js`.
## Architecture
The `UI` object stays as a single global with methods. Domain files extend
it via `Object.assign(UI, { ... })`. The `App` state object stays in
`app.js`. All functions stay global.
**Load order** (dependency graph, top to bottom):
```
vendor/marked.min.js, vendor/purify.min.js
debug.js, events.js
api.js
ui-format.js ← esc(), markdown rendering (used by everything)
ui-core.js ← UI object: sidebar, chat list, messages, streaming, model selector
ui-settings.js ← Object.assign(UI, { settings/team methods })
ui-admin.js ← Object.assign(UI, { admin modal methods })
tokens.js ← Tokens object, context tracking
notes.js ← Notes panel
chat.js ← Chat ops, send, regen, edit, branch, summarize
settings-handlers.js ← Provider CRUD, command palette, save handlers
admin-handlers.js ← Admin actions, presets, team management
app.js ← State, init, boot, auth, listeners (orchestrator)
```
## File Breakdown
### Unchanged (3 files, 1,482 lines)
| File | Lines | Notes |
|------|------:|-------|
| api.js | 575 | Clean. Self-contained HTTP client. |
| debug.js | 580 | Clean. Debug logger + modal. |
| events.js | 327 | Clean. EventBus WebSocket. |
### From ui.js → 4 files
#### ui-format.js (~280 lines)
Extract from ui.js bottom section + helpers.
```
Formatting (L2241-2462):
_renderMarkdown(text)
_renderCodeBlocks(html)
_copyCodeBlock(btn)
_formatTokenCount(n)
_formatCost(c)
Helpers (L2519-2583):
esc(s) ← global function, used everywhere
_formatTimestamp(ts)
_formatRelativeTime(ts)
_truncate(s, n)
openSidePanel(panelId) ← global, called from notes/search
closeSidePanel() ← global, HTML onclick
Side Panel Resize (L2463-2518):
_initSidePanelResize()
```
**Why separate:** Formatting is a pure utility layer. Every other UI file
depends on `esc()` and `_renderMarkdown()`. Loading first eliminates
forward references.
#### ui-core.js (~650 lines)
The `UI` object definition with core rendering methods.
```
const UI = {
// Sidebar (L189-216)
toggleSidebar(), restoreSidebar(),
toggleUserMenu(), closeUserMenu(),
// Chat List (L217-284)
renderChatList(),
// Messages (L285-454)
renderMessages(), showEmptyState(),
_messageHTML(), _summaryHTML(), _isSummaryMessage(),
toggleSummarizedHistory(),
// Streaming (L455-596)
streamResponse(),
// Model Selector (L597-727)
getModelValue(), setModelValue(),
updateModelSelector(), initModelDropdown(),
// Capability Badges (L728-774)
getSelectedModelCaps(), updateCapabilityBadges(),
// User / Connection (L775-811)
updateUser(), showAdminButton(),
// Generating State (L812-862)
setGenerating(), showRegenerate(),
// Toast (L863-873)
toast(),
// Settings Modal (L874-921) — thin dispatcher
openSettings(), switchSettingsTab(),
// Export (L2191-2214)
exportMarkdown(), exportJSON(),
// Message Actions (L2215-2240)
(delegated action handlers)
// Scroll (L2226-2240)
scrollToBottom(),
};
```
Also includes the top-level `_avatarHTML()` helper (L5-24) and
summary message helpers (L167-188).
#### ui-settings.js (~550 lines)
Extends UI with all settings modal tab content.
```
Object.assign(UI, {
// Preset Form Component (L25-166) — _presetFormHTML(), etc.
// Appearance (L922-1267)
loadAppearanceSettings(), initAppearance(),
// Providers (L1444-1480)
loadProviderList(), hideProviderForm(),
// Profile (L994-1002)
loadProfileIntoSettings(),
// Teams (L1003-1267)
loadMyTeams(), loadTeamsTab(), openTeamManage(),
showTeamPicker(), loadTeamManageMembers(),
loadTeamManageProviders(), loadTeamManagePresets(),
loadTeamAuditLog(), loadTeamPresetModelDropdown(),
switchTeamTab(),
// Usage (L1160-1267)
loadMyUsage(), loadTeamUsage(),
// User Model Roles (L1268-1328)
loadUserRoles(),
// User Models & Presets (L2118-2190)
loadUserModels(), loadUserPresets(),
// checkUserProvidersAllowed(), checkUserPresetsAllowed()
});
```
#### ui-admin.js (~650 lines)
Extends UI with all admin modal content.
```
Object.assign(UI, {
// Admin Modal (L1481-1604)
openTeamAdmin(), switchAdminTab(),
// Users (L1549-1588)
loadAdminUsers(),
// Stats (L1589-1604)
loadAdminStats(),
// Roles (L1605-1658)
loadAdminRoles(),
// Usage + Audit (L1659-1813)
loadAdminUsage(), loadAuditLog(),
// Providers (L1814-1835)
loadAdminProviders(),
// Models (L1836-1863)
loadAdminModels(),
// Presets (L1864-1939)
loadAdminPresets(),
// Teams (L1940-2117)
loadAdminTeams(), openTeamDetail(),
loadTeamMembers(), loadMemberUserDropdown(),
// Settings (L2037-2117)
loadAdminSettings(), updateBannerPreview(),
});
```
### From app.js → 6 files
#### tokens.js (~120 lines)
Already has its own `Tokens` namespace — clean extraction.
```
const Tokens = { ... }; // L36-74
function updateInputTokens() // L78-106
function updateContextWarning() // L108-147
function dismissContextWarning() // L148-152
```
#### notes.js (~300 lines)
Self-contained with own state variables.
```
var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
openNotes(), loadNotesList(), _noteListItem()
_highlightHeadline()
_enterSelectMode(), _exitSelectMode()
_toggleNoteSelect(), _toggleSelectAll()
_updateSelectedCount(), _bulkDeleteSelected()
loadNoteFolders(), showNotesList()
copyNoteContent(), openNoteEditor()
_populateEditFields(), _clearEditFields()
_showNoteReadMode(), _showNoteEditMode()
_showNotePreview()
saveNote(), deleteNote()
_initNotesListeners() ← NEW: extracted from initListeners
```
#### chat.js (~500 lines)
All conversation operations.
```
// Chat management
loadChats(), selectChat(), newChat(), deleteChat()
// Per-chat model persistence
_saveChatModel(), _restoreChatModel()
// Send + stream
sendMessage(), stopGeneration()
// Active path
reloadActivePath()
// Regenerate
regenerateMessage(), regenerate()
// Edit
editMessage(), submitEdit(), cancelEdit()
// Branch navigation
switchSibling()
// Summarize & Continue
summarizeAndContinue()
// Preview
clearPreview()
_initChatListeners() ← NEW: extracted from initListeners
```
#### settings-handlers.js (~400 lines)
Settings-related handlers + command palette.
```
// Settings
handleSaveSettings(), loadSettings(), saveSettings()
// Avatar
updateAvatarPreview()
// Providers
handleCreateProvider(), deleteProvider()
refreshProviderModels(), editProvider()
// Admin settings save
handleSaveAdminSettings()
// User model roles
userRoleProviderChanged(), saveUserRole(), clearUserRole()
// Command Palette
_cmdCommands[], toggleCmdPalette(), openCmdPalette()
closeCmdPalette(), _handleCmdKey(), _highlightCmdItem()
_getVisibleCommands(), _renderCmdResults(), executeCmdAction()
_initSettingsListeners() ← NEW: extracted from initListeners
```
#### admin-handlers.js (~450 lines)
Admin actions + team management.
```
// Admin user management
toggleUserActive(), showApproveForm(), hideApproveForm()
handleApproveUser(), resetUserPassword(), promoteUser()
createAdminUser()
// Admin roles
adminSaveRole(), adminRoleProviderChanged()
// User model preferences
toggleModelVisibility(), bulkSetVisibility()
bulkSetUserModelVisibility(), deleteUserPreset()
// Admin presets
_adminPresetForm, ensureAdminPresetForm()
editAdminPreset(), createAdminPreset()
toggleAdminPreset(), deleteAdminPreset()
// Team admin (system admin)
toggleTeamActive(), deleteTeam()
updateTeamMember(), removeTeamMember()
// Team admin (settings-side)
settingsUpdateTeamMember(), settingsRemoveTeamMember()
settingsDeleteTeamPreset(), settingsToggleTeamProvider()
settingsDeleteTeamProvider(), settingsEditTeamProvider()
_initAdminListeners() ← NEW: extracted from initListeners
```
#### app.js (~400 lines) — the orchestrator
What remains after extraction.
```
// State
const App = { ... };
// Models
resolveCapabilities(), fetchModels()
// Init + Boot
init(), startApp()
// Auth flow
showSplash(), hideSplash()
handleLogin(), handleRegister(), handleLogout()
switchAuthTab(), setAuthError(), setAuthLoading()
// Modal helpers
openModal(), closeModal()
checkTabsOverflow(), updateTabArrows()
// Branding + Banners
initBranding(), initBanners()
// Listeners — thin dispatcher
function initListeners() {
if (_listenersInit) return;
_listenersInit = true;
_initChatListeners(); // from chat.js
_initSettingsListeners(); // from settings-handlers.js
_initAdminListeners(); // from admin-handlers.js
_initNotesListeners(); // from notes.js
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
_initSidePanelResize(); // from ui-format.js
}
// Boot
document.addEventListener('DOMContentLoaded', init);
```
## Execution Steps
### 1. Preparation (before any splits)
- [ ] Branch from merged v0.10.2
- [ ] Add `node --check` validation to a quick local script:
`for f in src/js/*.js; do node --check "$f" || exit 1; done`
- [ ] Run all frontend tests as baseline: `node --test src/js/__tests__/*.test.js`
### 2. Extract ui-format.js
- [ ] Create `src/js/ui-format.js`
- [ ] Move: `esc()`, formatting functions, side panel resize, helpers
- [ ] Remove from `ui.js`
- [ ] Add `<script>` tag in `index.html` **before** `ui.js`
- [ ] Add to `sw.js` SHELL_FILES
- [ ] Run syntax check + tests
### 3. Extract ui-settings.js
- [ ] Create `src/js/ui-settings.js`
- [ ] Move settings/team UI methods out of UI object in `ui.js`
- [ ] Add `Object.assign(UI, { ... })` wrapper
- [ ] Add `<script>` tag **after** `ui.js`
- [ ] Add to `sw.js`
- [ ] Run syntax check + tests
### 4. Extract ui-admin.js
- [ ] Create `src/js/ui-admin.js`
- [ ] Move admin UI methods out of UI object
- [ ] Add `Object.assign(UI, { ... })` wrapper
- [ ] Add `<script>` tag **after** `ui.js`
- [ ] Add to `sw.js`
- [ ] Run syntax check + tests
### 5. Extract tokens.js
- [ ] Create `src/js/tokens.js`
- [ ] Move `Tokens` object + `updateInputTokens` + `updateContextWarning` + `dismissContextWarning`
- [ ] Remove from `app.js`
- [ ] Add `<script>` tag **before** `app.js`
- [ ] Add to `sw.js`
- [ ] Run syntax check + tests
### 6. Extract notes.js
- [ ] Create `src/js/notes.js`
- [ ] Move all notes state + functions
- [ ] Extract `_initNotesListeners()` from `initListeners()` — cut the
notes-related event bindings into a new function, call it from
`initListeners()`
- [ ] Add `<script>` tag **before** `app.js`
- [ ] Add to `sw.js`
- [ ] Run syntax check + tests
### 7. Extract chat.js
- [ ] Create `src/js/chat.js`
- [ ] Move chat management, send, regen, edit, branch, summarize, model persistence
- [ ] Extract `_initChatListeners()` from `initListeners()` — cut chat input,
Enter-to-send, message action delegation, etc.
- [ ] Add `<script>` tag **before** `app.js`
- [ ] Add to `sw.js`
- [ ] Run syntax check + tests
### 8. Extract settings-handlers.js
- [ ] Create `src/js/settings-handlers.js`
- [ ] Move settings handlers, provider CRUD, command palette, avatar, user roles
- [ ] Extract `_initSettingsListeners()` from `initListeners()`
- [ ] Add `<script>` tag **before** `app.js`
- [ ] Add to `sw.js`
- [ ] Run syntax check + tests
### 9. Extract admin-handlers.js
- [ ] Create `src/js/admin-handlers.js`
- [ ] Move admin actions, presets, team management
- [ ] Extract `_initAdminListeners()` from `initListeners()`
- [ ] Add `<script>` tag **before** `app.js`
- [ ] Add to `sw.js`
- [ ] Run syntax check + tests
### 10. Final validation
- [ ] `node --check` all JS files
- [ ] `node --test src/js/__tests__/*.test.js` — all 159 tests pass
- [ ] Verify `index.html` script order matches dependency graph
- [ ] Verify `sw.js` SHELL_FILES lists all new files
- [ ] Manual smoke test: login, chat, settings, admin, notes, debug
- [ ] Grep for any remaining forward references / undefined calls
- [ ] Update ROADMAP: mark v0.10.3 complete
- [ ] Update CHANGELOG
## Target State
| File | Lines | Domain |
|------|------:|--------|
| api.js | 575 | HTTP client, auth tokens |
| debug.js | 580 | Debug logger + modal |
| events.js | 327 | EventBus WebSocket |
| ui-format.js | ~280 | Markdown, code blocks, esc(), helpers |
| ui-core.js | ~650 | UI object: rendering, model selector, streaming |
| ui-settings.js | ~550 | Settings tabs, teams, providers, user prefs |
| ui-admin.js | ~650 | Admin tabs, users, roles, usage, teams |
| tokens.js | ~120 | Context tracking, token estimation |
| notes.js | ~300 | Notes panel, editor, multi-select |
| chat.js | ~500 | Chat ops, send, regen, edit, branch, summarize |
| settings-handlers.js | ~400 | Settings save, provider CRUD, command palette |
| admin-handlers.js | ~450 | Admin actions, presets, team management |
| app.js | ~400 | State, init, boot, auth, listener dispatch |
| **Total** | **~5,782** | 13 files (was 5) |
Line count drops slightly due to removed duplicate section headers and
consolidated comments. Average file: ~445 lines. Largest: ~650.
## Risk Mitigation
**Forward references:** Function declarations are hoisted within a
`<script>`, but not across scripts. All functions called by
`initListeners()` must be defined in scripts loaded before `app.js`.
The load order above ensures this.
**`UI` object extension:** `Object.assign(UI, { ... })` in
ui-settings.js and ui-admin.js means `UI` must be defined first
(in ui-core.js). The load order handles this.
**Regression testing:** Each extraction step ends with syntax check +
test run. If anything breaks, it's isolated to the last extraction.
**Rollback:** Each step is a single commit. Revert any step independently.
## What This Does NOT Change
- No ES modules, no import/export, no bundler
- No function renaming (HTML onclick handlers untouched)
- No new dependencies
- No architectural changes to backend
- No feature additions
- The `UI` object is still a single global — just defined across files
- All tests continue to work unchanged

1187
docs/ROADMAP.md Normal file

File diff suppressed because it is too large Load Diff

358
docs/UI-AUDIT-0.10.5.md Normal file
View File

@@ -0,0 +1,358 @@
# UI Duplication Audit — v0.10.5 ✅ IMPLEMENTED
Systematic catalog of every repeated pattern in the frontend.
Each section: what's duplicated, where, and what's inconsistent.
---
## 1. Provider Form (CREATE + EDIT) — 3 copies
The same "configure a provider" concept exists in **3 places** with
different field IDs, different features, and different behaviors.
### Where
| Context | Create handler | Edit handler | HTML form IDs |
|---------|---------------|-------------|---------------|
| **User BYOK** | `handleCreateProvider()` settings-handlers.js:88 | same fn (dual-mode via `UI._editingProviderId`) | `providerName`, `providerType`, `providerEndpoint`, `providerApiKey`, `providerDefaultModel` |
| **Admin Global** | `createGlobalProvider()` admin-handlers.js:122 | same fn (dual-mode via `_editingProviderId`) | `adminProvName`, `adminProvType`, `adminProvEndpoint`, `adminProvKey`, `adminProvModel`, `adminProvPrivate` |
| **Team** | inline anon listener settings-handlers.js:571 | `settingsTeamEditProviderSubmit` listener :597 | Create: `teamProviderName`, `teamProviderType`, `teamProviderEndpoint`, `teamProviderKey`, `teamProviderPrivate` / Edit: `teamProviderEditName`, `teamProviderEditEndpoint`, `teamProviderEditKey`, `teamProviderEditDefault`, `teamProviderEditPrivate` |
### Inconsistencies
| Feature | User BYOK | Admin | Team Create | Team Edit |
|---------|-----------|-------|-------------|-----------|
| **Endpoint auto-fill on type change** | ✅ | ❌ MISSING | ✅ | ❌ N/A (no type field) |
| **"Private" checkbox** | ❌ | ✅ | ✅ | ✅ |
| **"Default Model" field** | ✅ | ✅ | ❌ MISSING | ✅ |
| **Provider type dropdown** | Static HTML | Static HTML | Dynamic JS (same labels) | N/A (no type change) |
| **Provider types defined in** | index.html:408 | index.html:626 | settings-handlers.js:553 | — |
| **Endpoint defaults defined in** | settings-handlers.js:480 | NOWHERE | settings-handlers.js:542 | — |
| **API key placeholder** | "sk-..." / "(unchanged)" | "sk-..." / "(unchanged)" | "sk-..." | "(unchanged)" |
| **Post-save refresh** | `loadProviderList()` + `fetchModels()` | `loadAdminProviders()` (no fetchModels) | `loadTeamManageProviders()` + `fetchModels()` | `loadTeamManageProviders()` (no fetchModels) |
| **Create/Edit dual-mode** | Single fn, flag | Single fn, flag | Separate forms + separate listeners | Separate forms + separate listeners |
| **Form show/hide** | `UI.showProviderForm()`/`hideProviderForm()` | inline `.style.display` | inline `.style.display` | inline `.style.display` |
### Constants duplicated
```
Provider types: 3× — index.html (2 static selects) + settings-handlers.js (1 dynamic build)
Provider labels: 3× — { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' }
Endpoint map: 2× — settings-handlers.js:480 and :542 (admin has ZERO)
```
---
## 2. Provider List (READ) — 3 copies
| Context | Function | File:Line | API call |
|---------|----------|-----------|----------|
| **User BYOK** | `UI.loadProviderList()` | ui-settings.js:534 | `API.listConfigs()` |
| **Admin Global** | `UI.loadAdminProviders()` | ui-admin.js:343 | `API.adminListGlobalConfigs()` |
| **Team** | `UI.loadTeamManageProviders(teamId)` | ui-settings.js:191 | `API.teamListProviders(teamId)` |
### Inconsistencies
| Feature | User BYOK | Admin | Team |
|---------|-----------|-------|------|
| Shows endpoint | ❌ | ✅ | ❌ |
| Shows provider type | ✅ (in meta) | ✅ (in meta) | ✅ (in meta) |
| Shows has_key indicator | ✅ 🔑/⚠️ | ❌ | ✅ 🔑 |
| Shows active/inactive toggle | ❌ | ❌ | ✅ |
| Shows private badge | ❌ | ✅ | ✅ |
| Edit button | ✅ | ✅ | ✅ |
| Delete button | ✅ | ✅ | ✅ |
| Refresh models button | ✅ | ❌ (separate bulk) | ❌ |
| Row CSS class | `provider-row` | `admin-provider-row` | `admin-preset-row` (!) |
| Empty state msg | different | different | different |
| Provider cache | ❌ | ✅ (`UI._providerCache`) | ❌ |
---
## 3. Role Configuration — 2 copies
| Context | Render function | Provider change handler | Save handler |
|---------|----------------|------------------------|-------------|
| **Admin Global** | `UI.loadAdminRoles()` ui-admin.js:134 | `adminRoleProviderChanged()` admin-handlers.js:199 | `adminSaveRole()` admin-handlers.js:216 |
| **User Personal** | `UI.loadUserRoles()` ui-settings.js:356 | `userRoleProviderChanged()` settings-handlers.js:214 | `saveUserRole()` settings-handlers.js:231 |
### Inconsistencies
| Feature | Admin | User |
|---------|-------|------|
| Fallback slot | ✅ primary + fallback | ❌ primary only |
| Test button | ✅ | ❌ |
| Clear/remove button | ❌ | ✅ |
| Models source | `window._adminModelList` (from admin API) | `App.models` (from enabled API) |
| Model ID field | `m.model_id` | `m.baseModelId` |
| Reload on save | ✅ (added in 0.10.4) | ✅ (always did) |
| BYOK gate check | ❌ (admin sees always) | ✅ (hidden if no BYOK) |
---
## 4. Capability Badges — 3 copies
The same badge rendering logic exists in 3 places with different levels of detail:
| Context | File:Line | Badges shown |
|---------|-----------|-------------|
| **Model selector** (chat) | ui-core.js:759-774 | max_output, max_context, tools, vision, thinking, reasoning, code, search — with titles and labels |
| **Admin model list** | ui-admin.js:375-378 | max_output, tools, vision, thinking — compact, no titles |
| **User model list** | ui-settings.js:596-599 | max_output, tools, vision, thinking — compact, no titles |
Admin and User are identical copies. Model selector has more detail.
---
## 5. Usage Dashboard — 3 copies
| Context | Function | File:Line | API call |
|---------|----------|-----------|----------|
| **Admin** | `UI.loadAdminUsage()` | ui-admin.js:190 | `API.adminGetUsage()` |
| **User (My Usage)** | `UI.loadMyUsage()` | ui-settings.js:246 | `API.getMyUsage()` |
| **Team** | `UI.loadTeamUsage()` | ui-settings.js:297 | `API.teamGetUsage()` |
### Inconsistencies
| Feature | Admin | User | Team |
|---------|-------|------|------|
| Pricing table | ✅ | ❌ | ❌ |
| "group by user" column header | ✅ | ❌ | ✅ |
| Stats card styling | default padding | `padding:8px; font-size:16px` | `padding:8px; font-size:16px` |
| Stats label font | default | `font-size:10px` | `font-size:10px` |
| Empty state check | `results.length === 0` | `!t.requests` | `!t.requests` |
| Grid layout | default | `grid-template-columns:repeat(4,1fr)` | `grid-template-columns:repeat(4,1fr)` |
| Table font size | default | `font-size:12px` | `font-size:12px` |
User and Team are near-identical. Admin is a separate fork.
---
## 6. Provider Type / Endpoint Constants — scattered everywhere
The "known provider types" concept is defined in **5 places**:
| # | Location | Format |
|---|----------|--------|
| 1 | index.html:408 | `<option>` tags in User BYOK select |
| 2 | index.html:626 | `<option>` tags in Admin select |
| 3 | settings-handlers.js:553 | JS object `{ openai: 'OpenAI-compatible', ... }` for Team dynamic build |
| 4 | settings-handlers.js:480 | Endpoint map for User BYOK |
| 5 | settings-handlers.js:542 | Endpoint map for Team (separate copy) |
| — | server/providers/registry.go | Backend `Init()` registers providers — source of truth |
Adding a new provider type requires touching 5 frontend locations.
---
## 7. Preset Form — ALREADY SHARED ✅
`renderPresetForm()` in ui-core.js:33 is used by:
- Admin presets (admin-handlers.js:303)
- Team presets (settings-handlers.js:511)
- User presets (settings-handlers.js:626)
This is the ONE pattern that's already been consolidated into a primitive.
Good reference for how the other patterns should work.
---
## 8. Model List — 2 copies
| Context | Function | File:Line |
|---------|----------|-----------|
| **Admin models** | `UI.loadAdminModels()` | ui-admin.js:365 |
| **User models** | `UI.loadUserModels()` | ui-settings.js:571 |
Admin shows visibility toggle, user shows hide/unhide toggle.
Both render capability badges (same compact format, duplicated).
---
## Summary: Primitives Needed
| Primitive | Replaces | Copies eliminated |
|-----------|----------|-------------------|
| `PROVIDERS` — type list, label map, endpoint defaults | 5 definitions → 1 | 4 |
| `renderProviderForm(container, options)` | 3 HTML forms + 6 handlers → 1 | ~200 lines |
| `renderProviderList(container, items, options)` | 3 list renderers → 1 | ~80 lines |
| `renderRoleConfig(container, options)` | 2 role UIs + 4 handlers → 1 | ~120 lines |
| `renderCapBadges(caps, compact?)` | 3 badge builders → 1 | ~30 lines |
| `renderUsageDashboard(container, options)` | 3 usage renderers → 1 | ~120 lines |
**Estimated net reduction: ~550 lines + single source of truth for all constants.**
---
## Proposed File: `ui-primitives.js`
Loaded after `ui-format.js`, before `ui-core.js`. Contains:
```
// Constants (single source of truth)
const PROVIDER_TYPES = [ ... ]; // { id, label, defaultEndpoint }
const ROLE_NAMES = ['utility', 'embedding'];
const ROLE_TYPE_MAP = { embedding: 'embedding', utility: 'chat' };
// Rendering primitives
function renderCapBadges(caps, opts) → HTML string
function renderProviderForm(container, opts) → { getValues, setValues, clear }
function renderProviderList(container, opts) → refresh function
function renderRoleConfig(container, opts) → refresh function
function renderUsageDashboard(container, opts) → refresh function
```
Each primitive follows the `renderPresetForm` pattern:
- Takes a container element + options object
- Returns a control object with getValues/setValues/refresh
- Options include callbacks (onSubmit, onDelete, etc.) and feature flags
- Scope-specific behavior (admin/team/personal) via options, not separate code
---
## 9. Extension Surface Implications
The extension spec (EXTENSIONS.md §6) defines injection points:
```
sidebar-top, sidebar-nav, sidebar-content, sidebar-bottom
surface-header, surface-main, surface-footer
```
Extensions interact with primitives through `ctx.ui.inject()` and
`ctx.ui.replace()`. The cleaner the primitive interface, the easier
extensions can:
1. **Add to** — e.g. a custom provider type, a new capability badge,
an extra usage metric column
2. **Hook into** — e.g. react when a provider is created, when a role
is saved, when models refresh
3. **Reuse** — e.g. render a provider form inside an extension's own
settings surface
### 9.1 What Extensions Will Want to Do
| Extension | Wants to... | Primitive it hooks |
|-----------|------------|-------------------|
| Custom provider adapter | Add a provider type (e.g. "Ollama", "vLLM") | `PROVIDER_TYPES` registry |
| Cost tracker | Add badges (cost/msg), extra usage columns | `renderCapBadges`, `renderUsageDashboard` |
| Image gen | Register a role + show config UI | `renderRoleConfig` |
| Smart routing | Show routing rules alongside role config | `renderRoleConfig` |
| Cluster manager | Render provider forms in its own surface | `renderProviderForm` |
| KB/RAG | Show embedding model status in sidebar | `ROLE_NAMES`, role resolution |
### 9.2 Design Rules for Extension-Ready Primitives
**Registry pattern, not constants.**
Don't just export `PROVIDER_TYPES = [...]` as a frozen array.
Make it a registry that extensions can `.add()` to:
```js
const Providers = {
_types: [ /* builtins */ ],
add(entry) { this._types.push(entry); },
all() { return this._types; },
get(id) { return this._types.find(t => t.id === id); },
endpoints() { /* map id → defaultEndpoint */ },
labels() { /* map id → label */ },
};
```
Same for roles — `Roles.add('generation', { typeFilter: 'image', label: '...' })`.
**Options objects, not positional args.**
Every primitive takes `(container, options)`. Options are the extension
point — an extension can wrap a primitive call and inject additional
options:
```js
// Extension adds a custom column to usage
renderUsageDashboard(el, {
apiCall: () => API.getMyUsage({ period, group_by }),
extraColumns: [
{ header: 'Est. Cost/msg', render: (r) => '$' + (r.total_cost / r.requests).toFixed(4) }
]
});
```
**Return control handles.**
Every primitive returns an object with methods, not just void:
```js
const provForm = renderProviderForm(el, opts);
// Extensions can:
provForm.getValues() // read current state
provForm.setValues({...}) // programmatically fill
provForm.clear() // reset
provForm.onTypeChange(fn) // hook into type selection
provForm.container // DOM reference for injection
```
**Emit events on state changes.**
Primitives should fire EventBus events that extensions can subscribe to:
```
provider.form.typeChanged — user picked a different provider type
provider.created — provider successfully saved (already exists)
provider.deleted — provider removed
role.saved — role config updated
models.refreshed — model list reloaded (already exists)
```
Most of these events already exist or will exist in the EventBus. The
primitives just need to emit them consistently rather than each copy
doing ad-hoc `UI.toast()` + `fetchModels()` in different orders.
**Scope via options, not separate code.**
The biggest win: one `renderProviderForm` handles admin/team/personal
through options, not three separate implementations:
```js
renderProviderForm(el, {
scope: 'admin', // → shows private checkbox, no BYOK gate
// scope: 'team', // → shows private checkbox, scoped API
// scope: 'personal', // → no private, BYOK gated
apiCreate: (vals) => API.adminCreateGlobalConfig(...),
apiUpdate: (id, vals) => API.adminUpdateGlobalConfig(id, ...),
onSaved: () => { UI.loadAdminProviders(); },
showPrivate: true,
showDefaultModel: true,
});
```
### 9.3 Primitive → Extension Migration Path
These primitives are **pre-extension infrastructure**. When the extension
loader (v0.11.0) lands, the migration is:
1. `Providers` registry gets a `ctx.providers.add()` wrapper in the
extension context
2. `renderCapBadges` checks a `capBadgeRegistry` that extensions can add to
3. `renderUsageDashboard` accepts `extraColumns` from extension settings
4. `renderRoleConfig` reads `Roles.all()` instead of hardcoded list
The primitives we build now become the extension API surface later.
No rewrite needed — just wrapping in the permission-scoped `ctx` object.
---
## 10. Implementation Plan
### Phase 1: Constants + Small Primitives
- `Providers` registry (types, labels, endpoints)
- `Roles` registry (names, type filters, descriptions)
- `renderCapBadges(caps, opts)` — consolidate 3 badge builders
### Phase 2: Provider Primitives
- `renderProviderForm(container, opts)` — replace 3 form implementations
- `renderProviderList(container, opts)` — replace 3 list renderers
- Update index.html: remove 2 static provider selects, add empty containers
### Phase 3: Dashboard Primitives
- `renderUsageDashboard(container, opts)` — replace 3 usage renderers
- `renderRoleConfig(container, opts)` — replace 2 role UIs
### Phase 4: Cleanup
- Remove dead code from admin-handlers.js, settings-handlers.js
- Update tests
- Verify all 3 scopes (admin/team/personal) work identically