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