27 KiB
Architecture — Chat Switchboard v0.20
Deployment Modes
Three Docker images support different deployment scenarios:
| Image | Dockerfile | Contents | Use Case |
|---|---|---|---|
| Unified | Dockerfile |
nginx + Go backend | Dev, docker-compose, single-node |
| Backend | server/Dockerfile |
Go binary only | K8s — scale API independently |
| Frontend | Dockerfile.frontend |
nginx + static files + CM6 bundle | K8s — scale FE independently |
Unified bundles everything in one container (4-stage Docker build: vendor libs → CM6 bundle → Go backend → nginx runtime). Nginx serves static files and proxies /api/* and /ws to the Go backend running on :8080. Good for development and small deployments.
Split (Backend + Frontend) for production K8s: Ingress routes /api/* and /ws to the backend Service, everything else to the frontend Service. The frontend entrypoint (docker-entrypoint-fe.sh) handles BASE_PATH injection into index.html and dynamic nginx config generation at startup. Supports branding volume mounts at /branding/.
┌─────────────────────────┐
│ Ingress / Traefik │
│ ├─ /api/* → be-svc:8080 │
│ ├─ /ws → be-svc:8080 │
│ └─ /* → fe-svc:80 │
└─────────────────────────┘
│ │
┌──────────▼──┐ ┌───────▼────────┐
│ Backend │ │ Frontend │
│ (Go :8080) │ │ (nginx :80) │
│ replicas:N │ │ replicas:M │
└──────┬──────┘ └────────────────┘
│
┌──────▼──────┐
│ PostgreSQL │ (or SQLite for
└─────────────┘ single-node)
Design Principles
-
Persona-as-Trust-Boundary: A persona (model + config + prompt) is the unit of access control. Users interact with personas, not raw provider configs. Admins control which models are visible; team admins control which personas their team can use.
-
Roles vs Teams: Clean separation between vertical permissions (Roles: admin, user) and horizontal visibility (Teams: organizational units). A user's role determines what they can do; their team membership determines what they can see.
-
Store Layer: All database access goes through typed Go interfaces (
store.Stores). Handlers never write raw SQL. The store layer has two implementations —store/postgres/andstore/sqlite/— selected at startup viaDB_DRIVER. This enables Postgres for production and SQLite for single-node or air-gapped deployments. -
Scope Model: Provider configs, personas, and model settings all use a three-value
scopecolumn:global(admin-managed, visible to all),team(team-admin-managed, visible to team),personal(user-managed, visible to owner). Theowner_idcolumn points to the owning user or team depending on scope. -
Capabilities Resolution: Model capabilities (vision, tool calling, thinking, context window) are resolved through a three-tier priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID) → admin overrides (highest priority, from
capability_overridestable). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh. Admins can correct any field via the override endpoints. -
Channels as Execution Context: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure.
Workflow Architecture (Future — v0.25.0+)
The platform's existing primitives (teams, personas, channels, tools, notes) compose into a workflow engine where the channel is the execution context that moves through defined stages.
Conceptual Model
Team
└─ Workflow (team-admin defined)
├─ name, description
├─ entry conditions (public link, team-internal, API trigger)
└─ Stages[]
├─ persona_id (which AI drives this stage)
├─ assignment_team_id (who can be assigned)
├─ form_template (structured note schema, optional)
└─ transitions[] (conditions → next stage)
Channel (workflow instance)
├─ workflow_id + current_stage
├─ participants[]
│ ├─ anonymous visitor (mTLS fingerprint / session token)
│ ├─ AI persona (per-stage, from workflow definition)
│ └─ assigned team member (claimed or auto-routed)
├─ messages (existing tree structure)
├─ notes (channel-scoped artifacts, intake forms)
└─ tool activity (existing execution framework)
How Existing Primitives Map
| Existing Primitive | Workflow Role |
|---|---|
| Persona (system prompt + model) | Drives a workflow stage — the AI knows what to collect, when to route |
| Team (members + roles) | Owns the workflow definition; members are assignable to channels |
| Channel (messages + tree) | Execution instance of a workflow; full conversation history |
| Notes + Tools | Structured data collection; the persona's system prompt is the form definition, the note is the filled form |
| EventBus + WebSocket | Real-time notifications for assignment, stage transitions, new messages |
Design Constraints for Current Development
These invariants keep the workflow path open without building it prematurely:
- Channels: Don't assume single-owner. If touching channel queries, keep
room for
team_id,type(beyonddirect), and multi-participant access patterns alongsideuser_id. - Notes: User-scoped with
[[wikilink]]bi-directional linking (v0.17.3). Thenote_linksjunction table tracks directed edges between notes, withtarget_note_idnullable for dangling links (unresolved references). Links are extracted from content on save via regex, resolved by title match, and re-resolved when new notes are created. The graph endpoint returns all nodes, edges, and unresolved links for Canvas-based force-directed visualization. Future channel-scoped notes (attached to a conversation) need achannel_idFK option.source_message_idenables jump-to-source provenance from notes back to the originating chat message. - Personas: Already team-scoped. Don't couple to "user picks from dropdown" — a workflow stage references a persona programmatically.
- Tool ExecutionContext: Already carries
UserID+ChannelID. Will needTeamIDandWorkflowID— the struct is easily extended. - Auth: mTLS anonymous users need identity to participate in channels.
Lightest version: a
participantstable that can reference auser_idor an opaque session identifier (cert fingerprint). Don't assume every channel participant has a row inusers.
Package Structure
server/
├── main.go # Wiring: stores → handlers → routes
├── config/config.go # Env-based configuration
├── database/
│ ├── database.go # Connection management (PG + SQLite)
│ ├── migrate.go # Auto-migration on startup
│ └── migrations/
│ ├── 001_v016_schema.sql # Consolidated schema (PG)
│ └── 002_v017_persona_kb.sql
├── store/
│ ├── interfaces.go # Store interfaces + shared types
│ ├── postgres/ # Postgres implementations (22 stores)
│ │ ├── stores.go # NewStores() constructor
│ │ ├── provider.go # ProviderStore
│ │ ├── catalog.go # CatalogStore
│ │ ├── persona.go # PersonaStore
│ │ ├── channel.go # ChannelStore
│ │ ├── message.go # MessageStore
│ │ ├── user.go # UserStore
│ │ ├── team.go # TeamStore
│ │ ├── note.go # NoteStore
│ │ ├── knowledge.go # KnowledgeStore
│ │ ├── memory.go # MemoryStore (CRUD + recall)
│ │ ├── memory_hybrid.go # RecallHybrid (pgvector cosine)
│ │ └── ...
│ └── sqlite/ # SQLite implementations (22 stores)
│ ├── stores.go # NewStores() constructor
│ └── ... # Mirror of postgres/ with dialect adaptations
├── models/models.go # Shared domain types
├── capabilities/
│ ├── intrinsic.go # Heuristic detection + three-tier resolution (catalog → heuristic → admin override)
│ └── resolver.go # ModelsForUser() unified resolver
├── compaction/ # Conversation summarization engine
├── crypto/ # AES-256-GCM API key encryption
├── events/ # EventBus + WebSocket hub
├── extraction/ # Document text extraction pipeline
├── health/ # Provider health tracking (v0.22.0), tool health + auto-disable (v0.22.4)
├── routing/ # Policy-based request routing (v0.22.2)
│ ├── types.go # Policy, Candidate, Context, Decision types
│ ├── evaluator.go # Policy evaluation engine (4 policy types)
│ ├── evaluator_test.go # 12 tests
│ ├── fallback.go # RunWithFallback candidate dispatcher
│ └── convert.go # DB model → routing type conversion
│ ├── accumulator.go # In-memory counters, 60s flush, rate limits, tool health, auto-disable
│ └── accumulator_test.go # Concurrent recording, flush isolation, threshold tests
├── memory/ # Long-term memory extraction + scanning
│ ├── extractor.go # Conversation analysis → fact extraction
│ └── scanner.go # Background job: find channels needing extraction
├── mentions/ # @mention parsing for multi-model routing
│ └── parser.go # Parse() extracts + resolves @mentions against roster
├── notifications/ # Notification service + email transport
│ ├── service.go # Notify/NotifyMany, preference resolution, dispatch
│ ├── email.go # SMTP transport (TLS/STARTTLS, multipart MIME)
│ └── templates.go # HTML + plaintext email templates
├── handlers/ # HTTP handlers (Gin)
│ ├── auth.go # Login, register, refresh, logout
│ ├── admin.go # User/config/model management
│ ├── channels.go # Channel CRUD
│ ├── messages.go # Message CRUD + forking
│ ├── completion.go # Chat completions (SSE streaming)
│ ├── stream_loop.go # Shared streaming + tool execution loop
│ ├── capabilities.go # ModelHandler: model list + health enrichment + ResolveModelCaps (v0.22.3)
│ ├── health_admin.go # Provider health + capability override admin endpoints
│ ├── export.go # Markdown → PDF/DOCX conversion via pandoc (v0.22.4)
│ ├── routing_admin.go # Routing policy CRUD + dry-run test (v0.22.2)
│ ├── presets.go # Persona CRUD (all scopes)
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
│ ├── teams.go # Team management
│ ├── notes.go # Notes CRUD + search + graph + backlinks
│ ├── knowledge.go # Knowledge base management
│ ├── memory.go # Memory CRUD + review (user + admin)
│ ├── memory_inject.go # BuildMemoryHint() for completion injection
│ ├── notifications.go # Notification CRUD + preference endpoints
│ ├── admin_email.go # Admin SMTP test email endpoint
│ ├── channel_models.go # Channel model roster CRUD (multi-model)
│ ├── model_prefs.go # User model visibility preferences
│ └── ...
├── notelinks/ # Wikilink extraction (regex → NoteLink structs)
├── knowledge/ # KB chunking, embedding, search
├── providers/ # LLM provider adapters
│ ├── provider.go # Provider interface + ExtraBody/mergeExtraBody
│ ├── registry.go # Data-driven type registry with metadata (v0.22.1)
│ ├── profile.go # Profile schemas per provider type (v0.22.1)
│ ├── hooks.go # PreRequest/PostStreamEvent transforms (v0.22.1)
│ ├── hooks_test.go # 17 tests: all hooks, schemas, merge, nil safety
│ ├── anthropic.go # Anthropic Messages API (+ extended thinking)
│ ├── openai.go # OpenAI-compatible (+ ExtraBody merge)
│ ├── openrouter.go
│ └── venice.go
├── middleware/ # Auth, admin, CORS, rate limiting
├── storage/ # Blob storage (PVC + S3)
└── tools/ # Built-in tool definitions
Store Layer Pattern
Every store follows the same pattern:
// Interface in store/interfaces.go
type FooStore interface {
Create(ctx context.Context, f *models.Foo) error
GetByID(ctx context.Context, id string) (*models.Foo, error)
Update(ctx context.Context, id string, patch models.FooPatch) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, opts ListOptions) ([]models.Foo, int, error)
}
// Implementation in store/postgres/foo.go
type fooStore struct{ db *sql.DB }
func (s *fooStore) Create(ctx context.Context, f *models.Foo) error { ... }
Handlers receive store.Stores (a bundle of all store interfaces):
type AdminHandler struct { stores store.Stores }
func (h *AdminHandler) CreateUser(c *gin.Context) {
// ...
err := h.stores.Users.Create(c.Request.Context(), user)
}
Capabilities Resolution Chain
When the system needs to know what a model can do (vision? tools? thinking?):
1. model_catalog DB (exact match: model_id + provider_config_id)
↓ miss
2. model_catalog DB (any provider: same model_id)
↓ miss
3. Heuristic inference (name-based: "gpt-4-vision" → vision=true)
The capabilities.ModelsForUser() function combines catalog entries, team personas, and user preferences into a single unified model list for the frontend.
Scope / Ownership Model
scope='global' → owner_id=NULL → Admin-managed, visible to all
scope='team' → owner_id=team.id → Team-admin-managed, visible to team
scope='personal' → owner_id=user.id → User-managed, visible to owner
Used by: provider_configs, personas, model_catalog (visibility column adds enabled/disabled/team states on top), projects.
Projects (v0.19.0)
Projects are organizational containers that group channels, knowledge bases, and notes. They follow the same scope model as other resources.
KB Resolution Chain:
Persona KBs → Project KBs → Channel KBs → Personal KBs
When a channel belongs to a project, all KBs bound to that project are
automatically included in both the system prompt hint (BuildKBHint) and
tool-time search (kbsearch). This means adding a KB to a project makes it
available to every channel in that project without per-channel configuration.
Channel Association: Channels have an optional project_id FK.
The project_channels junction table maintains ordered membership with a
UNIQUE constraint on channel_id (a channel can only belong to one project).
AddChannel performs an atomic move: single transaction deletes from the
old project, inserts into the new one, and updates the denormalized FK.
Store Pattern: ProjectStore interface with Postgres and SQLite
implementations following the same conventions as other stores (see
Store Layer Pattern above). Access checks via UserCanAccess enforce
owner-or-team-member visibility.
Schema Migration
Migrations are handled by the backend on startup (no separate migration job):
- Creates
schema_migrationstable if absent - Checks which migration files have been applied
- Applies any new
.sqlfiles in order
Postgres uses consolidated schemas (001_v016_schema.sql + incremental). SQLite uses application-generated UUIDs and datetime() instead of gen_random_uuid() and now(). Both drivers share the same migration tracking table.
Notifications (v0.20.0)
Centralized notification infrastructure with real-time WebSocket delivery and optional email transport.
Service Pattern: All notification sources go through notifications.Service,
never insert directly. Notify() persists to store, checks user preferences,
pushes via WebSocket (if online), and dispatches email (if configured and
opted-in). NotifyMany() fans out to multiple users (best-effort).
Preference Resolution:
Specific type pref → User wildcard '*' pref → System default (in_app=true, email=false)
Email Transport: Optional SMTP via EmailTransport. Supports implicit TLS
(port 465) and STARTTLS (port 587). Multipart MIME (HTML + plaintext).
Templates rendered via Go html/template with instance branding. Async
delivery via goroutine (failures logged, never block notification creation).
Admin configures SMTP in platform settings; users toggle per-type delivery
in Settings → Notifications.
Cleanup: Background goroutine deletes notifications older than retention period (default 90 days, configurable via admin settings).
@mention Routing + Multi-model (v0.20.0)
Channels support multiple AI models. The channel_models table (schema 001)
holds the roster; mentions.Parse() extracts @mentions from message content
and resolves against display names (case-insensitive, longest-match-first).
Completion Fan-out: When mentions resolve to channel models, the completion handler fires sequentially for each target, producing one assistant message per model. Without @mention, the default channel model responds (backward compatible). Frontend renders model attribution labels on multi-model responses.
Autocomplete: CM6 mentionCompletion extension triggers on @ in the
chat input, showing a dropdown of channel model display names.
Frontend Architecture
Vanilla JavaScript, no framework. The frontend ships as static files served by nginx. The only build step is the CM6 editor bundle, compiled at Docker build time via esbuild (IIFE output, no runtime bundler).
File Structure
src/
├── index.html # Single-page app shell
├── sw.js # Service worker (offline cache)
├── manifest.json # PWA manifest
├── css/
│ ├── styles.css # Core styles (CSS variables, light/dark themes)
│ └── memory.css # Memory settings + admin review panel styles
├── js/
│ ├── api.js # HTTP client with token refresh
│ ├── app.js # Application state machine, startup, routing
│ ├── chat.js # Chat input abstraction, message send/receive
│ ├── events.js # Labeled event bus + WebSocket bridge
│ ├── debug.js # Debug panel, diagnostics, state export
│ ├── ui-core.js # DOM rendering, sidebar, modals, panels
│ ├── ui-settings.js # Settings tabs, theme, appearance, teams
│ ├── ui-admin.js # Admin panel rendering
│ ├── admin-handlers.js # Admin CRUD operations + extension editors
│ ├── settings-handlers.js # User settings CRUD, command palette
│ ├── tokens.js # Input token counting + context budget
│ ├── attachments.js # File upload, paste-to-file, lightbox
│ ├── notes.js # Notes panel CRUD + graph + daily notes
│ ├── note-graph.js # Canvas force-directed graph visualization
│ ├── projects-ui.js # Project sidebar, context menu, drag-and-drop
│ ├── knowledge.js # Knowledge base UI
│ ├── memory-ui.js # Memory settings tab + admin review panel
│ └── __tests__/ # Node.js test suite (node --test)
├── editor/ # CM6 source (compiled at build time)
│ ├── package.json # CM6 + language mode dependencies
│ ├── package-lock.json # Lockfile for npm ci
│ ├── build.mjs # esbuild script → IIFE bundle
│ ├── index.mjs # Bundle entrypoint (window.CM)
│ ├── chat-input.mjs # Chat input factory (markdown, code blocks)
│ ├── code-editor.mjs # Code editor factory (syntax, keybindings)
│ ├── note-editor.mjs # Note editor factory (markdown + wikilinks)
│ ├── wikilink.mjs # CM6 wikilink extension (parse, decorate, autocomplete)
│ └── theme.mjs # Switchboard + chat input + note editor themes
└── vendor/ # Vendored libraries (local + CDN fallback)
├── marked/ # Markdown renderer
├── purify/ # DOMPurify (XSS protection)
├── mermaid/ # Diagram renderer
├── katex/ # Math renderer
└── codemirror/ # CM6 bundle (built by Docker)
└── codemirror.bundle.js # ~295KB min, ~90KB gzip
CM6 Integration
The CodeMirror 6 bundle provides three factory functions exposed on window.CM:
CM.chatInput(target, opts) — Markdown-mode editor for the chat input area. Features: auto-growing height, Enter=send / Shift+Enter=newline, WYSIWYG fenced code block decorations (visual container with monospace font and accent border), inline code shortcut (Ctrl/Cmd+E), spell check. Always uses standard keybindings.
CM.codeEditor(target, opts) — Full-featured code editor for the admin extension panel (JSON manifests, JavaScript scripts). Features: line numbers, bracket matching, search/replace, fold gutter, 10 bundled language modes. Supports Vim and Emacs keybindings via user preference (reconfigurable at runtime via compartments).
CM.noteEditor(target, opts) — Rich markdown editor for the notes panel. Features: heading size rendering (h1–h3), blockquote styling, fenced code block decorations, [[wikilink]] autocomplete (triggered by [[), wikilink chip rendering (clickable, styled by link/transclusion type), search/replace. onLink callback for navigating to linked notes; linkCompleter async callback for autocomplete results.
Both factories return a clean API: getValue(), setValue(), focus(), destroy(). The ChatInput abstraction in chat.js wraps the CM6 instance with a textarea fallback — all callsites use ChatInput.getValue() etc., never raw DOM access.
Graceful degradation: Every integration point checks window.CM before using CM6. If the bundle fails to load (air-gapped without the build step, CDN issues), the app falls back to native <textarea> with zero breakage.
Theme System
CSS variables define the color palette in :root (dark, default) and [data-theme="light"] (light override). All UI components — including CM6 editors — reference these variables, so theme switches propagate instantly without editor reconfiguration.
The appearance settings offer three modes: Light, Dark, System. System mode listens to prefers-color-scheme and re-evaluates on OS theme change. Theme changes emit theme.changed on the EventBus; CM6 code editors toggle the oneDark syntax theme via compartment reconfiguration.
Communication Pattern
app.js calls API.* methods, updates state, then calls UI.* methods to render. events.js handles real-time updates via WebSocket. No framework, no virtual DOM, no reactive bindings.
Security Model
- Auth: JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable)
- Admin Bootstrap:
SWITCHBOARD_ADMIN_USERNAME/PASSWORDenv vars create/update admin on every startup (K8s secret pattern) - API Key Storage: Provider API keys encrypted with AES-256-GCM. Two-tier: org keys use
ENCRYPTION_KEYenv var, personal BYOK keys use per-user encryption keys (admins cannot recover) - Policies: Boolean flags in
global_settingstable control registration, BYOK, team providers, etc. - Audit: All admin operations logged to
audit_logwith actor, action, resource type/ID, and diff - Banner: Environment classification banner configurable via admin settings (text, color, position)
- CORS: Configurable allowed origins via env var
- No sensitive terminology: Banner system avoids classification-related terms for security compliance
File Storage
Blob storage for attachments, extracted text, and (future) knowledge base documents.
Interface: storage.ObjectStore — Put, Get, Delete, DeletePrefix, Exists, Healthy, Stats, Backend. All handlers use the interface; backend is selected at startup.
Backends:
| Backend | Config | Use Case |
|---|---|---|
| PVC | STORAGE_BACKEND=pvc + STORAGE_PATH |
Single-node, dev, docker-compose. Local filesystem with atomic writes (temp+rename). |
| S3 | STORAGE_BACKEND=s3 + S3_ENDPOINT, S3_BUCKET, credentials |
Multi-node production. MinIO, Ceph RGW, AWS S3. Uses minio-go v7. |
| Auto | STORAGE_BACKEND= (empty) |
Tries PVC at STORAGE_PATH; disables if not writable. |
Key layout: attachments/{channel_id}/{attachment_id}_{filename}. S3 prefix (S3_PREFIX) prepended for shared buckets.
PVC always mounted: Even with S3 backend, the PVC mount at STORAGE_PATH is needed for the extraction queue's local scratch directory (processing/{id}/status.json). With S3, the PVC can be small (1Gi) — only transient coordination state, not blobs.
Admin panel: Settings → Storage tab shows backend type, health status, file count, total size, endpoint/bucket (S3) or path (PVC), orphan detection and cleanup.
Backward Compatibility
v0.9 maintains backward-compatible API routes:
| Old Route | New Handler | Notes |
|---|---|---|
/api/v1/presets |
PersonaHandler | Returns both personas and presets keys |
/api/v1/api-configs |
ProviderConfigHandler | Unchanged route, new implementation |
/api/v1/models |
ModelHandler.ListEnabledModels | Alias for /models/enabled |
JSON field rename: api_config_id → provider_config_id in channel and completion request/response bodies. Frontend updated to match.