Changeset 0.15.0 (#71)
This commit is contained in:
274
docs/ARCHITECTURE.md
Normal file
274
docs/ARCHITECTURE.md
Normal 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.
|
||||
93
docs/CHANGES-0.15.0-phase1.md
Normal file
93
docs/CHANGES-0.15.0-phase1.md
Normal 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 ~832–841). 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
|
||||
186
docs/CHANGES-0.15.0-phase2.md
Normal file
186
docs/CHANGES-0.15.0-phase2.md
Normal 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
|
||||
106
docs/CHANGES-0.15.0-phase4.md
Normal file
106
docs/CHANGES-0.15.0-phase4.md
Normal 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
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
231
docs/DESIGN-0.13.1.md
Normal 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
845
docs/DESIGN-0.14.0.md
Normal 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
584
docs/DESIGN-0.15.0.md
Normal 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
688
docs/EXTENSIONS.md
Normal 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
530
docs/REFACTOR-0.10.3.md
Normal 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
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
358
docs/UI-AUDIT-0.10.5.md
Normal 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
|
||||
Reference in New Issue
Block a user