diff --git a/docs/DESIGN-0.12.0.md b/docs/DESIGN-0.12.0.md deleted file mode 100644 index 98f8c4b..0000000 --- a/docs/DESIGN-0.12.0.md +++ /dev/null @@ -1,1304 +0,0 @@ -# v0.12.0 — File Handling + Vision (Design) - -## Overview - -File input into chat — table stakes for serious use. Image uploads for -vision-capable models, document uploads for text extraction into context, -and the storage backend that v0.14.0 (Knowledge Bases) and v0.15.0 -(Compaction snapshots) will reuse. - -Also picks up stragglers deferred from earlier releases. - -**Depends on:** v0.11.0 (extension foundation — complete). -**Reused by:** v0.14.0 (KBs), v0.15.0 (compaction), v0.22.0 (exports). - ---- - -## Stragglers (from v0.9.4 deferred) - -Items deferred during vault work that were never scheduled: - -### `switchboard vault rekey` CLI command -Re-encrypts all global/team API keys when `ENCRYPTION_KEY` is rotated. -Personal BYOK keys are unaffected (keyed to UEK, not env var). - -Implementation: standalone CLI entrypoint that reads old + new env vars, -iterates `api_configs` and `team_providers` where `key_scope IN ('global', -'team')`, decrypts with old key, re-encrypts with new key, updates in a -transaction. - -### Admin UI: encryption status indicator -Badge in admin Settings showing whether `ENCRYPTION_KEY` is set and how -many keys are encrypted vs. plaintext (migration stragglers). Single -`GET /admin/storage/status` endpoint. - -### Per-chat model/preset persistence (server-side) -Currently localStorage bandaid from v0.10.2 — doesn't roam across devices. - -**Fix:** Store `last_selector_id` in `channels.settings` JSONB on each -completion success. On chat selection, resolve: -`channel.settings.last_selector_id` → match against available -models/presets → fall back to `channel.model` → global default. - -No migration needed (`channels.settings` JSONB column already exists). -Single `PATCH /channels/:id` with settings merge on completion success. - ---- - -## Track 1: Storage Backend - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. If not set: `pvc` when `STORAGE_PATH` is writable, disabled otherwise. | -| `STORAGE_PATH` | `/data/storage` | Mount point for PVC backend. Also scratch dir for extraction with S3. | -| `STORAGE_CLASS` | — | K8s only. Gitea CI variable → PVC manifest. Value: `cephfs` (RWX for multi-pod). | -| `S3_ENDPOINT` | — | S3-compatible endpoint (e.g. `http://minio:9000`). Required when `STORAGE_BACKEND=s3`. | -| `S3_BUCKET` | — | Bucket name. Must exist before first start. | -| `S3_ACCESS_KEY` | — | S3 access key ID. | -| `S3_SECRET_KEY` | — | S3 secret access key. | -| `S3_REGION` | `us-east-1` | AWS region. | -| `S3_PREFIX` | — | Optional key prefix for shared buckets (e.g. `switchboard/`). | -| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs. Required for MinIO, Ceph RGW, most self-hosted. | -| `EXTRACTION_MODE` | `inline` | `inline` (in-process, unified image) or `sidecar` (K8s, shared PVC). | -| `EXTRACTION_CONCURRENCY` | `3` | Max concurrent extraction jobs. Caps LibreOffice memory usage. | - -Auto-detection when `STORAGE_BACKEND` is not set: - -``` -STORAGE_PATH writable → pvc (implicit) -STORAGE_PATH missing → file features disabled - admin panel shows "Storage not configured" -``` - -When `STORAGE_BACKEND=pvc` is explicit, fail startup if path is not -writable (fail-safe, same pattern as `ENCRYPTION_KEY` enforcement). - -S3 backend: reads `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, -`S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` from env vars. Uses minio-go v7. -Works with MinIO, Ceph RGW, AWS S3. Same interface as PVC — all handlers are -backend-agnostic. PVC still required as scratch dir for extraction queue. - -### Config Addition - -```go -// In config.go -StorageBackend string // "pvc", "s3", or "" (auto-detect) -StoragePath string // mount point for PVC backend -ExtractionMode string // "inline" or "sidecar" -ExtractionConcurrency int // max concurrent extraction jobs -``` - -### Interface - -```go -// server/storage/storage.go -package storage - -import ( - "context" - "io" -) - -// ObjectStore is the abstraction for blob storage. -// Implementations: PVC (filesystem), S3 (minio-go v7). -type ObjectStore interface { - // Put writes data to the given key. Creates parent dirs as needed. - Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error - - // Get returns a reader for the given key. Caller must close. - // Returns ErrNotFound if key does not exist. - Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) - - // Delete removes the object at key. No error if already gone. - Delete(ctx context.Context, key string) error - - // DeletePrefix removes all objects under the given prefix. - // Used for channel deletion (bulk cleanup). - DeletePrefix(ctx context.Context, prefix string) error - - // Exists checks if an object exists at key without reading it. - Exists(ctx context.Context, key string) (bool, error) - - // Healthy returns nil if the backend is operational. - Healthy(ctx context.Context) error -} - -var ErrNotFound = errors.New("storage: object not found") -``` - -### PVC Implementation - -`server/storage/pvc.go` — ~100 lines. Thin wrapper around `os.*`: - -```go -type PVCStore struct { - basePath string // e.g. "/data/storage" -} - -func NewPVC(basePath string) (*PVCStore, error) { - // Validate basePath is writable (create test file, remove) - // Create top-level subdirs: attachments/ -} -``` - -Key → filesystem path: `filepath.Join(basePath, key)`. The key itself -provides all the directory structure. - -### Filesystem Layout - -``` -/data/storage/ ← STORAGE_PATH mount point -└── attachments/ - └── {channel_id}/ - └── {attachment_id}_{filename} -``` - -**Channel-first** because: -- Channel deletion → `DeletePrefix("attachments/{channel_id}/")` — one call. -- User deletion cascades to owned channels → same cleanup path. -- Group chats / multi-participant channels: all files in one place - regardless of who uploaded them. -- Quota enforcement uses PG (`SUM(size_bytes) WHERE user_id = $1`), - not filesystem walks. - -Future subdirs at the `STORAGE_PATH` level (not this release): -- `knowledge-bases/` — v0.14.0 -- `compaction/` — v0.15.0 -- `exports/` — TBD - -### K8s Manifest (PVC) - -```yaml -# k8s/storage-pvc.yaml -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: switchboard-storage - namespace: switchboard -spec: - accessModes: [ReadWriteMany] # CephFS RWX — multi-pod safe - storageClassName: "${STORAGE_CLASS}" - resources: - requests: - storage: 10Gi # configurable per deployment -``` - -Backend deployment addition: - -```yaml -# In k8s/backend.yaml -volumes: - - name: storage - persistentVolumeClaim: - claimName: switchboard-storage -containers: - - name: backend - volumeMounts: - - name: storage - mountPath: /data/storage - env: - - name: STORAGE_BACKEND - value: "pvc" - - name: STORAGE_PATH - value: "/data/storage" -``` - -### Docker Compose - -```yaml -# In docker-compose.yml -services: - switchboard: - volumes: - - ./data/storage:/data/storage - environment: - - STORAGE_PATH=/data/storage -``` - -### Admin Status Endpoint - -`GET /admin/storage/status` - -```json -{ - "backend": "pvc", - "path": "/data/storage", - "healthy": true, - "total_files": 142, - "total_bytes": 52428800, - "configured": true -} -``` - -When storage is not configured, file upload endpoints return 503 with -`{"error": "File storage not configured"}`. Frontend hides upload UI -based on a capabilities flag in the boot payload. - ---- - -## Track 2: Attachments Schema + API - -### Migration 007_attachments.sql - -```sql --- ── Attachments ──────────────────────────── -CREATE TABLE IF NOT EXISTS attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id), - message_id UUID REFERENCES messages(id) ON DELETE SET NULL, - filename VARCHAR(255) NOT NULL, - content_type VARCHAR(127) NOT NULL, - size_bytes BIGINT NOT NULL, - storage_key TEXT NOT NULL, - extracted_text TEXT, - metadata JSONB DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- Access pattern: always join through channel for auth -CREATE INDEX idx_attachments_channel ON attachments(channel_id); --- Quota pattern: sum by user -CREATE INDEX idx_attachments_user_size ON attachments(user_id); --- Message association: find attachments for a message -CREATE INDEX idx_attachments_message ON attachments(message_id); -``` - -Notes on columns: -- `message_id` is nullable — attachment is uploaded before the message - is persisted. Updated after message creation. If message is deleted, - attachment persists (belongs to channel, not message). -- `storage_key` is the relative path within `STORAGE_PATH`, e.g. - `attachments/{channel_id}/{attachment_id}_{filename}`. -- `extracted_text` is populated asynchronously for PDFs/DOCX. NULL for - images. Used for context injection into completions. -- `metadata` JSONB holds dimensions (images), page count (PDFs), - extraction status, thumbnails, etc. - -### Store Layer - -```go -// server/store/attachments.go -type AttachmentStore interface { - Create(ctx context.Context, a *models.Attachment) error - GetByID(ctx context.Context, id string) (*models.Attachment, error) - GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) - GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) - SetMessageID(ctx context.Context, id, messageID string) error - Delete(ctx context.Context, id string) error - DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys for cleanup - UserUsageBytes(ctx context.Context, userID string) (int64, error) -} -``` - -### API Endpoints - -``` -POST /api/v1/channels/:id/attachments Upload file -GET /api/v1/attachments/:id Attachment metadata -GET /api/v1/attachments/:id/download Download file content -DELETE /api/v1/attachments/:id Delete attachment -GET /api/v1/channels/:id/attachments List channel attachments -``` - -### Upload Flow - -``` -Client Backend Storage - │ │ │ - ├── POST multipart ────────►│ │ - │ (file + channel_id) │ │ - │ ├── Auth check │ - │ ├── Channel membership ─────│ - │ │ verify │ - │ ├── Size limit check │ - │ ├── MIME type validation │ - │ ├── Generate attachment_id │ - │ ├── PUT ───────────────────►│ - │ │ key: att/{ch}/{id}_{fn} │ - │ ├── INSERT attachments row │ - │ │ │ - │◄── 201 { attachment } ────┤ │ -``` - -The attachment is created *before* the message is sent. The frontend -holds the attachment ID(s), includes them in the completion request, -and the backend links them to the persisted message after creation. - -### Download Flow (Access Control) - -``` -GET /api/v1/attachments/:id/download - - → SELECT a.*, c.id, c.user_id - FROM attachments a - JOIN channels c ON a.channel_id = c.id - WHERE a.id = $1 - - → Verify: requester is channel owner - (future RBAC: OR requester is channel participant - OR requester is team member with channel access - OR requester is admin with audit log) - - → Stream file from ObjectStore with: - Content-Type: {content_type} - Content-Disposition: attachment; filename="{filename}" - Content-Length: {size_bytes} -``` - -**Critical:** Files are NEVER served directly from nginx or any static -file path. Every byte goes through the Go backend with auth checks. -No pre-signed URLs, no direct filesystem exposure. - -### Size & Type Limits - -Configured via global settings (see **Global Settings** section below). -Enforced server-side on upload — frontend mirrors limits for UX but the -backend is authoritative. - -Validation order on upload: -1. `storage_allowed_types` — reject disallowed MIME types -2. `storage_max_file_size` — reject oversized individual files -3. `storage_max_upload_size` — reject if request total exceeds limit -4. `storage_max_attachments_per_message` — reject if too many files -5. `storage_user_quota_bytes` — reject if user quota exceeded (future) - -MIME type is detected server-side (`http.DetectContentType` + extension -validation), not trusted from the client `Content-Type` header. - ---- - -## Track 3: Multimodal Message Assembly - -### Provider Message Polymorphism - -Currently `providers.Message.Content` is a plain string. Vision requires -content arrays with image parts. The change is additive — no existing -callsites break. - -```go -// In server/providers/provider.go - -// ContentPart represents one part of a multimodal message. -type ContentPart struct { - Type string `json:"type"` // "text", "image_url" - Text string `json:"text,omitempty"` // for type="text" - ImageURL *ImageURL `json:"image_url,omitempty"` // for type="image_url" -} - -type ImageURL struct { - URL string `json:"url"` // "data:image/jpeg;base64,..." or URL - Detail string `json:"detail,omitempty"` // "auto", "low", "high" -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` - - // Multimodal content. When non-nil, providers use this instead of Content. - ContentParts []ContentPart `json:"content_parts,omitempty"` - - // ... existing tool fields unchanged -} -``` - -**Resolution rule:** if `ContentParts` is non-nil, providers build their -wire format from it. If nil, they use `Content` (string) as today. This -means every existing codepath works unchanged until an attachment is -involved. - -### Provider Wire Format Mapping - -**OpenAI / OpenRouter / Venice** (OpenAI-compatible): - -```go -// In openai.go — message conversion -if len(m.ContentParts) > 0 { - // Content becomes array of objects - oaiMsg.Content = nil // clear string content - oaiMsg.ContentArray = toOpenAIContentParts(m.ContentParts) -} -``` - -OpenAI wire format: -```json -{ - "role": "user", - "content": [ - {"type": "text", "text": "What's in this image?"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}} - ] -} -``` - -The `openaiMessage` struct needs a `ContentArray` field with custom JSON -marshaling — when `ContentArray` is set, marshal content as the array; -otherwise marshal as string. This is the standard OpenAI multimodal -format. - -**Anthropic:** - -```go -// In anthropic.go — message conversion -if len(m.ContentParts) > 0 { - antMsg.Content = toAnthropicContentBlocks(m.ContentParts) -} -``` - -Anthropic wire format: -```json -{ - "role": "user", - "content": [ - {"type": "text", "text": "What's in this image?"}, - {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "..."}} - ] -} -``` - -The `anthropicContentBlock` struct gets new fields: - -```go -type anthropicContentBlock struct { - // ... existing fields ... - // Image support - Source *anthropicImageSource `json:"source,omitempty"` -} - -type anthropicImageSource struct { - Type string `json:"type"` // "base64" - MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc. - Data string `json:"data"` // base64-encoded image -} -``` - -### Image Delivery: Base64 Inline - -For v0.12.0, all images are sent as base64-encoded data inline in the -API request. No pre-signed URLs. - -**Rationale:** The target audience is government/enterprise, often in -air-gapped or restricted networks. Pre-signed URLs require the provider's -API servers to reach the storage endpoint — impossible in disconnected -environments. Base64 works everywhere. - -**Trade-off:** Larger request payloads. Acceptable for v0.12.0 — images -are typically <10MB each and context windows are large. URL-based -delivery can be an optimization in a future release for cloud deployments. - -### Completion Handler Changes - -```go -// In completion.go — after loading conversation, before building provReq - -// If message has attachments, build multimodal content -if len(req.AttachmentIDs) > 0 { - parts := []providers.ContentPart{ - {Type: "text", Text: req.Content}, - } - for _, attID := range req.AttachmentIDs { - att, err := h.store.Attachments().GetByID(c, attID) - // ... verify att.channel_id matches ... - - if isImageType(att.ContentType) { - // Verify vision capability - if !caps.Vision { - c.JSON(400, gin.H{"error": "Selected model does not support image input"}) - return - } - - // Read from storage, base64 encode - reader, _, _, _ := h.objectStore.Get(c, att.StorageKey) - data, _ := io.ReadAll(reader) - reader.Close() - b64 := base64.StdEncoding.EncodeToString(data) - dataURL := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64) - - parts = append(parts, providers.ContentPart{ - Type: "image_url", - ImageURL: &providers.ImageURL{URL: dataURL, Detail: "auto"}, - }) - } else if att.ExtractedText != nil && *att.ExtractedText != "" { - // Document: inject extracted text as context - parts = append(parts, providers.ContentPart{ - Type: "text", - Text: fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText), - }) - } else { - // Extraction not yet complete or failed — include filename only - status := att.Metadata["extraction_status"] - parts = append(parts, providers.ContentPart{ - Type: "text", - Text: fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status), - }) - } - } - - // Use ContentParts instead of Content string - userMsg := providers.Message{ - Role: "user", - ContentParts: parts, - } - messages = append(messages, userMsg) -} else { - // Existing text-only path — unchanged - messages = append(messages, providers.Message{ - Role: "user", - Content: req.Content, - }) -} -``` - -**Frontend blocks send only during active upload** — the send button -stays disabled while any staged attachment is in `uploading` state -(bytes still in flight). Once the file is on disk and the attachment -row exists in PG, send is enabled regardless of extraction status. - -The completion handler checks `extraction_status` at request time: -- `complete` → inject extracted text as context -- anything else → inject filename-only placeholder - -This avoids blocking users on slow extractions while still providing -extracted context when available. For images (no extraction), the -attachment is immediately usable. - -### Capability Gating - -Images are only assembled into content parts when the resolved model -has `capabilities.vision == true`. If a user attaches an image to a -non-vision model: - -- Documents (PDF, DOCX, TXT): always allowed — extracted text is - injected as a text content part. No vision needed. -- Images to non-vision model: backend returns 400 with - `{"error": "Selected model does not support image input"}`. -- Frontend disables image upload button when selected model lacks vision. - -### Text Extraction (Documents) - -#### License Constraint - -The project is Apache 2.0. Libraries with AGPL/GPL copyleft (e.g. -unipdf) are incompatible — their license would infect the codebase. -Pure Go PDF libraries under permissive licenses (`ledongthuc/pdf`, -`pdfcpu`) handle simple cases but choke on complex layouts, scanned -PDFs, and encrypted documents. - -#### Approach: Tiered Extraction - -**Tier 1 — Native Go (no external deps, handles ~80% of uploads):** - -| Format | Library | License | -|--------|---------|---------| -| TXT, Markdown, CSV | `io.ReadAll` | — | -| PDF (simple) | `pdfcpu` | Apache 2.0 | -| DOCX | `fumiama/go-docx` | MIT | - -**Tier 2 — LibreOffice Headless (handles everything else):** - -For PDF with complex layouts, ODT, ODS, ODP, RTF, DOC (legacy), XLS, -and any format Tier 1 can't handle — shell out to LibreOffice headless: - -```bash -libreoffice --headless --convert-to txt:Text /tmp/input.odt --outdir /tmp/ -``` - -LibreOffice handles the full Office family: -- OpenDocument: `.odt`, `.ods`, `.odp` -- Microsoft: `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx` -- PDF (including scanned with basic OCR via hunspell) -- RTF, HTML, and dozens more - -#### Extraction Queue (Filesystem-Backed) - -Extraction state lives on the PVC, not in memory. Pod restarts don't -lose queue state. The queue is a directory convention: - -``` -/data/storage/ -├── attachments/ ← completed uploads (served to clients) -└── processing/ ← extraction queue (internal only) - └── {attachment_id}/ - ├── input ← original file (copy or hardlink) - ├── status.json ← queue state + metadata - ├── output.txt ← extracted text (when complete) - └── thumb.jpg ← thumbnail (when complete) -``` - -**status.json:** - -```json -{ - "state": "pending", - "queued_at": "2026-02-25T14:30:00Z", - "started_at": null, - "completed_at": null, - "content_type": "application/pdf", - "filename": "report.pdf", - "error": null -} -``` - -States: `pending` → `processing` → `complete` | `failed` - -**Worker loop (runs in both inline and sidecar mode):** - -``` -1. Scan processing/ for dirs with status.state == "pending" - (ordered by queued_at — FIFO) -2. Respect concurrency cap (semaphore, default 3) -3. Pick job → flip state to "processing" -4. Tier 1: try native Go extraction - ├── success → write output.txt, generate thumb, flip "complete" - └── fail or unsupported → -5. Tier 2: LibreOffice headless - ├── success → write output.txt, generate thumb, flip "complete" - └── fail → flip "failed", write error -6. Copy results to PG: - ├── UPDATE attachments SET extracted_text = ..., - │ metadata = jsonb_set(metadata, '{extraction_status}', '"complete"') - └── Thumbnail storage_key written to metadata -7. Clean up processing/{id}/ dir -``` - -**In inline mode** (Unified image): the worker is a goroutine pool in -the Go backend, scanning the queue directory on a 2-second tick. - -**In sidecar mode** (K8s): the sidecar runs the same loop as a -standalone process. When extraction completes, it writes `output.txt` -and `thumb.jpg` to the processing dir and flips status to `complete`. -The Go backend polls the status file and copies results to PG. - -**Crash recovery:** On startup, scan for `state == "processing"` (stale -from a crash). Reset to `pending` for re-processing. This is why the -queue is on the filesystem — nothing is lost. - -**Concurrency:** Capped by semaphore. Default 3 concurrent extractions. -Configurable via `EXTRACTION_CONCURRENCY` env var. LibreOffice is -memory-hungry on complex docs — the cap prevents OOM on burst uploads. - -**Frontend queue awareness:** - -``` -Attachment status: - uploading → progress bar - pending → "Queued (2 ahead)" ← position from queue scan - processing → "Extracting..." ← spinner - complete → ✓ (ready) - failed → ⚠ (send still enabled, model sees filename only) -``` - -The frontend polls `GET /api/v1/attachments/:id` every 2s for -non-terminal states. The response includes `extraction_status` and -`queue_position` (0 = currently processing, N = N jobs ahead). - -Send is blocked while any attachment is `uploading`. Once all are at -least `pending`, send is enabled — the extraction results will be -available by the time the model processes the request (optimistic), or -the model sees the filename-only fallback (acceptable degradation). - -**Correction to earlier design:** Send is blocked only during `uploading` -phase. Once the file is on disk and queued, the user can send. The -completion handler checks extraction status at request time and uses -whatever is available. - -#### Extraction Metadata in PG - -Attachment `metadata` JSONB tracks status (mirrored from queue): - -```json -{ - "extraction_status": "complete", - "extraction_error": null, - "queue_position": 0, - "page_count": 12, - "dimensions": {"width": 1920, "height": 1080}, - "thumbnail_key": "attachments/{channel_id}/{id}_thumb.jpg", - "thumb_dimensions": {"width": 280, "height": 147} -} -``` - -States: `pending` → `processing` → `complete` | `failed` | `unavailable` - -#### LibreOffice Deployment Strategy - -**Two deployment modes, matching existing Docker architecture:** - -| Deployment | LibreOffice location | Reason | -|-----------|---------------------|--------| -| **Unified** (docker-compose, dev) | Baked into image | Single container, simplicity wins | -| **K8s** (backend + frontend split) | Sidecar container | Separate scaling, lean backend image, independent version pinning | - -**Unified Dockerfile addition:** - -```dockerfile -# In Dockerfile (unified) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libreoffice-core libreoffice-writer libreoffice-calc \ - && rm -rf /var/lib/apt/lists/* -``` - -**K8s sidecar (backend.yaml addition):** - -```yaml -# In k8s/backend.yaml -containers: - - name: backend - image: switchboard-backend:0.12.0 - volumeMounts: - - name: storage - mountPath: /data/storage - env: - - name: STORAGE_BACKEND - value: "pvc" - - name: STORAGE_PATH - value: "/data/storage" - - name: EXTRACTION_MODE - value: "sidecar" # "inline" for unified, "sidecar" for k8s - - - name: extractor - image: switchboard-extractor:0.12.0 # minimal image: alpine + libreoffice - volumeMounts: - - name: storage - mountPath: /data/storage - resources: - requests: - memory: "256Mi" - limits: - memory: "1Gi" # LibreOffice can spike on complex docs - -volumes: - - name: storage - persistentVolumeClaim: - claimName: switchboard-storage -``` - -The sidecar image is small and purpose-built: Alpine + LibreOffice + -a watcher script. No Go code, no API surface. Upgrades to LibreOffice -are a tag bump on the sidecar image — backend image untouched. - -**Communication:** Filesystem-only via the shared PVC. No gRPC, no -HTTP between containers. See Extraction Queue below. - -#### Allowed Types (Updated) - -```json -{ - "storage_allowed_types": [ - "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", - "application/pdf", - "text/plain", "text/markdown", "text/csv", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "application/vnd.oasis.opendocument.text", - "application/vnd.oasis.opendocument.spreadsheet", - "application/vnd.oasis.opendocument.presentation", - "application/rtf", - "application/msword", - "application/vnd.ms-excel" - ] -} -``` - -### Thumbnail Generation - -Thumbnails are generated **asynchronously** alongside text extraction -as part of the same post-upload processing pipeline. - -#### Image Thumbnails - -Use `golang.org/x/image/draw` (BSD, stdlib-adjacent) for resizing: - -```go -// Resize to max 280px on longest edge, preserve aspect ratio -// Output: JPEG at 80% quality -// Stored at: attachments/{channel_id}/{attachment_id}_thumb.jpg -``` - -No external dependency. Pure Go. Fast enough for on-upload generation. - -#### Document Thumbnails - -When LibreOffice is available, generate a first-page preview: - -```bash -# Convert first page to PDF, then to image -libreoffice --headless --convert-to pdf /tmp/input.docx --outdir /tmp/ -# Then use Go image libraries to render first page -# Or: convert to PNG directly via LibreOffice draw filter -libreoffice --headless --convert-to png /tmp/input.docx --outdir /tmp/ -``` - -For PDFs specifically, `pdfcpu` can extract page images under Apache 2.0. - -When LibreOffice is not available, documents get a generic file-type -icon (handled in CSS, no thumbnail row needed). - -#### Storage - -Thumbnails stored alongside originals with `_thumb` suffix: - -``` -/data/storage/attachments/{channel_id}/ -├── {id}_report.pdf ← original -├── {id}_report.pdf_thumb.jpg ← thumbnail -├── {id}_photo.jpg ← original -└── {id}_photo.jpg_thumb.jpg ← thumbnail -``` - -Thumbnail URL returned in attachment metadata: - -```json -{ - "thumbnail_key": "attachments/{channel_id}/{id}_photo.jpg_thumb.jpg", - "dimensions": {"width": 1920, "height": 1080}, - "thumb_dimensions": {"width": 280, "height": 147} -} -``` - -Download endpoint: `GET /api/v1/attachments/:id/thumbnail` — same -channel-scoped auth check as the full download. - ---- - -## Track 4: Frontend - -### Input Area Changes - -The `
` gets an attachment button and a preview -strip: - -``` -┌──────────────────────────────────────────────┐ -│ ┌──────────┐ ┌──────────┐ │ ← attachment preview strip -│ │ image.png│ │ report.pdf│ │ (visible only when files -│ │ ✕ │ │ ✕ │ │ are staged) -│ └──────────┘ └──────────┘ │ -├──────────────────────────────────────────────┤ -│ 📎 │ Send a message... │ ■ │ ▶ │ ← existing input + new clip btn -└──────────────────────────────────────────────┘ -``` - -### Upload Interactions - -| Trigger | Behavior | -|---------|----------| -| 📎 button click | File picker dialog (filtered by allowed types) | -| Paste (Ctrl+V) binary | Auto-attach: screenshot/image → image attachment ({uuid}.{ext}) | -| Paste (Ctrl+V) large text | Auto-attach: text > threshold → text file ({uuid}.txt) | -| Paste (Ctrl+V) small text | Normal paste into textarea (below threshold) | -| Drag-and-drop on chat area | Auto-attach file(s) as attachments (max 5 per message) | - -#### Smart Paste (Auto-Attach) - -Paste events are intercepted and routed automatically — no prompts, no -user decisions. The clipboard content determines the behavior: - -**Detection logic:** - -``` -Clipboard paste event - │ - ├── Contains files/images (binary)? - │ └── YES → auto-attach each item - │ ├── Image (screenshot, copied image) → upload as image attachment - │ ├── File (dragged from OS) → upload as file attachment - │ └── Filename: {uuid}.{ext} (ext from MIME type) - │ - ├── Contains text? - │ ├── Length ≤ threshold → normal paste into textarea - │ └── Length > threshold → auto-attach as text file - │ ├── Upload as attachment (content_type: text/plain) - │ ├── Filename: {uuid}.txt - │ └── Textarea stays empty (or keeps existing draft) - │ - └── Empty → no-op -``` - -**Binary detection:** The `paste` event's `clipboardData.items` array -carries type information. Items with `type.startsWith('image/')` or -any non-text type are binary — always auto-attached. Text items -(`text/plain`, `text/html`) are checked against the threshold. - -**UUID filenames:** All auto-attached pastes get UUID-based filenames. -No timestamps, no `pasted-text.txt` collision issues. The original -content type and paste source are captured in attachment metadata: - -```json -{ - "source": "clipboard", - "original_type": "image/png", - "paste_length": 4200 -} -``` - -**Threshold:** `storage_paste_to_file_chars` global setting (default: -2000 characters, admin-configurable). Setting to 0 disables text -auto-attach (binary pastes still auto-attach regardless). - -**UX flow for a screenshot paste:** -1. User hits Ctrl+V with a screenshot on clipboard -2. Image appears instantly in the attachment preview strip (uploading...) -3. Upload completes → thumbnail shown with checkmark -4. User types their question, hits send -5. Image goes to vision model as base64 - -**UX flow for a large log paste:** -1. User copies 5,000 chars of server logs, hits Ctrl+V -2. Text file chip appears in attachment preview strip: `📄 {uuid}.txt (4.9 KB)` -3. Extraction runs (trivial for plain text — near-instant) -4. User types "what's wrong with these logs?", hits send -5. Log content injected as extracted text context - -No prompts, no dialogs, no decisions. Paste just works. - -### State Management - -Staged attachments live in `App.stagedAttachments[]` — an array of -`{id, filename, contentType, sizeBytes, previewUrl, extractionStatus}` -populated after the upload API returns. On send, attachment IDs are -included in the completion request body. On cancel/clear, staged -attachments are deleted via the API. - -**Upload lifecycle per attachment:** - -``` -uploading → uploaded/queued → extracting → ready | failed -``` - -- `uploading`: progress bar visible, **send disabled**. -- `uploaded/queued`: file on disk, extraction queued. Queue position - shown: "Queued (2 ahead)". **Send enabled** — extraction will - complete before or during model processing, or the model gets - the filename-only fallback. -- `extracting`: spinner on thumbnail. Send remains enabled. -- `ready`: extraction complete (or image — no extraction needed). - Green checkmark. -- `failed`: extraction failed. Warning icon on thumbnail. Send still - enabled — model sees `[Attached file: X (extraction failed)]`. - -**Key principle:** Send is blocked **only during active upload** (bytes -in flight). Once the file is on disk and the attachment row exists, -the user can send. This prevents the frustrating case where a user -types their message while a 50-page PDF extracts for 30 seconds. - -**Polling:** Frontend polls `GET /api/v1/attachments/:id` every 2s for -any attachment with `extractionStatus == "pending" | "processing"`. -Stops when all settle. WebSocket notification is a future optimization. -Polling updates the queue position indicator and thumbnail status icon. - -### Message Rendering - -Attachments on sent/received messages render inline: - -- **Images:** Thumbnail (from `_thumb.jpg`) with click-to-expand - (lightbox or side panel). Rendered in the message bubble below/above - the text. -- **Documents:** File chip with icon, filename, size, and download link. - `📄 report.pdf (2.3 MB) ⬇` -- **Pasted text files:** Same as documents — `📄 {uuid}.txt (4.9 KB) ⬇`. - Metadata `source: "clipboard"` available for future UX refinement. - -Attachment data comes from `messages.metadata.attachment_ids` → -batch-fetched on conversation load via -`GET /channels/:id/attachments`. - -### Capability-Aware UI - -The frontend already knows model capabilities from the model selector. - -**Images are always auto-attached regardless of current model** — the -user might switch models before sending. When the selected model has -`vision: false`: - -- Image attachments show a subtle indicator: `👁️‍🗨️` icon dimmed with - tooltip "Current model doesn't support images" -- File picker filters to documents only, but paste/drop still accepts - images (user can switch models) -- The 📎 button tooltip says "Attach document" instead of "Attach file" -- On send with image + non-vision model: backend returns 400 with - `"Selected model does not support image input — switch to a vision - model or remove image attachments"` -- Document uploads (PDF, DOCX, ODT, etc.) always work — text - extraction doesn't require vision - -When storage is not configured (boot payload flag), the 📎 button is -hidden entirely and all paste/drop auto-attach handlers are bypassed. - ---- - -## Privacy & Access Control Model - -### Principle - -**Attachments have no access identity of their own. They inherit access -from their parent channel.** The channel membership check is the single -gate for all attachment operations. - -### Access Matrix (v0.12.0 — Current Auth Model) - -| Channel type | Who can upload | Who can download | Delete | -|---|---|---|---| -| Personal DM | Channel owner | Channel owner | Owner | -| (Future) Team channel | Team members with access | Team members with access | Uploader or team admin | -| (Future) Group chat | Channel participants | Channel participants | Uploader or channel owner | -| (Future) Workflow | Assigned participants | Assigned participants | Stage rules | - -### Enforcement Points - -Every attachment endpoint performs channel-scoped authorization: - -```go -// Pseudocode — every handler follows this pattern -func (h *Handler) downloadAttachment(c *gin.Context) { - att := h.store.Attachments().GetByID(c, attID) - channel := h.store.Channels().GetByID(c, att.ChannelID) - - // THE GATE: is the requester authorized for this channel? - if channel.UserID != requesterID { - c.JSON(403, gin.H{"error": "access denied"}) - return - } - - // Future RBAC (v0.20.0): replace the above with: - // if !h.rbac.CanAccess(requesterID, channel.ID, "attachment:read") { - // c.JSON(403, ...) - // } - - h.objectStore.Get(c, att.StorageKey) // → stream to client -} -``` - -### Threat Mitigations - -| Threat | Mitigation | -|--------|-----------| -| ID guessing / enumeration | Every download joins through channel + verifies requester membership. UUIDs are non-sequential. | -| Direct filesystem access | Files never served by nginx. All bytes go through Go backend with auth. No static file routes for storage path. | -| Cross-team data leak | Channel membership is the boundary. HR channel attachments are only accessible to HR channel participants. | -| Admin overreach | v0.12.0: admin can access (consistent with existing admin trust model). Audit-logged. v0.20.0: RBAC with team-scoped admin. | -| Metadata leak | Channel attachment list endpoint also requires channel membership. No global search across attachments. | -| Storage key prediction | Keys include UUID attachment_id — not guessable from channel_id + filename alone. | -| Upload as attack vector | MIME type validated against allowlist. File size enforced. Content-Type set from server-side detection, not client header. | - -### Consistency with Existing Scoped Resources - -The attachment access pattern is identical to the pattern used for all -scoped data in the system: - -``` -Resource Scope Access Gate -────────────────────────────────────────────────── -Notes (personal) user_id user_id = requester -Notes (team) team_id requester ∈ team members -Attachments channel_id requester ∈ channel (owner/participant) -Usage data user/team scoped queries -API keys user/org vault (UEK) / env-derived key -``` - -The row knows its scope. The handler enforces the boundary. The query -filters by it. - -### Future RBAC Hooks (v0.20.0) - -The v0.12.0 access checks are written as explicit `if` checks that -can be trivially replaced by a centralized RBAC call: - -```go -// v0.12.0 -if channel.UserID != requesterID { deny } - -// v0.20.0 -if !rbac.Can(requesterID, channel.ID, permission) { deny } -``` - -The schema, storage layer, and API surface don't change. Only the -authorization check function gets swapped. - ---- - -## Implementation Order - -``` -Phase 1: Storage Backend - ├── Config additions (STORAGE_BACKEND, STORAGE_PATH, EXTRACTION_MODE) - ├── ObjectStore interface - ├── PVC implementation (~100 lines) - ├── Startup validation (writable check, health) - ├── K8s manifest (PVC + volume mount + STORAGE_CLASS) - ├── Docker-compose volume mount - ├── Admin status endpoint - └── Boot payload: storage_configured flag - -Phase 2: Attachments CRUD + Extraction Pipeline - ├── Migration 007_attachments.sql - ├── models.Attachment struct - ├── AttachmentStore (PG CRUD) - ├── handlers/attachments.go (upload, download, delete, list, thumbnail) - ├── Channel-scoped access checks on all endpoints - ├── MIME validation + size enforcement (server-side detection) - ├── Filesystem cleanup on channel delete (CASCADE + DeletePrefix) - ├── Extraction queue (filesystem-backed): - │ ├── processing/ directory convention + status.json - │ ├── Worker loop with semaphore (default 3 concurrent) - │ ├── Tier 1: native Go (pdfcpu, go-docx, io.ReadAll) - │ ├── Tier 2: LibreOffice headless (inline or sidecar) - │ ├── Crash recovery (reset stale "processing" → "pending") - │ └── PG metadata mirror (extraction_status, queue_position) - ├── Thumbnail generation (images: Go resize, docs: LibreOffice) - ├── Unified Dockerfile: add libreoffice-core - ├── Sidecar Dockerfile: alpine + libreoffice + watcher - └── K8s backend.yaml: sidecar container definition - -Phase 3: Multimodal Message Assembly - ├── ContentPart + ImageURL types in providers/provider.go - ├── OpenAI message conversion (content array marshaling) - ├── Anthropic message conversion (image source blocks) - ├── OpenRouter / Venice (inherit OpenAI path) - ├── Completion handler: attachment → base64 → content parts - ├── Completion handler: extraction_status check at request time - ├── Capability gating (vision check before image assembly) - ├── Document text injection (extracted_text → text content part) - └── Usage logging for multimodal requests - -Phase 4: Frontend - ├── Attachment button (📎) in input area - ├── File picker with type filtering (capability-aware) - ├── Smart paste handler: - │ ├── Binary detection (images, files → auto-attach) - │ ├── Large text detection (> threshold → auto-attach as {uuid}.txt) - │ ├── Small text → normal textarea paste - │ └── UUID filename generation - ├── Drag-and-drop on chat area - ├── Staged attachment preview strip with status indicators: - │ ├── Upload progress bar - │ ├── Queue position ("Queued (2 ahead)") - │ ├── Extraction spinner - │ ├── Ready checkmark / failed warning - │ └── Send blocked only during active upload - ├── Extraction status polling (2s interval, stops at terminal state) - ├── Attachment rendering in messages (thumbnails + doc chips) - ├── Image lightbox / side panel expand - ├── Capability-aware UI (hide/filter by vision support) - ├── Storage-not-configured state (hide upload UI) - └── Admin Storage panel (status, orphan count, cleanup button) - -Phase 5: Stragglers - ├── vault rekey CLI command - ├── Admin encryption status indicator - └── Per-chat model/preset persistence (server-side) - -Phase 6: Admin Cleanup - ├── POST /admin/storage/cleanup endpoint - ├── Orphan detection query (message_id IS NULL, age > 24h) - ├── Admin Storage panel: orphan count + reclaimable space card - └── "Run Cleanup Now" button -``` - -### Testing - -- Integration tests: upload → send message with attachment → verify - multimodal completion request → verify download auth -- Access control tests: upload to channel A, attempt download from - user who owns channel B → 403 -- Storage backend tests: put/get/delete/deletePrefix round-trip -- Extraction tests: PDF/DOCX/TXT → extracted_text populated -- Frontend tests: staged attachments state, capability gating - ---- - -## Migration Summary - -| Migration | Tables/Columns | -|-----------|---------------| -| 007_attachments.sql | `attachments` table with indexes | - -No changes to existing tables. The `messages.metadata` JSONB carries -`attachment_ids` without schema changes. The `channels.settings` JSONB -carries `last_selector_id` without schema changes. - ---- - -## Decisions Made - -1. **Text extraction** — Tiered: native Go (pdfcpu Apache 2.0, go-docx - MIT) for common cases, LibreOffice headless for everything else - including ODF formats. No AGPL dependencies. - -2. **Thumbnails** — Yes. Generated async post-upload. Images via Go - stdlib (`golang.org/x/image/draw`). Documents via LibreOffice - first-page render. Stored alongside originals with `_thumb` suffix. - -3. **Max attachments per message** — 5 files (admin-configurable). - -4. **Extraction timing** — Async with filesystem-backed queue. Upload - returns immediately, extraction runs in background via worker loop - with concurrency cap. Frontend polls for status. Send blocked only - during active upload (bytes in flight), not during extraction. - -5. **Smart paste** — Automatic, no prompts. Binary clipboard items - (screenshots, images) always auto-attach. Text exceeding threshold - (default 2000 chars) auto-attaches as `{uuid}.txt`. Below threshold - pastes normally into textarea. UUID filenames, MIME detection from - clipboard data. Zero user decisions. - -## Global Settings (New) - -| Key | Default | Description | -|-----|---------|-------------| -| `storage_max_file_size` | `10485760` (10MB) | Per-file upload limit | -| `storage_max_upload_size` | `52428800` (50MB) | Per-request total limit | -| `storage_allowed_types` | *(see Allowed Types)* | MIME type allowlist | -| `storage_max_attachments_per_message` | `5` | Max files per message | -| `storage_paste_to_file_chars` | `2000` | Paste-to-file threshold (0=disabled) | -| `storage_user_quota_bytes` | `0` | Per-user quota (0=unlimited, future enforcement) | - -## Decisions Made (Continued) - -6. **Sidecar base image** — Debian slim. Rock-solid LibreOffice packages, - ~80MB larger than Alpine but no flaky package issues. - -7. **Queue persistence** — Dual: filesystem `status.json` is source of - truth (crash recovery, `ls` debuggability), mirrored to PG metadata - for API serving. Worth the bookkeeping for operational transparency. - -## Open Questions - -*(None remaining — all design decisions resolved.)* - -## Orphan Cleanup (Admin Action) - -Attachments uploaded but never associated with a message (user closes -tab, browser crash, abandoned upload) accumulate as orphans. - -**v0.12.0: manual admin action with "Run Now" button.** - -``` -POST /admin/storage/cleanup - → Scan attachments WHERE message_id IS NULL - AND created_at < NOW() - interval '24 hours' - → Delete PG rows + storage files + processing dirs - → Return { deleted: 3, freed_bytes: 15728640 } -``` - -**Admin Storage panel card:** - -``` -┌─ Orphan Files ──────────────────────────────┐ -│ 3 orphaned attachments (15 MB reclaimable) │ -│ │ -│ Files not linked to any message for >24hrs │ -│ │ -│ [Run Cleanup Now] │ -└─────────────────────────────────────────────┘ -``` - -The 24-hour grace period prevents cleaning up files that are simply -in the middle of a long compose session. - -**Future (v0.22.0 task runner):** This becomes a scheduled task running -daily. The admin button stays as an on-demand override. The endpoint -is identical — the scheduler just calls it on a cron. diff --git a/docs/DESIGN-0.13.1.md b/docs/DESIGN-0.13.1.md deleted file mode 100644 index 722137e..0000000 --- a/docs/DESIGN-0.13.1.md +++ /dev/null @@ -1,231 +0,0 @@ -# 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 diff --git a/docs/DESIGN-0.14.0.md b/docs/DESIGN-0.14.0.md deleted file mode 100644 index da86917..0000000 --- a/docs/DESIGN-0.14.0.md +++ /dev/null @@ -1,845 +0,0 @@ -# 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 diff --git a/docs/DESIGN-0.16.0.md b/docs/DESIGN-0.16.0.md deleted file mode 100644 index 1be0703..0000000 --- a/docs/DESIGN-0.16.0.md +++ /dev/null @@ -1,795 +0,0 @@ -# DESIGN-0.16.0 — User Groups + Resource Grants - -## Overview - -The missing access-control primitive. Teams define organizational structure -(horizontal visibility). Roles define vertical permissions (admin/user). -**Groups** are pure access-control lists that decouple resource access from -team membership. - -Today, resource visibility follows a rigid scope model: `global` (everyone), -`team` (team members), `personal` (owner only). This breaks down when: -- A KB should be visible to members from two different teams. -- A Persona should be accessible by a cross-functional working group. -- A future Project needs participants from multiple teams. - -Groups solve this by adding a fourth access path — grant-by-group — without -changing the existing scope model. Existing `global/team/personal` access -continues to work unchanged. Groups are additive. - -Depends on: teams (v0.10.0), knowledge bases (v0.14.0). - -**Design principle: don't break the scope model.** Groups extend it. Every -existing query that filters by `scope + owner_id + team_id` remains valid. -Group membership is a parallel access path checked alongside scope, not a -replacement for it. - ---- - -## 1. Schema - -Migration: `010_groups.sql` - -### 1.1 Groups Table - -```sql --- ========================================= --- USER GROUPS --- ========================================= - -CREATE TABLE IF NOT EXISTS groups ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL, - description TEXT NOT NULL DEFAULT '', - scope VARCHAR(20) NOT NULL DEFAULT 'global' - CHECK (scope IN ('global', 'team')), - team_id UUID REFERENCES teams(id) ON DELETE CASCADE, - created_by UUID NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - - -- Global groups: team_id must be NULL - -- Team groups: team_id must be set - CONSTRAINT groups_scope_team CHECK ( - (scope = 'global' AND team_id IS NULL) OR - (scope = 'team' AND team_id IS NOT NULL) - ) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope - ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000')); - -- Unique name within scope (global names unique globally, - -- team names unique within team) - -COMMENT ON TABLE groups IS - 'Access-control groups. Decouple resource visibility from team membership.'; -COMMENT ON COLUMN groups.scope IS - 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.'; -``` - -### 1.2 Group Members Table - -```sql -CREATE TABLE IF NOT EXISTS group_members ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - added_by UUID NOT NULL REFERENCES users(id), - added_at TIMESTAMPTZ DEFAULT NOW(), - - UNIQUE(group_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id); -CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id); -``` - -### 1.3 Resource Grants Table - -```sql --- ========================================= --- RESOURCE GRANTS --- ========================================= - -CREATE TABLE IF NOT EXISTS resource_grants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - resource_type VARCHAR(30) NOT NULL - CHECK (resource_type IN ('persona', 'knowledge_base')), - resource_id UUID NOT NULL, - grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only' - CHECK (grant_scope IN ('team_only', 'global', 'groups')), - granted_groups UUID[] NOT NULL DEFAULT '{}', - created_by UUID NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - - -- Only one grant row per resource - UNIQUE(resource_type, resource_id) -); - -CREATE INDEX IF NOT EXISTS idx_resource_grants_resource - ON resource_grants(resource_type, resource_id); -CREATE INDEX IF NOT EXISTS idx_resource_grants_groups - ON resource_grants USING gin(granted_groups); - -- GIN index for UUID[] containment queries (&&, @>) - -COMMENT ON TABLE resource_grants IS - 'Controls cross-team access to resources (personas, KBs, future: projects).'; -COMMENT ON COLUMN resource_grants.grant_scope IS - 'team_only: existing team scope behavior. global: all authenticated users. groups: specific group list.'; -COMMENT ON COLUMN resource_grants.granted_groups IS - 'UUID array of group IDs. Only meaningful when grant_scope = groups.'; -``` - -### 1.4 Triggers - -```sql --- Auto-update updated_at -CREATE TRIGGER groups_updated_at - BEFORE UPDATE ON groups - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -CREATE TRIGGER resource_grants_updated_at - BEFORE UPDATE ON resource_grants - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -``` - -### Design Decision: UUID[] vs Junction Table - -The roadmap specifies `granted_groups UUID[]` on `resource_grants`. This is -the right call for this use case: - -- A resource has exactly one grant row (UNIQUE constraint). -- The group list is always read/written as a unit (replace-all semantics). -- Postgres `UUID[] && ARRAY[...]::uuid[]` with a GIN index is fast for - "does this resource grant overlap with the user's groups?" queries. -- Avoids a many-to-many junction table that would complicate the common - path (checking access) for marginal normalization benefit. - -A junction table (`resource_grant_groups`) would be warranted if we needed -per-group config on grants (e.g., read-only vs read-write per group). We -don't — the grant is binary (access or no access). If that changes, we -can migrate the array to a junction table later. - ---- - -## 2. Models - -### 2.1 Group Models - -```go -// ── Group Constants ──────────────────────── - -const ( - GroupScopeGlobal = "global" - GroupScopeTeam = "team" -) - -// ── Groups ───────────────────────────────── - -type Group 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"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - CreatedBy string `json:"created_by" db:"created_by"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - - // Computed fields (not stored in groups table) - MemberCount int `json:"member_count,omitempty"` - TeamName string `json:"team_name,omitempty"` -} - -type GroupPatch struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` -} - -type GroupMember struct { - ID string `json:"id" db:"id"` - GroupID string `json:"group_id" db:"group_id"` - UserID string `json:"user_id" db:"user_id"` - AddedBy string `json:"added_by" db:"added_by"` - AddedAt string `json:"added_at" db:"added_at"` - // Joined from users table - Username string `json:"username,omitempty"` - DisplayName string `json:"display_name,omitempty"` - Email string `json:"email,omitempty"` -} -``` - -### 2.2 Resource Grant Models - -```go -// ── Resource Grant Constants ─────────────── - -const ( - ResourceTypePersona = "persona" - ResourceTypeKnowledgeBase = "knowledge_base" - // Future: "project" - - GrantScopeTeamOnly = "team_only" - GrantScopeGlobal = "global" - GrantScopeGroups = "groups" -) - -// ResourceGrant controls cross-team access to a resource. -type ResourceGrant struct { - ID string `json:"id" db:"id"` - ResourceType string `json:"resource_type" db:"resource_type"` - ResourceID string `json:"resource_id" db:"resource_id"` - GrantScope string `json:"grant_scope" db:"grant_scope"` - GrantedGroups []string `json:"granted_groups" db:"granted_groups"` // UUID[] - CreatedBy string `json:"created_by" db:"created_by"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} -``` - ---- - -## 3. Store Interfaces - -### 3.1 GroupStore - -```go -// ========================================= -// GROUP STORE -// ========================================= - -type GroupStore interface { - // CRUD - Create(ctx context.Context, g *models.Group) error - GetByID(ctx context.Context, id string) (*models.Group, error) - Update(ctx context.Context, id string, patch models.GroupPatch) error - Delete(ctx context.Context, id string) error - - // Listing - ListAll(ctx context.Context) ([]models.Group, error) // admin view - ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) - ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I'm in - - // Members - AddMember(ctx context.Context, groupID, userID, addedBy string) error - RemoveMember(ctx context.Context, groupID, userID string) error - ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) - IsMember(ctx context.Context, groupID, userID string) (bool, error) - - // Bulk lookups (for access resolution) - GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) -} -``` - -### 3.2 ResourceGrantStore - -```go -// ========================================= -// RESOURCE GRANT STORE -// ========================================= - -type ResourceGrantStore interface { - // Upsert: one grant row per resource (replace semantics) - Set(ctx context.Context, grant *models.ResourceGrant) error - Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) - Delete(ctx context.Context, resourceType, resourceID string) error - - // Access check: does user have group-based access to this resource? - UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) -} -``` - -### 3.3 Wire into Stores Bundle - -```go -type Stores struct { - // ... existing fields ... - Groups GroupStore - ResourceGrants ResourceGrantStore -} -``` - ---- - -## 4. Access Resolution - -### 4.1 The Problem - -Today, access checks look like this (Persona example): - -```sql --- PersonaStore.ListForUser -WHERE scope = 'global' - OR (scope = 'personal' AND created_by = $1) - OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $1)) - OR (scope = 'personal' AND is_shared = true) -``` - -KB access follows the same pattern in `KnowledgeBaseStore.ListForUser` and -the handler's `userCanAccess()` method. - -Groups add a fourth access path: "I'm in a group that has been granted -access to this resource." - -### 4.2 Design: Parallel Check, Not Query Rewrite - -We could embed the group check into every `ListForUser` query. That would -mean every listing query joins against `resource_grants` and `group_members`, -adding complexity to every scope-aware query in the system. - -Instead: **keep existing scope queries as-is, add group-granted resources -as a separate UNION.** This is cleaner, backward-compatible, and doesn't -slow down the common path for users without group-based access. - -### 4.3 Updated Persona Listing - -```go -// PersonaStore.ListForUser — updated -func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) { - rows, err := DB.QueryContext(ctx, fmt.Sprintf(` - -- Existing scope-based access (unchanged) - SELECT %s FROM personas WHERE is_active = true AND ( - scope = 'global' - OR (scope = 'personal' AND created_by = $1) - OR (scope = 'team' AND owner_id IN ( - SELECT team_id FROM team_members WHERE user_id = $1 - )) - OR (scope = 'personal' AND is_shared = true) - ) - - UNION - - -- Group-based access (new) - SELECT %s FROM personas p - WHERE p.is_active = true - AND p.id IN ( - SELECT rg.resource_id FROM resource_grants rg - WHERE rg.resource_type = 'persona' - AND ( - rg.grant_scope = 'global' - OR (rg.grant_scope = 'groups' AND rg.granted_groups && ( - SELECT ARRAY_AGG(gm.group_id) FROM group_members gm - WHERE gm.user_id = $1 - )::uuid[]) - ) - ) - - ORDER BY scope, name - `, personaCols, personaCols), userID) - // ... -} -``` - -The `&&` operator is the Postgres array overlap operator — returns true if -the two arrays share any element. The GIN index on `granted_groups` makes -this efficient. - -### 4.4 Updated KB Listing - -Same pattern applied to `KnowledgeBaseStore.ListForUser`: - -```go -func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) { - // ... existing scope-based query ... - - // Add UNION for group-granted KBs - q += ` - UNION - SELECT id, name, description, scope, owner_id, team_id, embedding_config, - document_count, chunk_count, total_bytes, status, created_at, updated_at - FROM knowledge_bases kb - WHERE kb.id IN ( - SELECT rg.resource_id FROM resource_grants rg - WHERE rg.resource_type = 'knowledge_base' - AND ( - rg.grant_scope = 'global' - OR (rg.grant_scope = 'groups' AND rg.granted_groups && ( - SELECT ARRAY_AGG(gm.group_id) FROM group_members gm - WHERE gm.user_id = $1 - )::uuid[]) - ) - )` - - q += ` ORDER BY name` - return queryKBs(ctx, q, args...) -} -``` - -### 4.5 Updated UserCanAccess - -The KB handler's `userCanAccess()` method also needs updating: - -```go -func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool { - // Existing scope-based check (unchanged) - switch kb.Scope { - case "global": - return true - case "personal": - if kb.OwnerID != nil && *kb.OwnerID == userID { - return true - } - case "team": - if kb.TeamID != nil { - for _, tid := range teamIDs { - if tid == *kb.TeamID { - return true - } - } - } - } - - // New: check group-based grants - hasAccess, _ := h.stores.ResourceGrants.UserHasGroupAccess( - context.Background(), userID, models.ResourceTypeKnowledgeBase, kb.ID) - return hasAccess -} -``` - -The `PersonaStore.UserCanAccess` method gets the same treatment. - -### 4.6 GetActiveKBIDs (Channel KB Access) - -The `GetActiveKBIDs` query (used by the completion handler to determine which -KBs to search) also needs the group path: - -```sql --- Add to the WHERE clause: -OR kb.id IN ( - SELECT rg.resource_id FROM resource_grants rg - WHERE rg.resource_type = 'knowledge_base' - AND ( - rg.grant_scope = 'global' - OR (rg.grant_scope = 'groups' AND rg.granted_groups && ( - SELECT ARRAY_AGG(gm.group_id) FROM group_members gm - WHERE gm.user_id = $2 - )::uuid[]) - ) -) -``` - -### 4.7 Performance Note: User Group IDs Cache - -The `ARRAY_AGG(gm.group_id)` subquery appears in every access check. For -hot paths (listing, access checks), we can pre-fetch the user's group IDs -once per request and pass them through: - -```go -// In middleware or handler setup: -groupIDs, _ := stores.Groups.GetUserGroupIDs(ctx, userID) - -// Then in queries, pass as a parameter instead of subquery: -// ... AND rg.granted_groups && $N::uuid[] -``` - -This trades a subquery for a pre-fetched array parameter. Worth doing in -phase 2 if access checks show up in profiling. For v0.16.0, the subquery -approach is simpler and correct. - ---- - -## 5. Handlers - -### 5.1 GroupHandler - -```go -type GroupHandler struct { - stores store.Stores -} - -func NewGroupHandler(s store.Stores) *GroupHandler { - return &GroupHandler{stores: s} -} -``` - -**Admin endpoints (global groups):** - -| Method | Path | Handler | Auth | -|--------|------|---------|------| -| GET | `/admin/groups` | ListAllGroups | admin | -| POST | `/admin/groups` | CreateGlobalGroup | admin | -| PUT | `/admin/groups/:id` | UpdateGroup | admin | -| DELETE | `/admin/groups/:id` | DeleteGroup | admin | -| GET | `/admin/groups/:id/members` | ListGroupMembers | admin | -| POST | `/admin/groups/:id/members` | AddGroupMember | admin | -| DELETE | `/admin/groups/:id/members/:userId` | RemoveGroupMember | admin | - -**Team endpoints (team-scoped groups):** - -| Method | Path | Handler | Auth | -|--------|------|---------|------| -| GET | `/teams/:teamId/groups` | ListTeamGroups | team member | -| POST | `/teams/:teamId/groups` | CreateTeamGroup | team admin | -| PUT | `/teams/:teamId/groups/:id` | UpdateTeamGroup | team admin | -| DELETE | `/teams/:teamId/groups/:id` | DeleteTeamGroup | team admin | -| GET | `/teams/:teamId/groups/:id/members` | ListTeamGroupMembers | team member | -| POST | `/teams/:teamId/groups/:id/members` | AddTeamGroupMember | team admin | -| DELETE | `/teams/:teamId/groups/:id/members/:userId` | RemoveTeamGroupMember | team admin | - -**User endpoint:** - -| Method | Path | Handler | Auth | -|--------|------|---------|------| -| GET | `/groups/mine` | MyGroups | authenticated | - -### 5.2 Resource Grant Endpoints - -Grant management is inline on the resource endpoints, not a separate handler. - -**On Personas:** - -| Method | Path | Handler | Auth | -|--------|------|---------|------| -| GET | `/presets/:id/grants` | GetPersonaGrants | owner or admin | -| PUT | `/presets/:id/grants` | SetPersonaGrants | owner or admin | - -**On Knowledge Bases:** - -| Method | Path | Handler | Auth | -|--------|------|---------|------| -| GET | `/knowledge-bases/:id/grants` | GetKBGrants | owner or admin | -| PUT | `/knowledge-bases/:id/grants` | SetKBGrants | owner or admin | - -**Request/response format:** - -```json -// PUT /api/v1/presets/:id/grants -{ - "grant_scope": "groups", - "granted_groups": ["uuid-1", "uuid-2"] -} - -// GET /api/v1/presets/:id/grants -{ - "grant_scope": "groups", - "granted_groups": [ - { "id": "uuid-1", "name": "Engineering" }, - { "id": "uuid-2", "name": "Design" } - ] -} -``` - -The GET response enriches group UUIDs with names for display. The PUT -accepts bare UUIDs. - ---- - -## 6. Routes (main.go) - -```go -// ── Groups ────────────────────────────── - -// User: my groups -protected.GET("/groups/mine", groupH.MyGroups) - -// Team-scoped groups -teamScoped.GET("/groups", groupH.ListTeamGroups) -teamScoped.POST("/groups", groupH.CreateTeamGroup) // RequireTeamAdmin -teamScoped.PUT("/groups/:groupId", groupH.UpdateTeamGroup) // RequireTeamAdmin -teamScoped.DELETE("/groups/:groupId", groupH.DeleteTeamGroup) // RequireTeamAdmin -teamScoped.GET("/groups/:groupId/members", groupH.ListTeamGroupMembers) -teamScoped.POST("/groups/:groupId/members", groupH.AddTeamGroupMember) // RequireTeamAdmin -teamScoped.DELETE("/groups/:groupId/members/:userId", groupH.RemoveTeamGroupMember) // RequireTeamAdmin - -// Admin: global groups -admin.GET("/groups", groupH.ListAllGroups) -admin.POST("/groups", groupH.CreateGlobalGroup) -admin.PUT("/groups/:id", groupH.UpdateGroup) -admin.DELETE("/groups/:id", groupH.DeleteGroup) -admin.GET("/groups/:id/members", groupH.ListGroupMembers) -admin.POST("/groups/:id/members", groupH.AddGroupMember) -admin.DELETE("/groups/:id/members/:userId", groupH.RemoveGroupMember) - -// ── Resource Grants ───────────────────── - -// Persona grants (add to existing preset routes) -protected.GET("/presets/:id/grants", personaH.GetGrants) -protected.PUT("/presets/:id/grants", personaH.SetGrants) - -// KB grants (add to existing KB routes) -protected.GET("/knowledge-bases/:id/grants", kbH.GetGrants) -protected.PUT("/knowledge-bases/:id/grants", kbH.SetGrants) -``` - ---- - -## 7. Audit Logging - -All group and grant operations log to the existing audit system: - -| Action | Resource Type | Details | -|--------|--------------|---------| -| `group.create` | `group` | name, scope, team_id | -| `group.update` | `group` | changed fields | -| `group.delete` | `group` | name | -| `group.member.add` | `group` | user_id | -| `group.member.remove` | `group` | user_id | -| `resource_grant.set` | `persona` / `knowledge_base` | grant_scope, group count | -| `resource_grant.delete` | `persona` / `knowledge_base` | — | - -Uses the existing `AuditStore.Log` method — no new infrastructure. - ---- - -## 8. Frontend - -### 8.1 Admin Panel: Groups Section - -New tab in the admin panel (alongside Users, Teams, Models, Settings, etc.): - -**Groups list view:** -- Table: Name, Scope, Team (if team-scoped), Members count, Actions -- "Create Group" button → modal with name, description, scope selector -- Click row → member management (add/remove users, search by username) - -**Team admin panel:** -- New "Groups" tab under team settings -- Same list/CRUD pattern, scoped to team - -### 8.2 Grant Picker Component - -Reusable component for Persona and KB create/edit forms: - -``` -┌─ Access ──────────────────────────────────┐ -│ ○ Team Only (default) │ -│ ○ All Users │ -│ ○ Specific Groups │ -│ ┌────────────────────────────────────┐ │ -│ │ [x] Engineering │ │ -│ │ [x] Design │ │ -│ │ [ ] Marketing │ │ -│ │ [ ] Leadership │ │ -│ └────────────────────────────────────┘ │ -└────────────────────────────────────────────┘ -``` - -- Only shown for team-scoped and global-scoped resources (personal resources - are always owner-only). -- "Team Only" = existing behavior (no `resource_grants` row, or row with - `grant_scope = 'team_only'`). -- "All Users" = `grant_scope = 'global'`. -- "Specific Groups" = `grant_scope = 'groups'` + multi-select group picker. -- Group list loaded from `/api/v1/groups/mine` (user's groups) for team - admins, `/api/v1/admin/groups` for system admins. - -### 8.3 User's Group Badge - -The model selector and KB list already show scope badges (`global`, `team`, -`personal`). For group-granted resources, show a "group" badge to distinguish -from team-native access. - ---- - -## 9. Backward Compatibility - -### 9.1 No Breaking Changes - -- All existing scope-based queries remain valid. -- Resources without a `resource_grants` row behave exactly as before. -- The `resource_grants` table is opt-in: only populated when an admin or - team admin explicitly sets grants on a resource. -- `persona_grants` (tool/KB/API grants ON a persona) is unrelated to - `resource_grants` (access grants TO a persona). Different concepts, - different tables. No rename needed. - -### 9.2 Migration Safety - -- New tables only (no ALTER on existing tables). -- No data migration required. -- Rollback: drop the three new tables. - -### 9.3 Naming Clarity - -To avoid confusion between the existing `persona_grants` table (which -controls what tools/KBs a Persona has access TO) and the new -`resource_grants` table (which controls which users have access TO the -Persona): - -| Table | Purpose | Example | -|-------|---------|---------| -| `persona_grants` | What a Persona can do (tools, KBs, APIs) | "CodeBot can use calculator" | -| `resource_grants` | Who can use a resource (users via groups) | "Engineering group can use CodeBot" | - -The naming is intentionally distinct. No rename of `persona_grants` is needed. - ---- - -## 10. Implementation Phases - -### Phase 1: Groups Entity (backend) -- [ ] Migration `010_groups.sql` -- [ ] Models: `Group`, `GroupPatch`, `GroupMember` -- [ ] Store: `GroupStore` interface + Postgres implementation -- [ ] Handler: `GroupHandler` with admin + team admin + user endpoints -- [ ] Routes: wire into `main.go` -- [ ] Audit logging for group operations -- [ ] Integration tests: CRUD, membership, scoping - -### Phase 2: Resource Grants (backend) -- [ ] Models: `ResourceGrant` + constants -- [ ] Store: `ResourceGrantStore` interface + Postgres implementation -- [ ] Grant endpoints on PersonaHandler and KBHandler -- [ ] Update `PersonaStore.ListForUser` with UNION for group access -- [ ] Update `PersonaStore.UserCanAccess` with group check -- [ ] Update `KnowledgeBaseStore.ListForUser` with UNION for group access -- [ ] Update `KBHandler.userCanAccess` with group check -- [ ] Update `KnowledgeBaseStore.GetActiveKBIDs` with group path -- [ ] Audit logging for grant operations -- [ ] Integration tests: grant CRUD, access resolution, cross-team visibility - -### Phase 3: Frontend -- [ ] Admin panel: Groups section (CRUD, member management) -- [ ] Team admin panel: Groups tab -- [ ] Grant picker component (shared between Persona and KB forms) -- [ ] `/groups/mine` endpoint for user's group list -- [ ] "Group" scope badge on model selector / KB list -- [ ] Update Persona create/edit form with grant picker -- [ ] Update KB create/edit form with grant picker - ---- - -## 11. Testing Plan - -### Integration Tests (extend `handlers/integration_test.go`) - -**Group lifecycle:** -1. Admin creates global group → verify in list -2. Admin adds users to group → verify membership -3. Team admin creates team group → verify scoping (not visible to other teams) -4. Remove member → verify no longer in group -5. Delete group → verify CASCADE cleans up members - -**Resource grants:** -1. Team admin creates a KB with `grant_scope = 'groups'`, grants to a group -2. User in the group can see the KB in `ListForUser` → verify access -3. User NOT in the group cannot see the KB → verify denied -4. User in group can search the KB via `kb_search` tool → verify access -5. Remove user from group → verify KB no longer visible -6. Change grant from `groups` to `global` → verify all users can access -7. Delete grant → verify KB reverts to team-only visibility - -**Backward compatibility:** -1. Resources with no `resource_grants` row → same behavior as before -2. Existing scope queries → unchanged results -3. Personal resources → never grant-accessible (grant picker hidden) - -### Edge Cases -- User belongs to 0 groups → `ARRAY_AGG` returns NULL → no match (safe) -- Group with 0 members → grant exists but no one matches (correct) -- Delete a group that's referenced in `granted_groups` → array still contains - the UUID but no `group_members` rows match → effectively revoked (correct). - Cleanup: periodic job or ON DELETE trigger to scrub stale UUIDs from arrays. - For v0.16.0, stale UUIDs are harmless (they just don't match anything). - ---- - -## 12. Future Considerations - -### v0.17.0 — Persona-KB Binding -Resource grants enable the access control layer for Persona-KB binding. -When a Persona has bound KBs, the Persona's grants implicitly grant search -access to those KBs. The `resource_grants` table already supports this — -the Persona grant controls who can use the Persona, and Persona-KB binding -controls which KBs are searchable through it. - -### v0.19.0 — Projects -Projects will use the same `resource_grants` pattern: -`resource_type = 'project'`. No schema changes needed. - -### v0.24.0 — Full RBAC -OIDC claim → group mapping will auto-sync external IdP groups into the -`groups` table. The infrastructure built here becomes the foundation for -enterprise RBAC. - -### Stale Group UUID Cleanup -When a group is deleted, its UUID may linger in `resource_grants.granted_groups` -arrays. This is harmless (dead UUIDs never match any `group_members` rows) -but untidy. Options for a future cleanup: -- Trigger on `groups` DELETE that scrubs the UUID from all `granted_groups` arrays. -- Periodic background job. -- Cleanup on next grant SET (when the admin edits the grant). - -For v0.16.0, we'll add a note in the delete handler response if the group -is referenced in any grants, warning the admin. diff --git a/docs/DESIGN-0.17.0.md b/docs/DESIGN-0.17.0.md deleted file mode 100644 index 15a488f..0000000 --- a/docs/DESIGN-0.17.0.md +++ /dev/null @@ -1,184 +0,0 @@ -# DESIGN-0.17.0 — Persona-KB Binding + Enterprise KB Mode - -## Overview - -Personas become **gateways** to knowledge. In enterprise deployments, -users don't interact with KBs directly — they talk to Personas that -have KBs attached. This is the core differentiator for enterprise use. - -Depends on: knowledge bases (v0.14.0), user groups (v0.16.0). - -**Design principle: Personas carry context, not users.** When a user -selects a Persona with bound KBs, the KB context flows automatically — -no manual KB selection, no toggle management, no confusion about which -KBs are in scope. Admins curate the KB↔Persona relationships; users -just pick a Persona. - ---- - -## 1. Schema - -### `persona_knowledge_bases` (new join table) - -```sql -CREATE TABLE persona_knowledge_bases ( - persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE, - kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, - auto_search BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (persona_id, kb_id) -); -``` - -`auto_search` controls whether the KB is searched automatically on every -message (top-K results prepended to context) or only via explicit -`kb_search` tool calls. Default `false` = tool-only. - -### `knowledge_bases.discoverable` (new column) - -```sql -ALTER TABLE knowledge_bases ADD COLUMN discoverable BOOLEAN NOT NULL DEFAULT true; -``` - -When `false`, the KB does not appear in user-facing listings -(`ListDiscoverableKBs`). It remains searchable through Persona bindings. - -### `platform_policies.kb_direct_access` (new row) - -Seeded as `'true'` (permissive default). When set to `'false'`, the -channel KB popup is hidden — users access KBs exclusively through -Persona bindings. - ---- - -## 2. Store Layer - -### `PersonaStore` additions - -- `SetKBs(ctx, personaID, kbIDs, autoSearch)` — UPSERT join table -- `GetKBs(ctx, personaID)` — returns `[]PersonaKB` with KB metadata - -### `KnowledgeBaseStore` additions - -- `ListDiscoverable(ctx, userID, teamIDs)` — KBs where `discoverable=true` - and user has access via ownership, team, or global scope -- `SetDiscoverable(ctx, kbID, discoverable)` — toggle visibility -- `UpdateDocumentStorageKey(ctx, docID, key)` — moved from raw SQL - ---- - -## 3. Completion Pipeline - -### KB scoping in `BuildKBHint` - -When a Persona has bound KBs, `BuildKBHint` merges them with any -channel-attached KBs: - -1. Load channel KBs (existing path) -2. Load Persona KBs via `PersonaStore.GetKBs()` -3. Union the two sets (deduplicated by KB ID) -4. Build the hint string with listing - -### Persona ID threading - -The `personaID` is threaded through the completion handler methods: -`Complete()` → `streamCompletion(…, personaID)` / `syncCompletion(…, personaID)` -→ `loadConversation(…, personaID)`. - -The `ExecutionContext` for tool calls carries the Persona ID so -`kb_search` can auto-include Persona-bound KBs in its search scope. - ---- - -## 4. Role Fallback Alerts (issue #69) - -When a role's primary provider fails: - -1. **Attempt fallback** — existing behavior, unchanged. -2. **Emit event** — `role.fallback` on EventBus (DirToClient). -3. **Cooldown** — 5-minute per-role TTL prevents flooding. -4. **Admin toast** — frontend listens for `role.fallback` events, - shows warning/error toast to admin users only. - -### Resolver changes - -```go -type Resolver struct { - stores store.Stores - vault *crypto.KeyResolver - bus *events.Bus - mu sync.Mutex - cooldown map[string]time.Time // role → last alert time -} -``` - -`WithBus(bus)` builder method. Nil-safe — if no bus is attached, -alerts are suppressed (log only). - ---- - -## 5. Security Fixes - -### ResolvePreset bypass - -`ResolvePreset()` in `presets.go` used raw SQL that skipped the -`resource_grants` table added in v0.16.0. A Persona accessible only -via group grant would fail at completion time. Fixed to use -`PersonaStore.GetByID()` + `UserCanAccess()`. - -### KB create scope authorization - -`POST /api/v1/knowledge-bases` now enforces: -- `scope=global` → requires admin role -- `scope=team` → requires team admin role for the target team -- `scope=personal` → open to all authenticated users - ---- - -## 6. Frontend - -### KB Picker (`persona-kb.js`) - -Standalone component: `renderPersonaKBPicker(container, opts)` → control -object with `getValues()`, `setValues()`, `clear()`, `refresh()`. - -Options: `scope` ('admin'|'team'|'personal'), `teamId`, `prefix`. - -Shows checkboxes for available KBs with doc/chunk counts. Selected KBs -show an "Auto-inject" toggle for the `auto_search` flag. - -### Integration points - -- Admin preset form: KB picker below the form, load/save bindings on - edit/create via `/api/v1/admin/presets/:id/knowledge-bases`. -- Team preset form: same pattern via team API prefix. -- User preset form: shows discoverable KBs only. -- Admin settings: `kb_direct_access` toggle checkbox. - -### Fallback alert - -Admin users receive `UI.toast()` notifications when `role.fallback` -events arrive via WebSocket. - ---- - -## 7. API Endpoints (new) - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/v1/presets/:id/knowledge-bases` | List KBs bound to a persona | -| PUT | `/api/v1/presets/:id/knowledge-bases` | Set persona-KB bindings | -| GET | `/api/v1/admin/presets/:id/knowledge-bases` | Admin: list persona KBs | -| PUT | `/api/v1/admin/presets/:id/knowledge-bases` | Admin: set persona-KB bindings | -| GET | `/api/v1/knowledge-bases-discoverable` | List discoverable KBs for user | -| PUT | `/api/v1/knowledge-bases/:id/discoverable` | Toggle KB discoverability | - ---- - -## 8. Migration - -Single migration file: `002_v017_persona_kb.sql` - -- Creates `persona_knowledge_bases` table -- Adds `discoverable` column to `knowledge_bases` -- Seeds `kb_direct_access` platform policy (default `'true'`) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d12b1bc..3d5b602 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,1192 +17,129 @@ Features have real dependencies. This ordering respects them. ``` v0.9.x Stability + Quick UX Wins ✅ - │ -v0.9.3 Content Visibility & Block Controls ✅ - │ -v0.9.4 API Key Encryption + Vault ✅ - │ -v0.10.0 Model Roles (utility + embedding) ✅ - + Usage Tracking - │ -v0.10.2 Summarize & Continue ✅ - │ -v0.10.3 Frontend Refactor ✅ - │ -v0.10.4 Model Type Pipeline + Role Fixes ✅ - │ -v0.10.5 UI Primitives + Extension Surfaces ✅ - │ -v0.11.0 Extension Foundation (browser tier) ✅ - │ - ┌───────┴──────────────┐ - │ │ -v0.12.0 File Handling v0.13.0 Admin Panel - + Vision ✅ Refactor (fullscreen) ✅ - │ │ - │ v0.13.1 Web Search - │ + url_fetch ✅ - │ │ - └───────┬──────────────┘ - │ -v0.14.0 Knowledge Bases ✅ (embedding role + file storage + pgvector) - │ -v0.15.0 Compaction ✅ (utility role + background job) - │ -v0.15.1 Context Recall Tools ✅ (attachment_recall, conversation_search) - │ - ┌───────┴──────────────┐ - │ │ -v0.16.0 User Groups v0.17.0 Persona-KB Binding - + Resource Grants ✅ + Enterprise KB Mode - │ + QOL / UX Wins - └───────┬──────────────┘ - │ - ┌───────┴──────────────┐ - │ │ -v0.17.1 SQLite Backend ✅ v0.17.2 CodeMirror 6 ✅ -v0.17.3 Notes Graph + Wikilinks ✅ - (dual DB) (editor bundle, chat - │ input, ext editor) - └───────┬──────────────┘ - │ -v0.18.0 Memory ✅ (user + persona scopes, review pipeline) - │ -v0.18.1 Side Panel Architecture ✅ (single-slot, ctx.ui primitives, mermaid refactor) - │ -v0.19.0 Projects / Workspaces ✅ (project groups, KB resolution, drag-drop sidebar) - │ -v0.19.1 Active Project + System Prompt + Detail Panel ✅ - │ -v0.19.2 Project Persona Default + Archive + Reorder ✅ - │ -v0.20.0 Notifications + @mention Routing + Multi-model ✅ - │ -v0.21.0 Workspace Storage Primitive ✅ - │ - ┌───────┴──────────────┐ - │ │ -v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅ - Tools + Bindings + REPL (parallel) - │ │ -v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅ - Indexing + Search (Development Mode) - │ │ -v0.21.4 Git ✅ v0.21.6 Editor Surface ✅ - Integration + Document Output - └───────┬──────────────┘ - │ -v0.21.7 Bugfix: scanJSON driver buffer aliasing ✅ - │ -v0.22.0 Provider Health + Capability Overrides ✅ - + Workspace Pane Refactor (FE) - │ -v0.22.1 Provider Extensions (declarative config) ✅ - │ -v0.22.2 Routing Policies + Fallback Chains ✅ - │ -v0.22.3 Provider Admin UI + Deferred Polish ✅ - │ -v0.23.0 Multi-Participant Channels + Presence - │ -v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions - │ - ┌───────┴──────────────┐ - │ │ -v0.25.0 Workflow v0.26.0 Tasks / - Engine Autonomous Agents - (team-owned, (service channels, - staged processes, scheduler, unattended - human + AI collab) execution) + │ +v0.10.x Providers + Models + Roles ✅ + │ +v0.11.x Tool Framework + Extension Base ✅ + │ +v0.12.x File Storage + Vision ✅ + │ +v0.13.x Web Tools + Toggle ✅ + │ +v0.14.x Knowledge Bases (RAG) ✅ + │ +v0.15.x Auto-Compaction ✅ + │ +v0.16.x Groups + Resource Grants ✅ + │ +v0.17.x Personas + Notes/CM6 + SQLite ✅ + │ +v0.18.x Memory System ✅ + │ +v0.19.x Projects/Workspaces ✅ + │ +v0.20.x Surface System ✅ + │ +v0.21.x Admin UX + Routing ✅ + │ +v0.22.x Routing Policies + Provider Chains ✅ + │ +v1.0 Stability + Multi-Tenant ⏳ ``` ---- - ## Completed Releases -> Full details for all completed versions are in [CHANGELOG.md](../CHANGELOG.md). - -### ✅ v0.9.0 — Schema Consolidation + BYOK -21 migrations → single schema, store layer abstraction, persona-as-trust-boundary, -BYOK with auto-fetch, composite model IDs, journey integration tests. - -### ✅ v0.9.1 — Capability Architecture + Docs -Removed static known model table. Resolution chain: catalog → heuristic only. -Frontend relies solely on backend for capabilities. Docs rewrite. - -### ✅ v0.9.2 — Quick UX Wins + Hardening -Collapsible code blocks, HTML preview, token counter, context warning, proxy -interception detection, environment injection, team admin audit scoping. - -### ✅ v0.9.3 — Content Visibility & Block Controls -Button-driven code collapse, always-rendered thinking blocks, tool call -persistence in history, Notes panel → unified side panel, streaming refactor -(`streamWithToolLoop`), regenerate fix. - -### ✅ v0.9.4 — API Key Encryption + Vault -Two-tier AES-256-GCM: env-var key for global/team, per-user UEK (Argon2id) -for personal BYOK. `server/crypto/` package. Migration 003. Startup backfill. -DOMPurify strict allowlist (XSS fix). - -### ✅ v0.10.0 — Model Roles + Usage Tracking + Vault Debt -Named role slots (utility, embedding) with fallback. `Provider.Embed()` interface. -`usage_log` + `model_pricing` tables. Streaming token capture. Vault debt: -UEK re-wrap on password change, destruction on admin reset. - -### ✅ v0.10.1 — Polish + UX -Admin system prompt injection, Personas tab, Team Management modal extraction, -preview pane enhancements, code download, personal usage scoped to BYOK. - -### ✅ v0.10.2 — Summarize & Continue + Role Cleanup -User-triggered conversation compaction via utility role. Summary as tree node -with boundary metadata. BYOK role overrides (personal → team → global). -Utility rate limiting. Generation role removed. - -### ✅ v0.10.3 — Frontend Refactor -2 monolith files → 13 domain-scoped files (~544 lines avg). Zero features, -no function renames. Vanilla JS, no build step. -See [REFACTOR-0.10.3.md](REFACTOR-0.10.3.md). - -### ✅ v0.10.4 — Model Type Pipeline + Role Fixes -`model_type` (chat/embedding/image) captured end-to-end from provider API -through catalog to frontend. Role dropdowns filter by type. - -### ✅ v0.10.5 — UI Primitives + Extension Surfaces -Shared `Providers`/`Roles` registries, 6 consolidated render primitives, -`showConfirm()` replacing native dialogs, `.popup-menu` CSS primitive. -Removed ~400 lines of duplication. - -### ✅ v0.11.0 — Extension Foundation (Browser Tier) -Full extension lifecycle: manifest, loader, scoped `ctx.*` API, browser tool -bridge via WebSocket. Custom renderer pipeline. 2 server tools (calculator, -datetime), 6 built-in browser extensions (Mermaid, KaTeX, CSV, Diff, JS -Sandbox, Regex). See [EXTENSIONS.md](EXTENSIONS.md). - -### ✅ v0.12.0 — File Handling + Vision -S3/PVC storage backend, image/file upload (📎, drag-drop, paste), multimodal -message assembly, text extraction pipeline (PDF/DOCX/XLSX/PPTX/ODT/RTF), -vault CLI (`rekey`/`status`), per-chat model persistence. - -### ✅ v0.13.0 — Admin Panel Refactor -12-tab modal → fullscreen admin panel. 4 categories (People, AI, System, -Monitoring) × section sidebar. URL-based routing, responsive, banner-aware. -CSS design token cleanup. - -### ✅ v0.13.1 — Web Search + URL Fetch -`web_search` + `url_fetch` tools. Search provider abstraction (DuckDuckGo, -SearXNG). Tool categories with per-tool toggle UI in chat bar. - -### ✅ v0.14.0 — Knowledge Bases -RAG: upload → chunk → embed (pgvector) → `kb_search` tool. Channel KB toggle. -Team/personal KB scopes. Notes semantic search. Admin panel KB management. -See [DESIGN-0.14.0.md](DESIGN-0.14.0.md). - -### ✅ v0.15.0 — Compaction -Background scanner with configurable thresholds. Automatic summarization via -utility role. Per-channel opt-in/out. Context budget guard rail (80% ceiling). - -### ✅ v0.15.1 — Context Recall Tools -`attachment_recall` (list + read, channel-scoped), `conversation_search` -(full-text via `plainto_tsquery`). Token estimator attachment awareness. - -### ✅ v0.16.0 — User Groups + Resource Grants -Groups (global/team-scoped ACLs) decoupled from team membership. Three-way -resource grants (team_only/global/groups) for Personas and KBs. Grant picker -UI. Schema consolidation: 9 migrations → single `001_v016_schema.sql`. - ---- - -## v0.17.0 — Persona-KB Binding + Enterprise KB Mode + QOL ✅ - -Personas become **gateways** to knowledge. In enterprise deployments, -users don't interact with KBs directly — they talk to Personas that -have KBs attached. Plus quality-of-life improvements: chat rename, -token display, state persistence, utility model auto-naming. - -Depends on: knowledge bases (v0.14.0), user groups (v0.16.0). - -**Persona-KB Binding** -- [x] `persona_knowledge_bases` table: `persona_id`, `kb_id`, `auto_search` (bool — always search vs tool-only) -- [x] When a channel uses a Persona with bound KBs, `kb_search` is auto-scoped to those KBs -- [x] No user toggle needed — Persona carries its own KB context -- [x] Channel KB toggle becomes "additional KBs" on top of Persona-provided ones -- [x] Persona system prompt auto-includes KB listing hint (reuses `BuildKBHint` pattern) -- [x] Admin/team admin UI: KB picker on Persona create/edit form - -**Enterprise KB Mode** -- [x] `discoverable` flag on knowledge_bases: `true` (default — appears in user's KB popup) or `false` (hidden, only accessible through Persona binding) -- [x] Hidden KBs don't appear in `GET /api/v1/knowledge-bases-discoverable` for non-admins -- [x] Hidden KBs still searchable by `kb_search` tool when accessed through a Persona -- [x] Admin panel: toggle discoverability per KB -- [x] Platform policy: `kb_direct_access` — when `false`, disables channel KB popup entirely (strict enterprise mode) - -**Access Control Integration** -- [x] Persona grants (v0.16.0 groups) control who can *use* a Persona -- [x] KB grants control who can *manage* a KB (upload/delete docs) -- [x] Persona-KB binding grants implicit *search* access through the Persona -- [x] Users who can use a Persona can search its KBs, even if they can't see those KBs directly - -**Team Admin Workflow** -- [x] Team admin creates KB → uploads documents → marks non-discoverable -- [x] Team admin creates Persona → binds KBs → sets access to Team Only or specific Groups -- [x] Team members see the Persona in their preset list, use it, get KB-powered answers -- [x] Team members never see or manage the underlying KBs - -**Technical Debt (carried forward)** -- [x] **Security**: KB create handler authorization — team/global scope verified -- [x] **Test hygiene**: stripped DIAG diagnostics from `TestGroupBasedPersonaAccess` -- [x] **Architecture**: `KnowledgeBaseStore.UpdateDocumentStorageKey()` — moved to store layer -- [x] **UX**: Embedding dropdown fix — tolerant type filter, manual model ID fallback, auto-switch on empty _(already implemented in `ui-primitives.js`)_ -- [x] **Minor**: Paste-to-file character threshold — synced from backend `PublicSettings` (`storage.paste_to_file_chars`), admin-configurable, default 2000 - -**Role Fallback Alerts** ([#69](https://git.gobha.me/xcaliber/chat-switchboard/issues/69)) -- [x] Audit log entry on fallback fire: `action = "role.fallback"` with primary/fallback model IDs and error string -- [x] `role.fallback` WebSocket event via EventBus (`PublishAsync`, zero latency impact on completion path) -- [x] In-memory cooldown on `Resolver` (per-role, 5-minute TTL) to deduplicate repeated fallback alerts -- [x] Admin UI: persistent alert banner on `role.fallback` event (persists until dismissed) -- [x] Wire `*events.Bus` into `roles.NewResolver` via `.WithBus()` builder - -**QOL / UX Wins** -- [x] **Chat rename**: inline edit on sidebar item — dblclick title → input → blur/Enter saves via `API.updateChannel(id, { title })` -- [x] **Chat token count**: display `Tokens.estimateConversation()` in model bar, update on `selectChat` and after each message -- [x] **State restore on refresh**: persist `App.currentChatId` to `sessionStorage` on select, restore on load -- [x] **Utility model auto-naming**: after first assistant response, background utility role request → `POST /channels/:id/generate-title` → re-render sidebar. Fallback to truncation when no utility model configured. Debounced via `_autoNamePending` set -- [x] **SW extension cache fix**: exclude `/extensions/` from service worker fetch handler -- [x] **Mermaid renderer**: promoted enhanced version (v2.0 — pan/zoom, SVG/PNG export, source copy) to `extensions/builtin/mermaid-renderer/` - ---- - -## v0.17.1 — SQLite Backend ✅ - -Dual-database support. Every subsequent schema change is developed -against both Postgres and SQLite from day one — no retrofit. SQLite -enables single-binary deployment for dev, demo, edge, and single-user. - -Depends on: v0.17.0 DB debt cleanup (store layer is sole DB interface). - -**Store Layer** -- [x] 19 SQLite store implementations covering all domain interfaces -- [x] `DB_DRIVER` env var: `postgres` (default) | `sqlite` -- [x] Separate migration files per driver (`migrations/sqlite/`) -- [x] WAL mode + single-writer connection pooling for SQLite concurrency -- [x] CI matrix: full handler integration tests against both Postgres and SQLite - -**Vector Search** -- [x] App-level cosine similarity in Go (pure Go, no CGO/sqlite-vec required) -- [x] Embeddings stored as JSON text in `kb_chunks.embedding` column -- [x] Full feature parity with pgvector: `SimilaritySearch`, `InsertChunks` -- [x] Graceful: adequate performance for SQLite-scale deployments (single-user, not millions of chunks) - -**Schema Compatibility** -- [x] UUID generation: application-side `store.NewID()` (Go `uuid.New()`) -- [x] JSONB → JSON text columns with `ToJSON()`/`ScanJSON()` helpers -- [x] `UUID[]` arrays → JSON arrays with `ToJSONArray()`/`ScanJSONArray()` -- [x] `TIMESTAMPTZ` → SQLite `TEXT` with `datetime('now')` -- [x] `ON CONFLICT excluded.*` pattern (works in both dialects) -- [x] `INSERT RETURNING id` → separate query pattern for SQLite - -**Test Infrastructure** -- [x] `dialectSQL()` converts `$N` → `?` and strips `::jsonb` at runtime -- [x] `database.PH(n)` returns dialect-appropriate placeholder -- [x] `database.TruncateAll()` dialect-aware (DELETE + PRAGMA vs TRUNCATE CASCADE) -- [x] Generic provider config: `PROVIDER`/`PROVIDER_KEY`/`PROVIDER_URL` (replaces `VENICE_API_KEY`) -- [x] Model selection prefers non-reasoning models in live tests - -**What SQLite mode skips** (feature-gated, not broken): -- [x] `LISTEN/NOTIFY` — WebSocket event fan-out uses in-process EventBus (already works) -- [x] Full-text search `tsvector` — conversation_search uses LIKE fallback - ---- - -## v0.17.2 — CodeMirror 6 Integration ✅ - -Rich editor infrastructure for the frontend. CM6 is ESM-native; the build -step runs in Docker (esbuild → single IIFE bundle). No change to dev -experience for non-editor code — the rest of `src/js/` stays vanilla. - -Depends on: nothing (frontend-only). Prerequisite for: extension surfaces -(v0.21.5 editor surface). See [DESIGN-CM6.md](DESIGN-CM6.md) for full spec. - -**Build Pipeline** -- [x] `src/editor/` directory: `package.json`, `build.mjs`, `index.mjs`, language/theme modules -- [x] `scripts/build-editor.sh`: shared build script for both Dockerfiles and local dev -- [x] New Docker stage (`cm6-build`): `npm ci` + `node build.mjs` → `vendor/codemirror/` -- [x] Both `Dockerfile.frontend` and `Dockerfile` (unified) use the shared build script -- [x] Bundle output: `codemirror.bundle.js` (~295KB min, ~90KB gzip) -- [x] Graceful degradation: all integration points check `window.CM` — falls back to `