Changeset 0.12.0 (#63)

This commit is contained in:
2026-02-25 21:38:49 +00:00
parent c9d8e9457e
commit 88216ec4cb
59 changed files with 13115 additions and 139 deletions

View File

@@ -1,6 +1,6 @@
# .gitea/workflows/ci.yaml
# ============================================
# Chat Switchboard - CI/CD Pipeline (v0.9.4)
# Chat Switchboard - CI/CD Pipeline (v0.12.0)
# ============================================
# Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (docker-compose use).
@@ -31,6 +31,9 @@
# Required Gitea Variables:
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
# SEED_USERS (optional) — CSV: "user:pass:role,..." for dev/test seed accounts
# STORAGE_CLASS — StorageClass for PVC (e.g. "cephfs", required for file storage)
# STORAGE_SIZE — PVC capacity (e.g. "10Gi", default: "10Gi")
# STORAGE_BACKEND — "pvc" or "s3" (default: "pvc")
#
# Required Gitea Secrets:
# POSTGRES_USER, POSTGRES_PASSWORD
@@ -39,6 +42,7 @@
# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32)
# VENICE_API_KEY (live provider integration tests — optional, tests skip if missing)
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
# S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY (only when STORAGE_BACKEND=s3)
#
# Global Variables (Gitea org-level):
# CERT_ISSUER_PROD
@@ -450,6 +454,26 @@ jobs:
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Sync S3 secrets
if: vars.STORAGE_BACKEND == 's3'
env:
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }}
S3_PREFIX: ${{ vars.S3_PREFIX }}
run: |
kubectl create secret generic switchboard-s3 \
--namespace=${NAMESPACE} \
--from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \
--from-literal=S3_BUCKET="${S3_BUCKET}" \
--from-literal=S3_ACCESS_KEY="${S3_ACCESS_KEY}" \
--from-literal=S3_SECRET_KEY="${S3_SECRET_KEY}" \
--from-literal=S3_REGION="${S3_REGION}" \
--from-literal=S3_PREFIX="${S3_PREFIX}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Render and apply manifests
env:
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}
@@ -468,12 +492,31 @@ jobs:
BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }}
BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }}
BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }}
# Storage (v0.12.0+)
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
EXTRACTION_CONCURRENCY: ${{ vars.EXTRACTION_CONCURRENCY || '3' }}
run: |
# Render PVC first (must exist before backend references it)
if [[ -n "${STORAGE_CLASS}" ]]; then
envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml
fi
envsubst < k8s/backend.yaml > /tmp/backend.yaml
envsubst < k8s/frontend.yaml > /tmp/frontend.yaml
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━"
# Apply PVC (idempotent — won't recreate if exists)
if [[ -n "${STORAGE_CLASS}" ]]; then
kubectl apply -f /tmp/storage-pvc.yaml
echo " ✓ Storage PVC (class: ${STORAGE_CLASS}, size: ${STORAGE_SIZE})"
else
echo " ⚠ STORAGE_CLASS not set — file storage disabled"
fi
kubectl apply -f /tmp/backend.yaml
kubectl apply -f /tmp/frontend.yaml
kubectl apply -f /tmp/ingress.yaml
@@ -536,6 +579,15 @@ jobs:
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY
if [[ -n "${{ vars.STORAGE_CLASS }}" ]]; then
if [[ "${{ vars.STORAGE_BACKEND }}" == "s3" ]]; then
echo "| **Storage** | S3 (bucket: ${{ vars.S3_BUCKET }}, PVC scratch: ${{ vars.STORAGE_CLASS }}) |" >> $GITHUB_STEP_SUMMARY
else
echo "| **Storage** | PVC (class: ${{ vars.STORAGE_CLASS }}, size: ${{ vars.STORAGE_SIZE || '10Gi' }}) |" >> $GITHUB_STEP_SUMMARY
fi
else
echo "| **Storage** | Disabled (STORAGE_CLASS not set) |" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
fi

View File

@@ -234,13 +234,33 @@ Communication: `app.js` calls `API.*` methods, updates state, then calls `UI.*`
- **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 stored in `api_key_enc` column (TODO: at-rest encryption)
- **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:

View File

@@ -2,6 +2,42 @@
All notable changes to Chat Switchboard.
## [0.12.0] — 2026-02-25
### Added
- **File handling and vision support.** Upload images, PDFs, and documents
into chat via 📎 button, drag-and-drop, or paste. Multimodal message
assembly injects base64 images for vision-capable models and extracted
text for documents. Staged attachment strip shows upload progress and
extraction status. Auth-aware blob rendering with Bearer tokens. Image
lightbox viewer. Vision capability hints on image attachments.
- **Storage backend abstraction.** `ObjectStore` interface with two
implementations: PVC (local filesystem) for single-node/dev and S3
(minio-go) for multi-node production. S3 backend works with any
S3-compatible API: MinIO, Ceph RGW, AWS S3. Auto-detection when
`STORAGE_BACKEND` is not set (tries PVC, disables if not writable).
Both backends share identical semantics — all handlers, attachment
CRUD, multimodal assembly, and orphan cleanup work regardless of
backend.
- **S3 configuration.** `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`,
`S3_SECRET_KEY`, `S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` env
vars. Endpoint auto-detects SSL from scheme. Path-style URLs default
on (required for MinIO/Ceph). Optional key prefix for shared buckets.
CI pipeline syncs S3 secrets to K8s when `STORAGE_BACKEND=s3`.
- **Text extraction pipeline.** PDF, DOCX, XLSX, PPTX, ODT, RTF text
extraction via filesystem-based queue. Inline and sidecar modes.
Crash recovery for items stuck in processing state.
- **Admin storage panel.** Backend health, file count, total size,
orphan detection and cleanup. Shows PVC path or S3 endpoint/bucket
depending on active backend. Extraction queue status.
- **Vault CLI commands.** `switchboard vault rekey` re-encrypts all
provider API keys when rotating the encryption key.
`switchboard vault status` shows encryption health. Admin UI
encryption status indicator in Settings tab.
- **Per-chat model persistence.** Server-side `channels.settings`
JSONB field stores `last_selector_id`. Roams across devices with
localStorage write-through cache as fallback.
## [0.11.0] — 2026-02-25
### Added

1304
DESIGN-0.12.0.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,15 @@ All configuration via environment variables. See `server/.env.example` for the f
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
| `ENCRYPTION_KEY` | ` ` | AES key for API key encryption (required if encrypted keys exist) |
| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` |
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. Auto-detects PVC if not set. |
| `STORAGE_PATH` | `/data/storage` | PVC mount point (also scratch dir for S3 extraction) |
| `S3_ENDPOINT` | ` ` | S3 endpoint (e.g. `http://minio:9000`, required for S3) |
| `S3_BUCKET` | ` ` | S3 bucket name (must exist, required for S3) |
| `S3_ACCESS_KEY` | ` ` | S3 access key ID |
| `S3_SECRET_KEY` | ` ` | S3 secret access key |
| `S3_REGION` | `us-east-1` | S3 region |
| `S3_PREFIX` | ` ` | Optional key prefix within bucket |
| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs (required for MinIO, Ceph) |
## Deployment

View File

@@ -43,19 +43,23 @@ v0.14.0 Knowledge Bases (embedding role + file storage + pgvector)
v0.15.0 Compaction (utility role + background job)
v0.16.0 @mention Routing + Multi-model
v0.15.1 Context Recall Tools (attachment_recall, conversation_search)
v0.17.0 Extension Surfaces + Modes
v0.16.0 Conversation Memory (cross-conversation user facts)
v0.18.0 Smart Routing (policy on model roles)
v0.17.0 @mention Routing + Multi-model
v0.19.0 Multi-Participant Channels + Presence
v0.18.0 Extension Surfaces + Modes
v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC
v0.19.0 Smart Routing (policy on model roles)
v0.20.0 Multi-Participant Channels + Presence
v0.21.0 Auth Strategy (mTLS/OIDC) + Full RBAC
┌───────┴──────────────┐
│ │
v0.21.0 Workflow v0.22.0 Tasks /
v0.22.0 Workflow v0.23.0 Tasks /
Engine Autonomous Agents
(team-owned, (service channels,
staged processes, scheduler, unattended
@@ -254,7 +258,7 @@ after backfill. If `ENCRYPTION_KEY` is not set, migration refuses to run
derivation. No additional UX. On login, derive PDK → unwrap UEK → cache
in session. User never knows the vault exists.
**mTLS / OIDC** (v0.20.0) — authentication is external (cert/token).
**mTLS / OIDC** (v0.21.0) — authentication is external (cert/token).
Vault passphrase is conditionally required:
```
@@ -336,7 +340,7 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err
- [x] UEK eviction on logout (memory zeroing)
- [ ] UEK re-wrap on password change _(deferred — Settings handler)_
- [ ] UEK destruction on admin password reset with confirmation _(deferred)_
- [ ] Vault passphrase prompt for mTLS/OIDC _(deferred — v0.20.0 dependency)_
- [ ] Vault passphrase prompt for mTLS/OIDC _(deferred — v0.21.0 dependency)_
**Provider key lifecycle**
- [x] Encrypt on provider create/update (all 6 write paths: admin global, personal, team)
@@ -344,8 +348,9 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err
- [x] `ErrVaultLocked` handling (prompt user to unlock)
**Admin tooling**
- [ ] `switchboard vault rekey` CLI command _(deferred)_
- [ ] Admin UI: encryption status indicator _(deferred)_
- [x] `switchboard vault rekey` CLI command _(v0.12.0)_
- [x] `switchboard vault status` CLI command _(v0.12.0)_
- [x] Admin UI: encryption status indicator _(v0.12.0 — Settings tab)_
- [x] Admin UI: password reset warning (v0.10.0 — vault destruction dialog)
**CI/CD**
@@ -579,17 +584,21 @@ The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec.
File input into chat — table stakes for serious use.
**Storage Backend**
- [ ] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API)
- [ ] Local PVC fallback (single-node / dev)
- [ ] Admin config: backend selection, endpoint, credentials, size limits
- [ ] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0)
- [x] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API)
- [x] Local PVC backend (single-node / dev)
- [x] Admin config: backend health status, orphan cleanup panel
- [x] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0)
**Chat Integration**
- [ ] `attachments` table (metadata in PG, blobs in object store)
- [ ] Image/file upload in chat messages
- [ ] Multimodal message assembly for vision-capable models
- [ ] Text extraction on upload (PDF, DOCX, TXT → tsvector)
- [ ] Paste-to-upload (clipboard image support)
- [x] `attachments` table (metadata in PG, blobs in object store)
- [x] Image/file upload in chat messages (📎 button, drag-and-drop)
- [x] Multimodal message assembly for vision-capable models
- [x] Text extraction pipeline (PDF, DOCX, TXT — inline + sidecar modes)
- [x] Paste-to-upload (clipboard images + large text auto-attach)
- [x] Staged attachment strip with extraction status polling
- [x] Auth-aware rendering (blob URLs with Bearer token)
- [x] Image lightbox viewer
- [x] Vision capability hints on image attachments
- [ ] Document generation output storage (extension → attachment → download)
---
@@ -633,7 +642,56 @@ Depends on: utility model role (v0.10.0).
---
## v0.16.0 — @mention Routing + Multi-model
## v0.15.1 — Context Recall Tools
Depends on: file handling (v0.12.0), tool framework (v0.11.0).
Attachments are injected into context once (at send time) and are not
replayed on subsequent turns — the model's own response acts as a natural
summary. After compaction (v0.15.0) or in long conversations, the model
may need to re-read source material. These tools bridge that gap.
**`attachment_recall` tool**
- [ ] `attachment_recall(attachment_id)` — re-reads attachment content from storage
- [ ] Images: re-inject as base64 content part (vision gating still applies)
- [ ] Documents: return extracted text
- [ ] Security: scoped to current channel's attachments only
- [ ] Extend `ExecutionContext` with optional storage backend reference
- [ ] Tool definition includes attachment list hint so model knows what's available
**`conversation_search` tool**
- [ ] Keyword search over compacted/archived messages in current channel
- [ ] Returns matching message excerpts with timestamps
- [ ] Useful post-compaction when summary dropped details the user asks about
**Token estimator improvements**
- [ ] Include attachment token estimates in context counter
- [ ] Images: use provider-specific sizing rules (e.g. tile-based for OpenAI)
- [ ] Documents: count extracted text tokens
- [ ] Staged attachments reflected in input token counter before send
---
## v0.16.0 — Conversation Memory
Depends on: compaction (v0.15.0), knowledge bases (v0.14.0).
Long-term memory across conversations. Unlike KB (static documents) or
compaction (within-conversation), this captures user-level facts and
preferences that persist across channels.
- [ ] `user_memory` table: `user_id`, `key`, `value`, `source_channel_id`, `confidence`, `created_at`, `updated_at`
- [ ] `memory_save` tool: LLM can explicitly store a fact ("user prefers Python", "project deadline is March 15")
- [ ] `memory_recall` tool: LLM queries stored facts relevant to current context
- [ ] Auto-extraction: background job identifies memorable facts from conversations (opt-in)
- [ ] Memory injection: relevant facts injected into system prompt at completion time
- [ ] User controls: view, edit, delete memories in Settings
- [ ] Admin controls: enable/disable, retention policies, per-team scoping
- [ ] Privacy: memories never cross team boundaries
---
## v0.17.0 — @mention Routing + Multi-model
The channel schema already supports multiple models. This adds routing.
@@ -645,7 +703,7 @@ The channel schema already supports multiple models. This adds routing.
---
## v0.17.0 — Extension Surfaces + Modes
## v0.18.0 — Extension Surfaces + Modes
Depends on: extension foundation (v0.11.0).
See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
@@ -659,7 +717,7 @@ See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
---
## v0.18.0 — Smart Routing
## v0.19.0 — Smart Routing
Depends on: model roles (v0.10.0), usage tracking (v0.10.0).
Rules-based routing engine — policy, not ML.
@@ -672,13 +730,13 @@ Rules-based routing engine — policy, not ML.
---
## v0.19.0 — Multi-Participant Channels + Presence
## v0.20.0 — Multi-Participant Channels + Presence
The channel foundation for workflows and real-time collaboration. Today
channels are single-user direct chats. This release makes channels a
shared space that multiple actors can inhabit.
Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exists).
Depends on: extension surfaces (v0.18.0), WebSocket infrastructure (already exists).
**Channel Participants**
- [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at`
@@ -699,7 +757,7 @@ Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exis
---
## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
## v0.21.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
Enterprise auth modes on top of the teams foundation.
Depends on: vault passphrase support from v0.9.3 (conditional unlock for
@@ -723,7 +781,7 @@ personal providers under external auth).
---
## v0.21.0 — Workflow Engine
## v0.22.0 — Workflow Engine
Team-owned, stage-based process execution. The channel is the runtime,
personas drive each stage, and the existing tool/notes infrastructure
@@ -731,7 +789,7 @@ handles structured data collection. See [ARCHITECTURE.md — Workflow
Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for
the conceptual model.
Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0).
Depends on: multi-participant channels (v0.20.0), anonymous identity (v0.21.0).
**Workflow Definitions**
- [ ] `workflows` table: `team_id` (owner), `name`, `description`, `is_active`, `entry_mode` (public_link, team_internal, api)
@@ -771,10 +829,10 @@ Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0).
---
## v0.22.0 — Tasks / Autonomous Agents
## v0.23.0 — Tasks / Autonomous Agents
Unattended execution — workflows without a human in the loop.
Depends on: workflow engine (v0.21.0).
Depends on: workflow engine (v0.22.0).
- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation)
- [ ] `task_create` tool (AI can spawn sub-workflows)
@@ -812,13 +870,7 @@ based on need.
- Backup/restore CronJob manifests
**UX / Multi-Seat**
- Per-chat model/preset persistence (server-side). Currently a localStorage
bandaid (v0.10.2) that doesn't roam across devices. Proper fix: store
`last_selector_id` in `channels.settings` JSONB on each send. On chat
selection, resolve from `channel.settings.last_selector_id` → match against
available models/presets → fall back to `channel.model` → global default.
Eliminates device-local state entirely. Needs `updateChannel` PATCH on
completion success (single extra column merge, no migration).
- ~~Per-chat model/preset persistence (server-side)~~ ✅ _(v0.12.0 — `channels.settings.last_selector_id` JSONB merge, localStorage write-through cache)_
**Platform**
- Rate limiting per user/team/tier (token budgets)

View File

@@ -1 +1 @@
0.11.0
0.12.0

668
admin.go Normal file
View File

@@ -0,0 +1,668 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type AdminHandler struct {
stores store.Stores
vault *crypto.KeyResolver
uekCache *crypto.UEKCache
}
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler {
return &AdminHandler{stores: s, vault: vault, uekCache: uekCache}
}
// ── User Management ─────────────────────────
func (h *AdminHandler) ListUsers(c *gin.Context) {
opts := store.DefaultListOptions()
// Accept both limit/offset and page/per_page conventions
if limit, _ := strconv.Atoi(c.Query("limit")); limit > 0 {
opts.Limit = limit
}
if offset, _ := strconv.Atoi(c.Query("offset")); offset > 0 {
opts.Offset = offset
}
if perPage, _ := strconv.Atoi(c.Query("per_page")); perPage > 0 {
opts.Limit = perPage
}
if page, _ := strconv.Atoi(c.Query("page")); page > 1 {
opts.Offset = (page - 1) * opts.Limit
}
users, total, err := h.stores.Users.List(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
return
}
c.JSON(http.StatusOK, gin.H{"users": users, "total": total})
}
func (h *AdminHandler) CreateUser(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
role := models.UserRoleUser
if req.Role == models.UserRoleAdmin {
role = models.UserRoleAdmin
}
user := &models.User{
Username: strings.ToLower(req.Username),
Email: strings.ToLower(req.Email),
PasswordHash: string(hash),
Role: role,
IsActive: true,
}
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
h.auditLog(c, "user.create", "user", user.ID, nil)
c.JSON(http.StatusCreated, user)
}
func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
id := c.Param("id")
var req struct {
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
return
}
h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role})
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
}
func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
id := c.Param("id")
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Users.SetActive(c.Request.Context(), id, req.IsActive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"})
return
}
h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive})
c.JSON(http.StatusOK, gin.H{"message": "user status updated"})
}
func (h *AdminHandler) ResetPassword(c *gin.Context) {
id := c.Param("id")
var req struct {
Password string `json:"password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"password_hash": string(hash)}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
return
}
// Destroy vault — old UEK is unrecoverable with new password.
// A fresh vault will be initialized on the user's next login.
h.destroyVault(c, id)
h.auditLog(c, "user.password_reset", "user", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
}
// destroyVault nullifies a user's encrypted UEK and deletes their personal
// provider keys. Called when an admin resets a user's password — the old
// UEK cannot be recovered without the old password.
func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
if h.uekCache == nil {
return
}
// Null out vault columns — user gets a fresh vault on next login
_, err := database.DB.Exec(`
UPDATE users
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
WHERE id = $1
`, userID)
if err != nil {
log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err)
}
// Evict from session cache (if user is currently logged in)
h.uekCache.Evict(userID)
// Delete personal provider configs — their keys are undecryptable now
result, err := database.DB.Exec(`
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
`, userID)
if err != nil {
log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err)
} else if rows, _ := result.RowsAffected(); rows > 0 {
log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID)
}
h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{
"reason": "admin_password_reset",
})
}
func (h *AdminHandler) DeleteUser(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
return
}
h.auditLog(c, "user.delete", "user", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
// ── Global Settings ─────────────────────────
func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
settings, err := h.stores.GlobalConfig.GetAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
// Also include policies
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"settings": settings, "policies": policies})
}
func (h *AdminHandler) GetGlobalSetting(c *gin.Context) {
key := c.Param("key")
val, err := h.stores.GlobalConfig.Get(c.Request.Context(), key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
return
}
c.JSON(http.StatusOK, gin.H{"key": key, "value": val})
}
func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) {
key := c.Param("key")
userID, _ := c.Get("user_id")
uid := userID.(string)
var req struct {
Value json.RawMessage `json:"value"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Determine if this is a policy (string value) or a global config (JSON value)
var strVal string
if err := json.Unmarshal(req.Value, &strVal); err == nil {
// String value → try as policy first
if _, ok := models.PolicyDefaults[key]; ok {
if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"})
return
}
h.auditLog(c, "policy.update", "policy", key, gin.H{"value": strVal})
c.JSON(http.StatusOK, gin.H{"message": "policy updated"})
return
}
}
// JSON value → global config
var jsonVal models.JSONMap
json.Unmarshal(req.Value, &jsonVal)
if err := h.stores.GlobalConfig.Set(c.Request.Context(), key, jsonVal, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"})
return
}
h.auditLog(c, "settings.update", "global_settings", key, nil)
c.JSON(http.StatusOK, gin.H{"message": "setting updated"})
}
func (h *AdminHandler) PublicSettings(c *gin.Context) {
// Banner config, branding, etc. — safe subset for non-admin users
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
// Only tell the user whether admin prompt exists — don't expose content
hasAdminPrompt := false
if content, ok := systemPrompt["content"].(string); ok && content != "" {
hasAdminPrompt = true
}
c.JSON(http.StatusOK, gin.H{
"banner": banner,
"branding": branding,
"has_admin_prompt": hasAdminPrompt,
"storage_configured": storageConfigured,
"policies": gin.H{
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],
"allow_user_personas": policies["allow_user_personas"],
},
})
}
// ── Vault Status ────────────────────────────
func (h *AdminHandler) VaultStatus(c *gin.Context) {
hasKey := h.vault != nil && h.vault.HasEnvKey()
keyStr := ""
if hasKey {
keyStr = "set" // non-empty signals to VaultStatus that key is configured
}
status, err := crypto.VaultStatus(database.DB, keyStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"})
return
}
c.JSON(http.StatusOK, status)
}
// ── Provider Configs (Global) ───────────────
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
// Redact API keys but expose has_key flag for UI
type configWithKey struct {
models.ProviderConfig
HasKey bool `json:"has_key"`
}
out := make([]configWithKey, len(cfgs))
for i, cfg := range cfgs {
out[i] = configWithKey{
ProviderConfig: cfg,
HasKey: cfg.HasKey(),
}
}
c.JSON(http.StatusOK, gin.H{"configs": out})
}
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
// Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-"
// so ShouldBindJSON would silently drop the api_key field.
var req struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
Headers map[string]interface{} `json:"headers,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := providers.Get(req.Provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported provider: " + req.Provider,
"supported_providers": providers.List(),
})
return
}
cfg := &models.ProviderConfig{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
ModelDefault: req.ModelDefault,
Config: models.JSONMap(req.Config),
Headers: models.JSONMap(req.Headers),
Settings: models.JSONMap(req.Settings),
Scope: models.ScopeGlobal,
KeyScope: models.ScopeGlobal,
IsActive: true,
IsPrivate: req.IsPrivate,
}
// Encrypt the API key
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
// No vault configured — store raw bytes (unencrypted fallback)
cfg.APIKeyEnc = []byte(req.APIKey)
}
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
c.JSON(http.StatusCreated, gin.H{
"id": cfg.ID,
"scope": cfg.Scope,
"name": cfg.Name,
"provider": cfg.Provider,
"endpoint": cfg.Endpoint,
"model_default": cfg.ModelDefault,
"config": cfg.Config,
"headers": cfg.Headers,
"settings": cfg.Settings,
"is_active": cfg.IsActive,
"is_private": cfg.IsPrivate,
"has_key": cfg.HasKey(),
"created_at": cfg.CreatedAt,
"updated_at": cfg.UpdatedAt,
})
}
func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
id := c.Param("id")
// Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-"
var req struct {
models.ProviderConfigPatch
APIKey *string `json:"api_key,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := req.ProviderConfigPatch
// Transfer api_key → encrypted fields
if req.APIKey != nil && *req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
patch.APIKeyEnc = []byte(*req.APIKey)
}
}
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
return
}
h.auditLog(c, "provider.update", "provider_config", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
}
func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
id := c.Param("id")
// Delete associated catalog entries first
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
return
}
h.auditLog(c, "provider.delete", "provider_config", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
}
// ── Model Catalog ───────────────────────────
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
c.JSON(http.StatusOK, gin.H{"models": entries})
}
func (h *AdminHandler) FetchModels(c *gin.Context) {
var req struct {
ProviderConfigID string `json:"provider_config_id"`
}
c.ShouldBindJSON(&req)
// If no specific provider, fetch from ALL global providers
if req.ProviderConfigID == "" {
configs, err := h.stores.Providers.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"})
return
}
if len(configs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"})
return
}
totalAdded, totalUpdated, totalFetched := 0, 0, 0
var errs []string
for _, cfg := range configs {
if !cfg.IsActive {
continue
}
added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg)
if err != nil {
errs = append(errs, cfg.Provider+": "+err.Error())
continue
}
totalAdded += added
totalUpdated += updated
totalFetched += fetched
}
result := gin.H{
"message": "models synced",
"added": totalAdded,
"updated": totalUpdated,
"total": totalFetched,
}
if len(errs) > 0 {
result["errors"] = errs
}
c.JSON(http.StatusOK, result)
return
}
// Single provider fetch
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"})
return
}
added, updated, fetched, err := h.fetchModelsForProvider(c, cfg)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "models synced",
"added": added,
"updated": updated,
"total": fetched,
})
}
// fetchModelsForProvider fetches and syncs models for a single provider config.
func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) {
// Decrypt the API key for the provider API call
apiKey := ""
if len(cfg.APIKeyEnc) > 0 {
if h.vault != nil {
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil {
return 0, 0, 0, err
}
h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{
"added": result.Added, "updated": result.Updated, "total": result.Total,
})
return result.Added, result.Updated, result.Total, nil
}
func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
id := c.Param("id")
var req struct {
Visibility *string `json:"visibility"`
DisplayName *string `json:"display_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Visibility != nil {
if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
}
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
var req struct {
ProviderConfigID string `json:"provider_config_id"`
Visibility string `json:"visibility" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var err error
if req.ProviderConfigID != "" {
err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility)
} else {
err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"})
}
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "model deleted"})
}
// ── Stats ───────────────────────────────────
func (h *AdminHandler) GetStats(c *gin.Context) {
ctx := c.Request.Context()
stats := gin.H{}
var userCount, channelCount, messageCount int
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount)
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount)
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount)
stats["users"] = userCount
stats["channels"] = channelCount
stats["messages"] = messageCount
c.JSON(http.StatusOK, stats)
}
// ── Helpers ─────────────────────────────────
// NOTE: ListAuditLog and ListAuditActions are in audit.go
func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID string, metadata interface{}) {
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
var meta models.JSONMap
if metadata != nil {
b, _ := json.Marshal(metadata)
json.Unmarshal(b, &meta)
}
entry := &models.AuditEntry{
ActorID: &uid,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
Metadata: meta,
IPAddress: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
}
if err := h.stores.Audit.Log(context.Background(), entry); err != nil {
log.Printf("audit log error: %v", err)
}
}

636
api.js Normal file
View File

@@ -0,0 +1,636 @@
// ==========================================
// Chat Switchboard API Client
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
//
// BASE_PATH: injected via index.html <script>
// at container startup. All API paths are
// prefixed automatically.
// ==========================================
const BASE = window.__BASE__ || '';
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
const API = {
accessToken: null,
refreshToken: null,
user: null,
_refreshing: null,
// ── Bootstrap ────────────────────────────
loadTokens() {
try {
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
if (saved) {
this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null;
this.user = saved.user || null;
// Unknown age — refresh soon to get a fresh token
if (this.refreshToken) this._scheduleRefresh(60);
}
} catch (e) { /* corrupt storage */ }
},
saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
user: this.user
}));
},
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.user = null;
localStorage.removeItem(_storageKey);
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
},
get isAuthed() { return !!this.accessToken; },
get isAdmin() { return this.user?.role === 'admin'; },
// ── Auth ─────────────────────────────────
async health() {
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
return this._parseJSON(resp, '/api/v1/health');
},
// Detect proxy interception: HTTP 200 but HTML instead of JSON
async _parseJSON(resp, context) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
// Proxy returned an HTML page (block page, auth wall, etc.)
let title = 'unknown';
try {
const html = await resp.clone().text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* best effort */ }
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
return resp.json();
},
async login(login, password) {
const data = await this._post('/api/v1/auth/login', { login, password }, true);
this._setAuth(data);
return data;
},
async register(username, email, password) {
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
if (!data.pending) this._setAuth(data);
return data;
},
async logout() {
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
catch (e) { /* best effort */ }
this.clearTokens();
},
async refresh() {
if (this._refreshing) return this._refreshing;
this._refreshing = (async () => {
try {
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
this._setAuth(data);
return true;
} catch (e) {
this.clearTokens();
return false;
} finally {
this._refreshing = null;
}
})();
return this._refreshing;
},
_setAuth(data) {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.user = data.user;
this.saveTokens();
this._scheduleRefresh(data.expires_in || 900);
},
_refreshTimer: null,
_scheduleRefresh(expiresIn) {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
// Refresh at 80% of expiry (e.g., 12min for 15min token)
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
this._refreshTimer = setTimeout(async () => {
if (!this.refreshToken) return;
console.debug('🔄 Proactive token refresh');
await this.refresh();
}, ms);
},
// ── Channels ──────────────────────────────
listChannels(page = 1, perPage = 100, type = '') {
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
if (type) url += `&type=${type}`;
return this._get(url);
},
createChannel(title, model, systemPrompt, type = 'direct') {
const body = { title, type };
if (model) body.model = model;
if (systemPrompt) body.system_prompt = systemPrompt;
return this._post('/api/v1/channels', body);
},
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
// ── Messages ─────────────────────────────
listMessages(channelId, page = 1, perPage = 200) {
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
},
// ── Message Tree (forking) ───────────────
getActivePath(channelId) {
return this._get(`/api/v1/channels/${channelId}/path`);
},
editMessage(channelId, messageId, content) {
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
},
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
const body = {};
if (model) body.model = model;
if (presetId) body.preset_id = presetId;
if (apiConfigId) body.provider_config_id = apiConfigId;
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the regeneration request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the regeneration stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
updateCursor(channelId, activeLeafId) {
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
},
listSiblings(channelId, messageId) {
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
},
// ── Summarize & Continue ────────────────
summarizeChannel(channelId) {
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
},
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
const body = { channel_id: channelId, content, stream: true };
if (presetId) {
body.preset_id = presetId;
// Preset resolves the model on backend; still pass model for channel metadata
if (model) body.model = model;
} else {
if (model) body.model = model;
}
if (apiConfigId) body.provider_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
let resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the completion request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
// Also check 200 OK but HTML (proxy returning block page with 200)
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the completion stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
// ── Models ───────────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
// User presets
listUserPresets() { return this._get('/api/v1/presets'); },
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
listAllModels() { return this._get('/api/v1/models'); },
// ── API Configs (user providers) ─────────
listConfigs() { return this._get('/api/v1/api-configs'); },
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
createConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._post('/api/v1/api-configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
});
},
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
// ── Profile & Settings ───────────────────
async getProfile() {
const data = await this._get('/api/v1/profile');
// Sync user object with profile data (including avatar)
if (data && this.user) {
this.user.avatar = data.avatar || null;
this.user.display_name = data.display_name;
this.saveTokens();
}
return data;
},
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
changePassword(current, newPw) {
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
},
getSettings() { return this._get('/api/v1/settings'); },
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
// ── Admin ────────────────────────────────
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active, teamIds, teamRole) {
const body = { is_active: active };
if (teamIds?.length) body.team_ids = teamIds;
if (teamRole) body.team_role = teamRole;
return this._put(`/api/v1/admin/users/${id}/active`, body);
},
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
getPublicSettings() { return this._get('/api/v1/settings/public'); },
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
adminGetStats() { return this._get('/api/v1/admin/stats'); },
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
return this._post('/api/v1/admin/configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminBulkUpdateModels(visibility) {
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
return this._put('/api/v1/admin/models/bulk', {
visibility,
is_enabled: visibility === 'enabled'
});
},
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── Admin Presets ────────────────────────
adminListPresets() { return this._get('/api/v1/admin/presets'); },
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
// ── Admin Teams ─────────────────────────
adminListTeams() { return this._get('/api/v1/admin/teams'); },
// ── Admin Audit ─────────────────────────
adminListAudit(params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/admin/audit?${q}`);
},
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
// ── User Teams ──────────────────────────
listMyTeams() { return this._get('/api/v1/teams/mine'); },
// ── Team Admin Self-Service ─────────────
teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); },
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
// ── Team Providers ──────────────────────
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
teamListAudit(teamId, params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
},
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
// ── User Presets ─────────────────────────
listPresets() { return this._get('/api/v1/presets'); },
createPreset(preset) { return this._post('/api/v1/presets', preset); },
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
// ── Notes ────────────────────────────────
listNotes(limit = 50, offset = 0, folder, tag, sort) {
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
// ── Attachments ─────────────────────────
/**
* Upload a file to a channel. Uses multipart/form-data (not JSON).
* Returns the created attachment object with id, metadata, etc.
*/
async uploadAttachment(channelId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
// No Content-Type — browser sets multipart boundary automatically
body: form,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
},
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
async downloadAttachmentBlob(id) {
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
headers: { 'Authorization': `Bearer ${this.accessToken}` },
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
}
return resp.blob();
},
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
// ── Admin Storage ───────────────────────
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
// Vault
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
// ── Admin Roles ─────────────────────────
adminListRoles() { return this._get('/api/v1/admin/roles'); },
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
// ── Admin Usage & Pricing ───────────────
adminGetUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.since) q.set('since', params.since);
if (params?.until) q.set('until', params.until);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage?${q}`);
},
adminGetUserUsage(userId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
},
adminGetTeamUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
},
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
adminDeletePricing(providerConfigId, modelId) {
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
},
// ── User Usage ──────────────────────────
getMyUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/usage?${q}`);
},
// ── Team Roles ──────────────────────────
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
// ── Team Usage ──────────────────────────
teamGetUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
},
// ── HTTP Internals ───────────────────────
_esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
_authHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
};
},
async _get(path) { return this._authed(path); },
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
async _put(path, body) { return this._authed(path, 'PUT', body); },
async _del(path) { return this._authed(path, 'DELETE'); },
async _authed(path, method = 'GET', body) {
try {
return await this._raw(path, method, body, true);
} catch (e) {
if (e.status === 401 && this.refreshToken) {
if (await this.refresh()) {
return this._raw(path, method, body, true);
}
}
throw e;
}
},
async _raw(path, method = 'GET', body, auth = false) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(BASE + path, opts);
if (!resp.ok) {
// Check if the error response is also a proxy page
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
let title = 'unknown';
try {
const html = await resp.text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* */ }
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
err.status = resp.status;
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, path);
}
};

458
channels.go Normal file
View File

@@ -0,0 +1,458 @@
package handlers
import (
"database/sql"
"encoding/json"
"math"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Request / Response types ────────────────
type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), group, channel
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type updateChannelRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
}
type channelResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
Description *string `json:"description"`
Model *string `json:"model"`
APIConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"`
Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type paginatedResponse struct {
Data interface{} `json:"data"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
}
// ChannelHandler holds dependencies for channel endpoints.
type ChannelHandler struct{}
// NewChannelHandler creates a new channel handler.
func NewChannelHandler() *ChannelHandler {
return &ChannelHandler{}
}
// channelDeleteHook is called after a channel is successfully deleted.
// Set at startup by SetChannelDeleteHook to clean up storage files.
var channelDeleteHook func(channelID string)
// SetChannelDeleteHook registers a callback invoked after channel deletion.
// Used to clean up attachment files on the storage backend.
func SetChannelDeleteHook(fn func(channelID string)) {
channelDeleteHook = fn
}
// ── Helpers ─────────────────────────────────
// getUserID extracts the authenticated user's ID from context.
func getUserID(c *gin.Context) string {
uid, _ := c.Get("user_id")
s, _ := uid.(string)
return s
}
// parsePagination reads page and per_page from query params with defaults.
func parsePagination(c *gin.Context) (page, perPage, offset int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
if perPage > 100 {
perPage = 100
}
offset = (page - 1) * perPage
return
}
// ── List Channels ───────────────────────────
func (h *ChannelHandler) ListChannels(c *gin.Context) {
userID := getUserID(c)
page, perPage, offset := parsePagination(c)
// Optional filters
archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder")
channelType := c.DefaultQuery("type", "") // empty = all types
search := strings.TrimSpace(c.Query("search"))
// Count total
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
countArgs := []interface{}{userID, archived == "true"}
argN := 3
if channelType != "" {
countQuery += ` AND type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType)
argN++
}
if folder != "" {
countQuery += ` AND folder = $` + strconv.Itoa(argN)
countArgs = append(countArgs, folder)
argN++
}
if search != "" {
countQuery += ` AND title ILIKE $` + strconv.Itoa(argN)
countArgs = append(countArgs, "%"+search+"%")
argN++
}
var total int
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
return
}
// Fetch channels with message count
query := `
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM channels c
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.user_id = $1 AND c.is_archived = $2`
args := []interface{}{userID, archived == "true"}
argN = 3
if channelType != "" {
query += ` AND c.type = $` + strconv.Itoa(argN)
args = append(args, channelType)
argN++
}
if folder != "" {
query += ` AND c.folder = $` + strconv.Itoa(argN)
args = append(args, folder)
argN++
}
if search != "" {
query += ` AND c.title ILIKE $` + strconv.Itoa(argN)
args = append(args, "%"+search+"%")
argN++
}
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
args = append(args, perPage, offset)
rows, err := database.DB.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
defer rows.Close()
channels := make([]channelResponse, 0)
for rows.Next() {
var ch channelResponse
var tags []string
err := rows.Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings,
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"})
return
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
channels = append(channels, ch)
}
c.JSON(http.StatusOK, paginatedResponse{
Data: channels,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
})
}
// ── Create Channel ──────────────────────────
func (h *ChannelHandler) CreateChannel(c *gin.Context) {
userID := getUserID(c)
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Tags == nil {
req.Tags = []string{}
}
// Default type is "direct" (1:1 AI chat)
channelType := req.Type
if channelType == "" {
channelType = "direct"
}
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags),
).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
ch.MessageCount = 0
// Auto-create channel_member for the creator
_, _ = database.DB.Exec(`
INSERT INTO channel_members (channel_id, user_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT DO NOTHING
`, ch.ID, userID)
// Auto-create channel_model if model specified
if req.Model != "" {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING
`, ch.ID, req.Model, req.APIConfigID)
}
c.JSON(http.StatusCreated, ch)
}
// ── Get Channel ─────────────────────────────
func (h *ChannelHandler) GetChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM channels c
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.id = $1 AND c.user_id = $2
`, channelID, userID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings,
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"})
return
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
c.JSON(http.StatusOK, ch)
}
// ── Update Channel ──────────────────────────
func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if database.DB == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"})
return
}
var req updateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership
var ownerID string
err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// Build dynamic UPDATE
setClauses := []string{}
args := []interface{}{}
argN := 1
addClause := func(col string, val interface{}) {
setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN))
args = append(args, val)
argN++
}
if req.Title != nil {
addClause("title", *req.Title)
}
if req.Description != nil {
addClause("description", *req.Description)
}
if req.Model != nil {
addClause("model", *req.Model)
}
if req.SystemPrompt != nil {
addClause("system_prompt", *req.SystemPrompt)
}
if req.APIConfigID != nil {
addClause("provider_config_id", *req.APIConfigID)
}
if req.IsArchived != nil {
addClause("is_archived", *req.IsArchived)
}
if req.IsPinned != nil {
addClause("is_pinned", *req.IsPinned)
}
if req.Folder != nil {
addClause("folder", *req.Folder)
}
if req.Tags != nil {
addClause("tags", pq.Array(req.Tags))
}
if req.Settings != nil {
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
args = append(args, []byte(*req.Settings))
argN++
}
if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
query := "UPDATE channels SET "
for i, clause := range setClauses {
if i > 0 {
query += ", "
}
query += clause
}
query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1)
args = append(args, channelID, userID)
_, err = database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
// Return updated channel
h.GetChannel(c)
}
// ── Delete Channel ──────────────────────────
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
result, err := database.DB.Exec(
`DELETE FROM channels WHERE id = $1 AND user_id = $2`,
channelID, userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// Clean up storage files (CASCADE already removed PG attachment rows)
if channelDeleteHook != nil {
go channelDeleteHook(channelID)
}
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
}

632
chat.js Normal file
View File

@@ -0,0 +1,632 @@
// ==========================================
// Chat Switchboard Chat Operations
// ==========================================
// Chat management, send, stream, regenerate, edit, branch,
// summarize, per-chat model persistence.
// ── Summarize & Continue ──────────────────
async function summarizeAndContinue() {
const channelId = App.currentChatId;
if (!channelId) return;
const btn = document.getElementById('summarizeBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Summarizing…';
}
try {
const result = await API.summarizeChannel(channelId);
UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success');
// Reload the active path to get the updated message tree
const resp = await API.getActivePath(channelId);
const chat = App.chats.find(c => c.id === channelId);
if (chat && resp) {
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
UI.renderMessages(chat.messages);
updateContextWarning();
updateInputTokens();
}
} catch (err) {
console.error('Summarize failed:', err);
UI.toast(err.message || 'Summarization failed', 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '📝 Summarize & Continue';
}
}
}
// ── Chat Management ──────────────────────────
async function loadChats() {
try {
const resp = await API.listChannels(1, 100, 'direct');
App.chats = (resp.data || []).map(c => ({
id: c.id,
title: c.title,
type: c.type || 'direct',
model: c.model || '',
messageCount: c.message_count || 0,
messages: [],
updatedAt: c.updated_at,
settings: c.settings || {},
}));
} catch (e) {
console.error('Failed to load chats:', e.message);
UI.toast('Failed to load chats', 'error');
}
}
async function selectChat(chatId) {
clearStaged(); // Discard any staged attachments from previous chat
App.currentChatId = chatId;
UI.renderChatList();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
if (ov) ov.style.display = 'none';
}
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
if (chat.messages.length === 0 && chat.messageCount > 0) {
try {
const resp = await API.getActivePath(chatId);
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
} catch (e) {
console.error('Failed to load messages:', e.message);
UI.toast('Failed to load messages', 'error');
}
}
// Restore the last-used model/preset for this chat
_restoreChatModel(chatId, chat);
// Load attachments for message rendering (non-blocking, best-effort)
await loadChannelAttachments(chatId);
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
Tokens._warningDismissed = false;
updateContextWarning();
updateInputTokens();
}
// ── Per-Chat Model/Preset Persistence ────────
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
// localStorage used as write-through cache for instant restore without
// waiting for the channel list API call.
function _saveChatModel(chatId) {
if (!chatId) return;
const selectorId = UI.getModelValue();
if (!selectorId) return;
// Write-through: localStorage for instant restore on next visit
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
map[chatId] = selectorId;
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* quota exceeded, etc */ }
// Update in-memory chat object
const chat = App.chats.find(c => c.id === chatId);
if (chat) {
if (!chat.settings) chat.settings = {};
chat.settings.last_selector_id = selectorId;
}
// Persist to server (fire-and-forget — non-critical)
API.updateChannel(chatId, { settings: { last_selector_id: selectorId } })
.catch(e => console.debug('Failed to persist model selection:', e.message));
}
function _restoreChatModel(chatId, chat) {
// Priority: server settings → localStorage fallback → channel base model → keep current
let targetId = null;
// 1. Server-side settings (most authoritative, roams across devices)
if (chat?.settings?.last_selector_id) {
targetId = chat.settings.last_selector_id;
}
// 2. localStorage fallback (covers race where settings haven't loaded yet)
if (!targetId) {
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
if (map[chatId]) targetId = map[chatId];
} catch (e) { /* */ }
}
// 3. Verify the stored ID still exists in available models
if (targetId) {
const found = App.models.find(m => m.id === targetId);
if (found) {
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
return;
}
// Stored model removed — clean up stale entries
_cleanStaleChatModel(chatId);
}
// 4. Fall back to channel's base model ID (match by baseModelId)
if (chat?.model) {
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
return;
}
}
// 4. Stored model gone — fall back to admin default → first visible
const visible = App.models.filter(m => !m.hidden);
const def = App.defaultModel
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
: null;
const fallback = def || visible[0];
if (fallback) {
UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : ''));
}
}
function _cleanStaleChatModel(chatId) {
// Remove from localStorage
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
// Clear from in-memory settings (server cleanup is fire-and-forget)
const chat = App.chats.find(c => c.id === chatId);
if (chat?.settings?.last_selector_id) {
delete chat.settings.last_selector_id;
}
}
async function newChat() {
clearStaged(); // Discard any staged attachments
App.currentChatId = null;
UI.renderChatList();
UI.showEmptyState();
UI.showRegenerate(false);
Tokens._warningDismissed = false;
updateContextWarning();
updateInputTokens();
document.getElementById('messageInput').focus();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
if (ov) ov.style.display = 'none';
}
}
async function deleteChat(chatId) {
if (!await showConfirm('Delete this chat?')) return;
try {
await API.deleteChannel(chatId);
App.chats = App.chats.filter(c => c.id !== chatId);
// Clean up stored model preference
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
UI.renderChatList();
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
}
// ── Send Message ─────────────────────────────
async function sendMessage() {
const input = document.getElementById('messageInput');
const text = input.value.trim();
const hasAttachments = hasStagedAttachments();
// Need text or attachments, not generating, not blocked by uploads
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
input.value = '';
input.style.height = 'auto';
// Snapshot attachment IDs before clearing staged state
const attachmentIds = getStagedAttachmentIds();
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
// For presets, send the base model ID; for regular models, send the model ID
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
if (!App.currentChatId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
}
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
// Optimistic: show user message immediately
const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.length > 1 ? 's' : ''} attached]` : '');
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
UI.renderMessages(chat.messages);
// Clear staged attachments (they're now part of this message)
if (hasAttachments) consumeStaged();
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
// Reload active path from server to get proper IDs and sibling metadata
await reloadActivePath();
UI.showRegenerate(true);
// Persist the model/preset selection for this chat
_saveChatModel(App.currentChatId);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Completion error:', e);
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request — contact your network admin', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit — wait and retry', 'warning');
} else if (msg.includes('no API config') || msg.includes('no model')) {
UI.toast('No provider configured — add one in Settings', 'error');
} else {
UI.toast(msg, 'error');
}
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
// ── Reload Active Path from Server ──────────
async function reloadActivePath() {
if (!App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
try {
const resp = await API.getActivePath(App.currentChatId);
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
await loadChannelAttachments(App.currentChatId);
UI.renderMessages(chat.messages);
updateContextWarning();
} catch (e) {
console.error('Failed to reload path:', e.message);
}
}
// ── Regenerate (tree-aware: creates sibling) ─
async function regenerateMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
// Truncate display to the parent of the message being regenerated.
// This makes the stream appear in the correct position — as a fresh
// response to the parent user message, not appended at the bottom.
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat) {
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
if (msgIdx > 0) {
const truncated = chat.messages.slice(0, msgIdx);
UI.renderMessages(truncated);
}
}
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamRegenerate(
App.currentChatId, messageId, App.abortController.signal,
model, presetId, modelInfo?.configId
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Reload from server to get proper tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings', 'error');
} else { UI.toast(msg, 'error'); }
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
// Legacy: bottom-bar regenerate button targets the last assistant message
async function regenerate() {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat || chat.messages.length === 0) return;
// Find the last assistant message with an ID
for (let i = chat.messages.length - 1; i >= 0; i--) {
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
return regenerateMessage(chat.messages[i].id);
}
}
UI.toast('No assistant message to regenerate', 'warning');
}
// ── Edit Message (creates sibling, triggers completion) ──
async function editMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
const msg = chat.messages.find(m => m.id === messageId);
if (!msg || msg.role !== 'user') return;
// Show inline edit UI
UI.showEditInline(messageId, msg.content);
}
async function submitEdit(messageId, newContent) {
if (App.isGenerating || !App.currentChatId) return;
if (!newContent.trim()) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const chat = App.chats.find(c => c.id === App.currentChatId);
// Step 1: Create sibling user message via edit endpoint
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
// Step 2: Reload path to show the new edited message
await reloadActivePath();
// Step 3: Generate assistant response as child of the new edit.
// Uses regenerate endpoint (which handles user messages by creating
// a child response, not a sibling). This avoids the duplicate user
// message that streamCompletion would create.
const resp = await API.streamRegenerate(
App.currentChatId, editResp.id, App.abortController.signal,
model, presetId, modelInfo?.configId
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Step 4: Final reload to get the complete tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Edit error:', e);
UI.toast(e.message || 'Edit failed', 'error');
await reloadActivePath();
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function cancelEdit() {
// Re-render to remove the edit form
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat) UI.renderMessages(chat.messages);
}
// ── Switch Branch (sibling navigation) ──────
async function switchSibling(messageId, direction) {
if (App.isGenerating || !App.currentChatId) return;
try {
// Get siblings for this message
const data = await API.listSiblings(App.currentChatId, messageId);
const siblings = data.siblings || [];
const currentIdx = data.current_index ?? 0;
const targetIdx = currentIdx + direction;
if (targetIdx < 0 || targetIdx >= siblings.length) return;
const targetSibling = siblings[targetIdx];
// Update cursor to the target sibling (backend walks to leaf)
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
// Update local state with the new path
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat && resp.path) {
chat.messages = resp.path.map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
}
} catch (e) {
console.error('Branch switch failed:', e.message);
UI.toast('Failed to switch branch', 'error');
}
}
// ── Chat Listeners (extracted from initListeners) ──
function _initChatListeners() {
// Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', newChat);
// Split button dropdown
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('newChatDropdown').classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.split-btn')) {
document.getElementById('newChatDropdown').classList.remove('open');
}
});
// User flyout
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
e.stopPropagation();
UI.toggleUserMenu();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
});
// Flyout items
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); });
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin());
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Sidebar chat search
const _chatSearchInput = document.getElementById('chatSearchInput');
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
});
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
// Model selector (custom dropdown)
UI.initModelDropdown();
UI.initAppearance();
// Mobile hamburger
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('sidebarOverlay').style.display = 'none';
localStorage.setItem('sb_sidebar', '1');
});
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModels();
const visible = App.models.filter(m => !m.hidden).length;
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
});
// Input
const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(overlay.id);
});
});
}

View File

@@ -37,6 +37,10 @@ services:
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# File storage — mount ./data/storage on host
STORAGE_PATH: /data/storage
volumes:
- switchboard_storage:/data/storage
ports:
- "3000:80"
depends_on:
@@ -56,3 +60,4 @@ services:
volumes:
postgres_data:
switchboard_storage:

931
index.html Normal file
View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<base href="%%BASE_HREF%%">
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script>
<script>window.__ENV__ = '%%ENVIRONMENT%%'; if (window.__ENV__.includes('%%')) window.__ENV__ = 'development';</script>
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
<title>Chat Switchboard</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=%%APP_VERSION%%">
<link rel="icon" type="image/x-icon" href="favicon.ico?v=%%APP_VERSION%%">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=%%APP_VERSION%%">
<link rel="icon" type="image/png" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
<link rel="apple-touch-icon" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
<link rel="manifest" href="manifest.json?v=%%APP_VERSION%%">
<meta name="theme-color" content="#0e0e10">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
</head>
<body>
<!-- ── App Shell ───────────────────────────── -->
<div id="appContainer" class="app" style="display:none">
<!-- Environment Banner (top) -->
<div class="banner banner-top" id="bannerTop"></div>
<div class="app-body">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-top">
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
<span class="brand-logo" id="brandSidebarLogo"><img src="favicon-32.png" class="brand-logo-img" alt=""></span>
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span class="sb-label brand-text" id="brandSidebarText">Chat Switchboard</span>
</button>
<div class="split-btn" style="position:relative">
<button class="split-btn-main" id="newChatBtn" title="New Chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
<span class="sb-label">New Chat</span>
</button>
<button class="split-btn-drop" id="newChatDropBtn" title="More options">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="split-dropdown" id="newChatDropdown">
<button class="split-dropdown-item" onclick="newChat()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
New Chat
</button>
<button class="split-dropdown-item disabled" title="Coming soon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Group Chat <span class="dd-hint">soon</span>
</button>
<button class="split-dropdown-item disabled" title="Coming soon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
New Channel <span class="dd-hint">soon</span>
</button>
</div>
</div>
</div>
<div class="sidebar-search" id="sidebarSearch">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
<button class="sidebar-search-clear" id="chatSearchClear" title="Clear"></button>
</div>
<div class="sidebar-chats" id="chatHistory"></div>
<!-- Notes shortcut -->
<div class="sidebar-notes-btn">
<button class="sb-notes" id="notesBtn" title="Notes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span class="sb-label">Notes</span>
</button>
</div>
<!-- User area -->
<div class="sidebar-bottom">
<button class="user-btn" id="userMenuBtn">
<div class="user-avatar" id="userAvatar"><span id="avatarLetter">?</span>
<span class="avatar-bug" title="Debug available">🐛</span>
</div>
<span class="sb-label user-name" id="userName">User</span>
</button>
<div class="user-flyout" id="userFlyout">
<button class="flyout-item" id="menuSettings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
<span>Settings</span>
</button>
<button class="flyout-item" id="menuAdmin" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin Panel</span>
</button>
<button class="flyout-item" id="menuTeamAdmin" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Team Management</span>
</button>
<button class="flyout-item" id="menuDebug">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><path d="M4 14h4"/><path d="M16 14h4"/><path d="M12 18v-6"/><circle cx="12" cy="14" r="6"/></svg>
<span>Debug Log</span>
</button>
<div class="flyout-divider"></div>
<button class="flyout-item flyout-danger" id="menuSignout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>Sign Out</span>
</button>
</div>
</div>
</aside>
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Main Chat Area -->
<main class="chat-area">
<div class="model-bar">
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="model-dropdown" id="modelDropdown">
<button class="model-dropdown-btn" id="modelDropdownBtn" title="Select model">
<span class="model-dropdown-label" id="modelDropdownLabel">Select a model</span>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3.5L5 6.5L8 3.5"/></svg>
</button>
<div class="model-dropdown-menu" id="modelDropdownMenu"></div>
</div>
<button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<div class="model-caps" id="modelCaps"></div>
</div>
<div class="messages" id="chatMessages">
<div class="empty-state">
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
<h2>Chat Switchboard</h2>
<p>Select a model and start chatting</p>
</div>
</div>
<div class="input-area">
<div class="context-warning" id="contextWarning" style="display:none">
<span class="context-warning-icon">⚠️</span>
<span class="context-warning-text" id="contextWarningText"></span>
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss"></button>
</div>
<div class="attachment-strip" id="attachmentStrip" style="display:none"></div>
<div class="input-wrap">
<button class="action-btn attach-btn" id="attachBtn" title="Attach file" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="fileInput" multiple style="display:none">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
<div class="input-actions">
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button class="action-btn send-btn" id="sendBtn" title="Send">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
<div class="input-meta" id="inputMeta">
<span class="input-token-count" id="inputTokenCount"></span>
</div>
</div>
</main>
<!-- Side Panel (preview + notes) -->
<aside class="side-panel" id="sidePanel">
<div class="side-panel-resize" id="sidePanelResize"></div>
<div class="side-panel-header">
<div class="side-panel-tabs" id="sidePanelTabs">
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
</div>
<div class="side-panel-actions">
<button class="side-panel-action-btn" id="sidePanelClearBtn" onclick="clearPreview()" title="Clear preview" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
</button>
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" onclick="toggleSidePanelFullscreen()" title="Toggle fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel"></button>
</div>
</div>
<div class="side-panel-body">
<!-- Preview tab -->
<div class="side-panel-page" id="sidePanelPreview">
<div class="side-panel-empty" id="previewEmpty">
<p>Click <strong>Preview</strong> on any HTML code block to render it here.</p>
</div>
<iframe id="previewFrame" class="preview-frame" sandbox="allow-scripts" style="display:none"></iframe>
</div>
<!-- Notes tab -->
<div class="side-panel-page" id="sidePanelNotes" style="display:none">
<div class="notes-toolbar">
<div class="notes-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
</div>
<select id="notesFolderFilter" class="notes-filter-select">
<option value="">All folders</option>
</select>
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
<option value="updated_desc">Updated ↓</option>
<option value="updated_asc">Updated ↑</option>
<option value="created_desc">Created ↓</option>
<option value="created_asc">Created ↑</option>
<option value="title_asc">Title AZ</option>
<option value="title_desc">Title ZA</option>
</select>
</div>
<div class="notes-actions-bar">
<button class="btn-small" id="notesSelectModeBtn">Select</button>
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
</div>
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
</div>
<!-- List view (default) -->
<div id="notesListView">
<div id="notesList" class="notes-list">
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
</div>
</div>
<!-- Editor view (hidden by default) -->
<div id="notesEditorView" style="display:none">
<div class="notes-editor">
<div class="notes-editor-toolbar">
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
<div class="notes-mode-toggle">
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
</div>
</div>
<!-- Read mode: rendered markdown -->
<div id="noteReadMode" style="display:none">
<h3 class="note-read-title" id="noteReadTitle"></h3>
<div class="note-read-meta" id="noteReadMeta"></div>
<div class="note-read-content msg-text" id="noteReadContent"></div>
<div class="notes-editor-actions">
<button class="btn-small" id="noteCopyBtn" onclick="copyNoteContent()">Copy</button>
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
</div>
</div>
<!-- Edit mode: form fields -->
<div id="noteEditMode">
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
<div class="form-row">
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
</div><!-- .app-body -->
<!-- Environment Banner (bottom) -->
<div class="banner banner-bottom" id="bannerBottom"></div>
</div>
<!-- ── Auth Splash ─────────────────────────── -->
<div class="splash" id="splashGate">
<div class="splash-hero">
<div class="hero-content">
<div class="hero-logo-row">
<div class="hero-logo-mark" id="brandLogo">
<img src="favicon-256.png" alt="Chat Switchboard" style="width:100%;height:100%;border-radius:12px">
</div>
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
</div>
<h1 class="hero-headline" id="brandHeadline">One interface.<br>Every AI model.</h1>
<p class="hero-sub" id="brandTagline">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
<div class="hero-features" id="brandPills">
<div class="hero-pill accent"><span class="pill-icon"></span> Streaming responses</div>
<div class="hero-pill purple"><span class="pill-icon"><img src="favicon-32.png" style="width:14px;height:14px;vertical-align:middle" alt=""></span> Multi-provider routing</div>
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
<div class="hero-pill"><span class="pill-icon">🏠</span> Self-hosted</div>
<div class="hero-pill"><span class="pill-icon">✈️</span> Air-gap ready</div>
<div class="hero-pill"><span class="pill-icon">🧠</span> Thinking blocks</div>
</div>
<div class="hero-version">v%%APP_VERSION%%</div>
</div>
</div>
<div class="splash-auth">
<div class="auth-card">
<div class="auth-card-header">
<h2>Welcome back</h2>
<p>Sign in to continue to your workspace</p>
</div>
<div class="auth-tabs">
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
</div>
<div id="authLoginForm">
<div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username" placeholder="you@example.com"></div>
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" placeholder="••••••••" onkeydown="if(event.key==='Enter')handleLogin()"></div>
</div>
<div id="authRegisterForm" style="display:none">
<div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username" placeholder="Choose a username"></div>
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email" placeholder="you@example.com"></div>
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" placeholder="Min 8 characters" onkeydown="if(event.key==='Enter')handleRegister()"></div>
</div>
<div class="auth-error" id="authError"></div>
<div class="splash-error" id="splashError"></div>
<div class="auth-actions">
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
</div>
<div class="auth-footer">
<p id="brandAuthFooter">Self-hosted AI chat &middot; Your keys, your data</p>
</div>
</div>
</div>
</div>
<!-- ── Settings Modal ──────────────────────── -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn"></button></div>
<div class="modal-tabs settings-tabs">
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')" id="settingsProvidersTabBtn">Providers</button>
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
<button class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')" id="settingsPersonasTabBtn">Personas</button>
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')" id="settingsUsageTabBtn">Usage</button>
<button class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" id="settingsRolesTabBtn">Model Roles</button>
</div>
<div class="modal-body">
<!-- General Tab -->
<div class="settings-tab-content" id="settingsGeneralTab">
<section class="settings-section">
<h3>Profile</h3>
<div class="avatar-upload-row">
<div class="avatar-preview" id="avatarPreview">
<span id="avatarPreviewLetter">?</span>
</div>
<div class="avatar-actions">
<button class="btn-small" id="avatarUploadBtn">Upload Photo</button>
<button class="btn-small btn-danger" id="avatarRemoveBtn" style="display:none">Remove</button>
<input type="file" id="avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
<div class="form-hint">Auto-resized to 128×128</div>
</div>
</div>
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
<button class="btn-small" id="profileChangePwBtn">Change Password</button>
<div id="profileChangePwForm" style="display:none">
<div class="form-group"><label>Current Password</label><input type="password" id="profileCurrentPw"></div>
<div class="form-group"><label>New Password</label><input type="password" id="profileNewPw"></div>
<button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
</div>
</section>
<section class="settings-section" id="settingsTeamsSection" style="display:none">
<h3>My Teams</h3>
<div id="settingsTeamsList"></div>
</section>
<section class="settings-section">
<h3>Chat</h3>
<div class="form-group"><label>Default Model</label><select id="settingsModel"></select></div>
<div class="form-group"><label>System Prompt</label><textarea id="settingsSystemPrompt" rows="3"></textarea></div>
<div class="form-row">
<div class="form-group">
<label>Max Tokens <span class="form-hint" id="settingsMaxHint"></span></label>
<input type="number" id="settingsMaxTokens" placeholder="Auto (from model)">
</div>
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
</div>
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Auto-expand thinking blocks</label>
</section>
</div>
<!-- Appearance Tab -->
<div class="settings-tab-content" id="settingsAppearanceTab" style="display:none">
<section class="settings-section">
<h3>Display</h3>
<div class="form-group">
<label>UI Scale <span class="form-hint" id="scaleValue">100%</span></label>
<input type="range" id="settingsScale" min="80" max="150" step="5" value="100" class="range-input">
<div class="range-labels"><span>80%</span><span>150%</span></div>
</div>
<div class="form-group">
<label>Message Font Size <span class="form-hint" id="msgFontValue">14px</span></label>
<input type="range" id="settingsMsgFont" min="12" max="20" step="1" value="14" class="range-input">
<div class="range-labels"><span>12px</span><span>20px</span></div>
</div>
</section>
</div>
<!-- Providers Tab -->
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
<section class="settings-section">
<div id="providerList"></div>
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
<div id="providerAddForm" style="display:none">
</div>
</section>
<div id="userProvidersDisabled" class="settings-notice" style="display:none">
<span>⚠️</span> User-configured providers have been disabled by the administrator.
</div>
</div>
<!-- Models Tab -->
<div class="settings-tab-content" id="settingsModelsTab" style="display:none">
<section class="settings-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<h3 style="font-size:14px;margin:0">Available Models</h3>
<div>
<button class="btn-small" onclick="bulkSetUserModelVisibility(true)" title="Show all models in selector">Show All</button>
<button class="btn-small" onclick="bulkSetUserModelVisibility(false)" title="Hide all models from selector">Hide All</button>
</div>
</div>
<p class="section-hint" style="margin-bottom:8px">Toggle visibility in your model selector. Hidden models are still accessible through presets.</p>
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
</section>
</div>
<!-- Personas Tab (hidden if admin disables user presets) -->
<div class="settings-tab-content" id="settingsPersonasTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:4px">My Personas</h3>
<p class="section-hint" style="margin-bottom:8px">Personal model presets with custom system prompts and settings.</p>
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Persona</button>
</section>
</div>
<!-- Usage Tab -->
<div class="settings-tab-content" id="settingsUsageTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:6px">My Usage</h3>
<p class="section-hint" style="margin-bottom:8px">Token consumption and estimated costs across all your conversations.</p>
<div class="form-row" style="gap:6px;margin-bottom:8px">
<select id="myUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadMyUsage()">
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
</select>
<select id="myUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadMyUsage()">
<option value="model">By Model</option>
<option value="day">By Day</option>
</select>
</div>
<div id="myUsageTotals"></div>
<div id="myUsageResults"></div>
</section>
</div>
<!-- Model Roles Tab (BYOK users — personal role overrides) -->
<div class="settings-tab-content" id="settingsRolesTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:4px">Personal Model Roles</h3>
<p class="section-hint" style="margin-bottom:8px">Override the org's default utility and embedding models with your own providers. Uses your personal API keys — no org cost.</p>
<div id="userRolesConfig"></div>
</section>
<div id="userRolesDisabled" class="settings-notice" style="display:none">
<span></span> Add a personal provider first to configure role overrides.
</div>
</div>
</div>
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
</div>
</div>
<!-- ── Team Management Modal ─────────────────── -->
<div class="modal-overlay" id="teamAdminModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2 id="teamAdminTitle">Team Management</h2>
<button class="modal-close" id="teamAdminCloseBtn"></button>
</div>
<!-- Team picker (shown when user admins multiple teams) -->
<div id="teamAdminPicker" style="display:none">
<div class="modal-body" id="teamAdminPickerList"></div>
</div>
<!-- Tabbed management (shown after selecting a team) -->
<div id="teamAdminContent" style="display:none">
<div class="modal-tabs admin-tabs" id="teamAdminTabs">
<button class="admin-tab active" data-ttab="members" onclick="UI.switchTeamTab('members')">Members</button>
<button class="admin-tab" data-ttab="providers" onclick="UI.switchTeamTab('providers')">Providers</button>
<button class="admin-tab" data-ttab="presets" onclick="UI.switchTeamTab('presets')">Presets</button>
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
<button class="admin-tab" data-ttab="activity" onclick="UI.switchTeamTab('activity')">Activity</button>
</div>
<div class="modal-body">
<!-- Members tab -->
<div class="team-tab-content" id="teamTabMembers">
<div id="settingsTeamMembers"></div>
<div id="settingsTeamAddMember" style="display:none;margin-top:8px" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>User</label><select id="settingsTeamMemberUser"></select></div>
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button><button class="btn-small" id="settingsTeamCancelMember">Cancel</button></div>
</div>
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
</div>
<!-- Providers tab -->
<div class="team-tab-content" id="teamTabProviders" style="display:none">
<div id="settingsTeamProviders"></div>
<div id="settingsTeamProviderForm" style="display:none;margin-top:8px" class="admin-inline-form">
</div>
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:6px">+ Add Provider</button>
</div>
<!-- Presets tab -->
<div class="team-tab-content" id="teamTabPresets" style="display:none">
<div id="settingsTeamPresets"></div>
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
</div>
<!-- Usage tab -->
<div class="team-tab-content" id="teamTabUsage" style="display:none">
<div class="form-row" style="gap:6px;margin-bottom:8px">
<select id="teamUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
</select>
<select id="teamUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
<option value="model">By Model</option>
<option value="user">By User</option>
<option value="day">By Day</option>
</select>
</div>
<div id="settingsTeamUsageTotals"></div>
<div id="settingsTeamUsageResults"></div>
</div>
<!-- Activity tab -->
<div class="team-tab-content" id="teamTabActivity" style="display:none">
<div class="audit-filters" style="margin-bottom:8px">
<select id="teamAuditFilterAction" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamAuditLog()">
<option value="">All actions</option>
</select>
</div>
<div id="settingsTeamAudit" style="max-height:400px;overflow-y:auto"></div>
<div id="teamAuditPagination" class="audit-pagination" style="display:none">
<button class="btn-small" id="teamAuditPrevBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage - 1)">← Prev</button>
<span id="teamAuditPageInfo" style="font-size:11px;color:var(--text-3)"></span>
<button class="btn-small" id="teamAuditNextBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage + 1)">Next →</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Admin Modal ─────────────────────────── -->
<div class="modal-overlay" id="adminModal">
<div class="modal modal-wide">
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn"></button></div>
<div class="modal-tabs admin-tabs">
<button class="admin-tab active" data-tab="users">Users</button>
<button class="admin-tab" data-tab="providers">Providers</button>
<button class="admin-tab" data-tab="models">Models</button>
<button class="admin-tab" data-tab="presets">Presets</button>
<button class="admin-tab" data-tab="teams">Teams</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="roles">Roles</button>
<button class="admin-tab" data-tab="usage">Usage</button>
<button class="admin-tab" data-tab="extensions">Extensions</button>
<button class="admin-tab" data-tab="storage">Storage</button>
<button class="admin-tab" data-tab="stats">Stats</button>
<button class="admin-tab" data-tab="audit">Audit</button>
</div>
<div class="modal-body">
<div class="admin-tab-content" id="adminUsersTab">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddUserBtn">+ Add User</button>
</div>
<div id="adminAddUserForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>
<div class="form-group"><label>Email</label><input type="email" id="adminNewEmail" placeholder="email@example.com"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>
<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateUserBtn">Create</button><button class="btn-small" id="adminCancelUserBtn">Cancel</button></div>
</div>
<div id="adminUserList"></div>
</div>
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
</div>
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
</div>
<div id="adminProviderList"></div>
</div>
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button>
<button class="btn-small" onclick="bulkSetVisibility('enabled')">All Enabled</button>
<button class="btn-small" onclick="bulkSetVisibility('team')">All Team</button>
<button class="btn-small" onclick="bulkSetVisibility('disabled')">All Disabled</button>
<span class="admin-hint" id="adminModelsHint"></span>
</div>
<div id="adminModelList"></div>
</div>
<div class="admin-tab-content" id="adminPresetsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Preset</button>
</div>
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form"></div>
<div id="adminPresetList"></div>
</div>
<div class="admin-tab-content" id="adminTeamsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddTeamBtn">+ New Team</button>
</div>
<div id="adminAddTeamForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Team Name</label><input type="text" id="adminTeamName" placeholder="Engineering"></div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Core engineering team"></div>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateTeamBtn">Create</button><button class="btn-small" id="adminCancelTeamBtn">Cancel</button></div>
</div>
<div id="adminTeamList"></div>
<!-- Team detail / member management (shown when editing a team) -->
<div id="adminTeamDetail" style="display:none">
<button class="notes-back-btn" id="adminTeamBackBtn">← Back to teams</button>
<h3 id="adminTeamDetailName" style="margin:8px 0 12px;font-size:15px"></h3>
<section class="settings-section" style="margin-bottom:12px;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (data stays on-prem)</label>
<p class="section-hint" style="margin:4px 0 0">Team members can only use providers marked as "private".</p>
<label class="checkbox-label" style="margin-top:8px"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team admins to manage providers</label>
<p class="section-hint" style="margin:4px 0 0">When disabled, team admins cannot add or modify team providers.</p>
</section>
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddMemberBtn">+ Add Member</button>
</div>
<div id="adminAddMemberForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>User</label><select id="adminMemberUser"></select></div>
<div class="form-group"><label>Team Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="adminAddMemberSubmit">Add</button><button class="btn-small" id="adminCancelMemberBtn">Cancel</button></div>
</div>
<div id="adminMemberList"></div>
</div>
</div>
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<section class="settings-section">
<h3>System Prompt</h3>
<p class="section-hint" style="margin-bottom:6px">Injected into every conversation before user/preset prompts. Users cannot override or disable this.</p>
<div class="form-group">
<textarea id="adminSystemPrompt" rows="4" placeholder="e.g. You are a helpful assistant for Acme Corp. Always respond professionally."></textarea>
</div>
</section>
<section class="settings-section">
<h3>Registration</h3>
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow new user registration</label>
<div class="form-group" style="margin-top: 8px;">
<label>Default state for new accounts</label>
<select id="adminRegDefaultState">
<option value="active">Active — immediate access</option>
<option value="pending">Pending — require admin approval</option>
</select>
</div>
</section>
<section class="settings-section">
<h3>User Providers</h3>
<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle" checked> Allow users to configure their own API providers</label>
<p class="section-hint">When disabled, users can only use admin-configured global providers.</p>
</section>
<section class="settings-section">
<h3>User Presets</h3>
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal presets</label>
<p class="section-hint">When disabled, users can only use admin-created global presets.</p>
</section>
<section class="settings-section">
<h3>Default Model</h3>
<div class="form-group">
<select id="adminDefaultModel">
<option value="">— None (first visible) —</option>
</select>
</div>
<p class="section-hint">Model selected by default for new users or when a saved model is no longer available.</p>
</section>
<section class="settings-section">
<h3>Environment Banner</h3>
<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>
<div id="bannerConfigFields" style="display:none">
<div class="form-group">
<label>Preset</label>
<select id="adminBannerPreset"><option value="">Custom</option></select>
</div>
<div class="form-group"><label>Banner Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>
<div class="form-row">
<div class="form-group"><label>Position</label>
<select id="adminBannerPosition"><option value="both">Top &amp; Bottom</option><option value="top">Top only</option><option value="bottom">Bottom only</option></select>
</div>
</div>
<div class="form-row">
<div class="form-group"><label>Background</label><div class="color-input-wrap"><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" class="color-hex" value="#007a33" maxlength="7"></div></div>
<div class="form-group"><label>Text Color</label><div class="color-input-wrap"><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" class="color-hex" value="#ffffff" maxlength="7"></div></div>
</div>
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
</div>
</section>
<section class="admin-section">
<h4>🔐 Encryption</h4>
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
</section>
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div>
<!-- ── Roles Tab ─────────────────── -->
<div class="admin-tab-content" id="adminRolesTab" style="display:none">
<p style="color:var(--text-muted);margin:0 0 12px">Configure system model roles. Each role has a primary and optional fallback model.</p>
<div id="adminRolesContent">
<div class="loading">Loading roles...</div>
</div>
</div>
<!-- ── Usage Tab ─────────────────── -->
<div class="admin-tab-content" id="adminUsageTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:12px">
<select id="usagePeriod" style="min-width:100px">
<option value="7d">Last 7 days</option>
<option value="30d" selected>Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<select id="usageGroupBy" style="min-width:100px">
<option value="model">By Model</option>
<option value="user">By User</option>
<option value="day">By Day</option>
<option value="provider">By Provider</option>
</select>
<button class="btn-secondary btn-small" id="usageRefreshBtn">Refresh</button>
</div>
<div id="adminUsageTotals"></div>
<div id="adminUsageResults"></div>
<h3 style="margin-top:16px;font-size:14px">Model Pricing</h3>
<div id="adminPricingTable"></div>
</div>
<div class="admin-tab-content" id="adminExtensionsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminInstallExtBtn">+ Install Extension</button>
</div>
<div id="adminExtensionsList"></div>
<div id="adminInstallExtForm" style="display:none;margin-top:12px" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Extension ID</label><input type="text" id="extInstallId" placeholder="my-extension"></div>
<div class="form-group"><label>Name</label><input type="text" id="extInstallName" placeholder="My Extension"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Version</label><input type="text" id="extInstallVersion" placeholder="1.0.0" value="1.0.0"></div>
<div class="form-group"><label>Author</label><input type="text" id="extInstallAuthor"></div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="extInstallDesc"></div>
<div class="form-group"><label>Manifest JSON <span class="text-muted">(optional)</span></label><textarea id="extInstallManifest" rows="4" placeholder='{"tools":[], "permissions":[]}'></textarea></div>
<div class="form-group"><label>Script <span class="text-muted">(JavaScript source)</span></label><textarea id="extInstallScript" rows="6" placeholder="Extensions.register({ id: 'my-ext', init(ctx) { ... } });"></textarea></div>
<div class="form-row">
<label class="toggle-label"><input type="checkbox" id="extInstallSystem"> System (users can't disable)</label>
<label class="toggle-label"><input type="checkbox" id="extInstallEnabled" checked> Enabled</label>
</div>
<div class="form-actions">
<button class="btn-small" onclick="document.getElementById('adminInstallExtForm').style.display='none'">Cancel</button>
<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>
</div>
</div>
</div>
<div class="admin-tab-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:8px">
<select id="auditFilterAction" style="min-width:140px"><option value="">All actions</option></select>
<select id="auditFilterResource" style="min-width:120px">
<option value="">All types</option>
<option value="user">user</option>
<option value="team">team</option>
<option value="preset">preset</option>
<option value="channel">channel</option>
<option value="config">config</option>
</select>
<button class="btn-small" id="auditRefreshBtn">Refresh</button>
</div>
<div id="adminAuditList"></div>
<div class="pagination-row" id="auditPagination" style="display:none;margin-top:8px">
<button class="btn-small" id="auditPrevBtn">← Prev</button>
<span id="auditPageInfo" style="font-size:12px;color:var(--text-3)"></span>
<button class="btn-small" id="auditNextBtn">Next →</button>
</div>
</div>
</div>
</div>
</div>
<!-- ── Notes Modal ────────────────────────── -->
<!-- Debug Modal (Ctrl+Shift+L) -->
<div class="modal-overlay" id="debugModal">
<div class="modal debug-modal">
<div class="modal-header"><h2>Debug Log</h2><button class="modal-close" onclick="closeDebugModal()"></button></div>
<div class="modal-tabs debug-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
</div>
<div class="modal-body debug-modal-body">
<div class="debug-tab-content" id="debugConsoleTab">
<div class="debug-toolbar">
<label class="debug-check"><input type="checkbox" id="debugFilterErrors"> Errors only</label>
<label class="debug-check"><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
</div>
<div id="debugConsoleContent" class="debug-content"></div>
</div>
<div class="debug-tab-content" id="debugNetworkTab" style="display:none"><div id="debugNetworkContent" class="debug-content"></div></div>
<div class="debug-tab-content" id="debugStateTab" style="display:none"><div id="debugStateContent" class="debug-content"></div></div>
</div>
<div class="modal-footer debug-footer">
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
<button class="btn-small btn-danger" onclick="purgeCache()">Purge Cache</button>
<div style="margin-left:auto;display:flex;gap:0.5rem">
<button class="btn-small" onclick="clearDebugLog()">Clear</button>
<button class="btn-small" onclick="copyDebugLog()">Copy</button>
<button class="btn-small" onclick="exportDebugLog()">Export</button>
</div>
</div>
</div>
</div>
<!-- Command Palette (Ctrl+K) -->
<div class="cmd-palette-overlay" id="cmdPalette">
<div class="cmd-palette">
<div class="cmd-input-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="cmdInput" placeholder="Type a command…" autocomplete="off" spellcheck="false">
<kbd class="cmd-kbd">esc</kbd>
</div>
<div class="cmd-results" id="cmdResults"></div>
<div class="cmd-footer">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd></kbd> run</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer"></div>
<!-- Vendor libs -->
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
<script src="js/debug.js?v=%%APP_VERSION%%"></script>
<script src="js/events.js?v=%%APP_VERSION%%"></script>
<script src="js/extensions.js?v=%%APP_VERSION%%"></script>
<script src="js/api.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-core.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-settings.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
<script src="js/app.js?v=%%APP_VERSION%%"></script>
<script>
// ── Service Worker Registration ─────────
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').then(reg => {
console.log('SW registered, scope:', reg.scope);
// Check for updates periodically
setInterval(() => reg.update(), 60 * 60 * 1000);
}).catch(err => console.warn('SW registration failed:', err));
});
}
// ── PWA Install Prompt ──────────────────
let _deferredInstallPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
_deferredInstallPrompt = e;
_showInstallBanner();
});
function _showInstallBanner() {
if (document.getElementById('pwaInstallBanner')) return;
const banner = document.createElement('div');
banner.id = 'pwaInstallBanner';
banner.className = 'pwa-install-banner';
banner.innerHTML =
'<span>Install Chat Switchboard for a better experience</span>' +
'<button class="btn-small btn-primary" onclick="_installPWA()">Install</button>' +
'<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>';
document.body.appendChild(banner);
}
function _installPWA() {
if (!_deferredInstallPrompt) return;
_deferredInstallPrompt.prompt();
_deferredInstallPrompt.userChoice.then(() => {
_deferredInstallPrompt = null;
document.getElementById('pwaInstallBanner')?.remove();
});
}
window.addEventListener('appinstalled', () => {
document.getElementById('pwaInstallBanner')?.remove();
_deferredInstallPrompt = null;
});
</script>
<!-- Image lightbox overlay -->
<div class="lightbox-overlay" id="lightbox">
<button class="lightbox-close" title="Close (Esc)"></button>
<img id="lightboxImg" alt="">
</div>
</body>
</html>

View File

@@ -7,6 +7,24 @@
# switchboard-admin - username, password, email (optional)
# switchboard-encryption - ENCRYPTION_KEY (required for v0.9.4+)
#
# PVC:
# switchboard-storage - Extraction scratch (always needed)
# Requires ReadWriteMany (RWX) — see k8s/storage-pvc.yaml
# With S3 backend, PVC is small (extraction status only, not blobs)
#
# Variables (Gitea CI):
# STORAGE_CLASS - StorageClass for PVC (e.g. "cephfs")
# STORAGE_SIZE - PVC size (e.g. "10Gi" for PVC, "1Gi" for S3)
# STORAGE_BACKEND - "pvc" or "s3" (default: "pvc")
# EXTRACTION_CONCURRENCY - Max concurrent extractions (default "3")
# EXTRACTOR_IMAGE - Sidecar image (LibreOffice headless)
#
# S3 secrets (Gitea Secrets — only when STORAGE_BACKEND=s3):
# S3_ENDPOINT - e.g. "http://minio:9000" or Ceph RGW URL
# S3_BUCKET - bucket name (must exist before deploy)
# S3_ACCESS_KEY - S3 access key
# S3_SECRET_KEY - S3 secret key
#
# Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars
# are set, the backend upserts an admin user. Change the secret and
# restart the pod to reset the admin password.
@@ -96,6 +114,57 @@ spec:
name: switchboard-encryption
key: ENCRYPTION_KEY
optional: true
# File storage (v0.12.0+)
- name: STORAGE_BACKEND
value: "${STORAGE_BACKEND}"
- name: STORAGE_PATH
value: "/data/storage"
- name: EXTRACTION_MODE
value: "sidecar"
- name: EXTRACTION_CONCURRENCY
value: "${EXTRACTION_CONCURRENCY}"
# S3 storage (optional — only used when STORAGE_BACKEND=s3)
- name: S3_ENDPOINT
valueFrom:
secretKeyRef:
name: switchboard-s3
key: S3_ENDPOINT
optional: true
- name: S3_BUCKET
valueFrom:
secretKeyRef:
name: switchboard-s3
key: S3_BUCKET
optional: true
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: switchboard-s3
key: S3_ACCESS_KEY
optional: true
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: switchboard-s3
key: S3_SECRET_KEY
optional: true
- name: S3_REGION
valueFrom:
secretKeyRef:
name: switchboard-s3
key: S3_REGION
optional: true
- name: S3_PREFIX
valueFrom:
secretKeyRef:
name: switchboard-s3
key: S3_PREFIX
optional: true
- name: S3_FORCE_PATH_STYLE
value: "true"
volumeMounts:
- name: storage
mountPath: /data/storage
resources:
requests:
memory: "${BE_MEMORY_REQUEST}"
@@ -119,6 +188,34 @@ spec:
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
# Extractor sidecar (v0.12.0 Phase 2):
# Uncomment when switchboard-extractor image is built.
# See DESIGN-0.12.0.md — Phase 2: Extraction Pipeline.
#
# - name: extractor
# image: ${EXTRACTOR_IMAGE}:${IMAGE_TAG}
# imagePullPolicy: Always
# env:
# - name: STORAGE_PATH
# value: "/data/storage"
# - name: EXTRACTION_CONCURRENCY
# value: "${EXTRACTION_CONCURRENCY}"
# volumeMounts:
# - name: storage
# mountPath: /data/storage
# resources:
# requests:
# memory: "256Mi"
# cpu: "100m"
# limits:
# memory: "1Gi"
# cpu: "1000m"
volumes:
- name: storage
persistentVolumeClaim:
claimName: switchboard-storage${DEPLOY_SUFFIX}
---
apiVersion: v1
kind: Service

32
k8s/storage-pvc.yaml Normal file
View File

@@ -0,0 +1,32 @@
# k8s/storage-pvc.yaml
# ============================================
# Chat Switchboard - Storage PVC
# ============================================
# Persistent storage for file attachments, extraction queue,
# and future knowledge base / compaction data.
#
# Requires ReadWriteMany (RWX) for multi-pod backend deployments.
# CephFS is recommended; NFS also works.
#
# Variables:
# NAMESPACE - deployment namespace
# STORAGE_CLASS - StorageClass name (e.g. "cephfs")
# STORAGE_SIZE - PVC size (e.g. "10Gi")
# ENVIRONMENT - deployment environment label
# ============================================
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: switchboard-storage${DEPLOY_SUFFIX}
namespace: ${NAMESPACE}
labels:
app: switchboard
component: storage
env: ${ENVIRONMENT}
spec:
accessModes:
- ReadWriteMany
storageClassName: "${STORAGE_CLASS}"
resources:
requests:
storage: ${STORAGE_SIZE}

508
main.go Normal file
View File

@@ -0,0 +1,508 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/extraction"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
)
func main() {
// ── Subcommand dispatch ──────────────────
// Usage: switchboard vault rekey
if len(os.Args) > 2 && os.Args[1] == "vault" {
runVaultCommand(os.Args[2])
return
}
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Println("switchboard", Version)
return
}
// ── Server startup ──────────────────────
cfg := config.Load()
// Register LLM providers
providers.Init()
var stores store.Stores
uekCache := crypto.NewUEKCache()
var keyResolver *crypto.KeyResolver
var objStore storage.ObjectStore
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
log.Println(" Running in unmanaged mode (no persistence)")
} else {
// Schema check: init if fresh, upgrade if behind, proceed if current
if err := database.Migrate(); err != nil {
log.Fatalf("❌ Schema migration failed: %v", err)
}
// Vault: enforce encryption key + backfill plaintext keys
if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil {
log.Fatalf("❌ Vault check failed: %v", err)
}
if cfg.EncryptionKey != "" {
if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil {
log.Fatalf("❌ Vault backfill failed: %v", err)
}
}
// Derive env key for key resolver (nil if not set)
var envKey []byte
if cfg.EncryptionKey != "" {
var err error
envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey)
if err != nil {
log.Fatalf("❌ Failed to derive encryption key: %v", err)
}
log.Println(" 🔐 API key encryption active")
}
keyResolver = crypto.NewKeyResolver(envKey, uekCache)
// Initialize store layer
stores = postgres.NewStores(database.DB)
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores)
// Seed additional users from env (dev/test only, skipped in production)
handlers.SeedUsers(cfg, stores)
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
}
defer database.Close()
// ── File Storage ─────────────────────────
// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
// overrides auto-detection. nil objStore = storage features disabled.
if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath); err != nil {
if cfg.StorageBackend != "" {
// Explicit backend requested but failed — fatal
log.Fatalf("❌ Storage init failed: %v", err)
}
log.Printf("⚠ Storage init failed: %v", err)
} else {
objStore = s
}
handlers.SetStorageConfigured(objStore != nil)
// ── Extraction Queue ────────────────────
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
// Nil if storage is disabled.
var extQueue *extraction.Queue
if objStore != nil && cfg.StoragePath != "" {
q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
if err != nil {
log.Printf("⚠ Extraction queue init failed: %v", err)
} else {
extQueue = q
// Recover items stuck in "processing" from previous crash
if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil {
log.Printf("⚠ Extraction recovery failed: %v", err)
} else if recovered > 0 {
log.Printf(" 📋 Recovered %d stale extraction items", recovered)
}
}
}
// Role resolver for model role dispatch (needs stores + vault)
roleResolver := roles.NewResolver(stores, keyResolver)
r := gin.Default()
r.Use(middleware.CORS())
// ── Base path group ──────────────────────
base := r.Group(cfg.BasePath)
// ── EventBus + WebSocket Hub ─────────────
bus := events.NewBus()
hub := events.NewHub(bus)
// Health check (k8s probes hit this directly)
base.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"version": Version,
"database": database.IsConnected(),
"schema_version": database.SchemaVersion(),
})
})
// WebSocket endpoint
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg, stores, uekCache)
authLimiter := middleware.NewRateLimiter(1, 5)
api := base.Group("/api/v1")
{
// Health (routable through ingress)
api.GET("/health", func(c *gin.Context) {
info := gin.H{
"status": "ok",
"version": Version,
"schema_version": database.SchemaVersion(),
"database": database.IsConnected(),
"providers": providers.List(),
}
if database.IsConnected() {
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
}
c.JSON(200, info)
})
authGroup := api.Group("/auth")
authGroup.Use(authLimiter.Limit())
{
authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login)
authGroup.POST("/refresh", auth.Refresh)
authGroup.POST("/logout", auth.Logout)
}
// ── Public extension assets ────────────────
// Script tags can't send Authorization headers, so asset serving must be public.
// Extension JS is the same for all users — no user-specific data.
extH := handlers.NewExtensionHandler(stores)
api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset)
// ── Protected routes ────────────────────
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
{
// Channels
channels := handlers.NewChannelHandler()
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
// Message tree (forking)
protected.GET("/channels/:id/path", msgs.GetActivePath)
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore)
protected.POST("/chat/completions", comp.Complete)
// Summarize & Continue
summarize := handlers.NewSummarizeHandler(stores, roleResolver)
protected.POST("/channels/:id/summarize", summarize.Summarize)
// Provider Configs (user-facing — replaces /api-configs)
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
protected.POST("/api-configs", provCfg.CreateConfig)
protected.GET("/api-configs/:id", provCfg.GetConfig)
protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
protected.GET("/api-configs/:id/models", provCfg.ListModels)
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
// Models (unified resolver — replaces scattered endpoints)
modelH := handlers.NewModelHandler(stores)
protected.GET("/models/enabled", modelH.ListEnabledModels)
protected.GET("/models", modelH.ListEnabledModels) // alias
// Model Preferences
modelPrefs := handlers.NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// User Settings & Profile
settings := handlers.NewSettingsHandler(uekCache)
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)
protected.POST("/profile/avatar", settings.UploadAvatar)
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Usage (personal)
usage := handlers.NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage)
// Personas (replaces /presets)
personas := handlers.NewPersonaHandler(stores)
protected.GET("/presets", personas.ListUserPersonas) // backward compat
protected.POST("/presets", personas.CreateUserPersona)
protected.PUT("/presets/:id", personas.UpdateUserPersona)
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Notes
notes := handlers.NewNoteHandler()
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
// Attachments (file upload/download)
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
protected.POST("/channels/:id/attachments", attachH.Upload)
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
protected.GET("/attachments/:id", attachH.GetMetadata)
protected.GET("/attachments/:id/download", attachH.Download)
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
// Hook: clean up storage files when channels are deleted
handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage)
// Teams (user: my teams)
teams := handlers.NewTeamHandler(keyResolver)
protected.GET("/teams/mine", teams.MyTeams)
// Team admin self-service
teamScoped := protected.Group("/teams/:teamId")
teamScoped.Use(middleware.RequireTeamAdmin())
{
teamScoped.GET("/members", teams.ListMembers)
teamScoped.POST("/members", teams.AddMember)
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
teamScoped.GET("/models", teams.ListAvailableModels)
// Team providers
teamScoped.GET("/providers", teams.ListTeamProviders)
teamScoped.POST("/providers", teams.CreateTeamProvider)
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
// Team audit log (team admins only — RequireTeamAdmin on group)
teamScoped.GET("/audit", teams.ListTeamAuditLog)
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
// Team usage (team admins only — usage against team-owned providers)
teamUsage := handlers.NewUsageHandler(stores)
teamScoped.GET("/usage", teamUsage.TeamUsage)
// Team personas
teamPersonas := handlers.NewPersonaHandler(stores)
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
// Team role overrides
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
}
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
protected.GET("/settings/public", adm.PublicSettings)
// Extensions (user-facing)
protected.GET("/extensions", extH.ListUserExtensions)
protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings)
protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest)
protected.GET("/extensions/tools", extH.ListBrowserToolSchemas)
}
// ── Admin routes ────────────────────────
admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg))
admin.Use(middleware.RequireAdmin())
{
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
// User management
admin.GET("/users", adm.ListUsers)
admin.POST("/users", adm.CreateUser)
admin.PUT("/users/:id/role", adm.UpdateUserRole)
admin.PUT("/users/:id/active", adm.ToggleUserActive)
admin.POST("/users/:id/reset-password", adm.ResetPassword)
admin.DELETE("/users/:id", adm.DeleteUser)
// Global settings
admin.GET("/settings", adm.ListGlobalSettings)
admin.GET("/settings/:key", adm.GetGlobalSetting)
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
// Stats
admin.GET("/stats", adm.GetStats)
// Global Provider Configs
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
// Model Catalog
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/bulk", adm.BulkUpdateModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
// Personas (admin global)
personaAdm := handlers.NewPersonaHandler(stores)
admin.GET("/presets", personaAdm.ListAdminPersonas)
admin.POST("/presets", personaAdm.CreateAdminPersona)
admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona)
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Teams (admin)
teamAdm := handlers.NewTeamHandler(keyResolver)
admin.GET("/teams", teamAdm.ListTeams)
admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam)
admin.PUT("/teams/:id", teamAdm.UpdateTeam)
admin.DELETE("/teams/:id", teamAdm.DeleteTeam)
admin.GET("/teams/:id/members", teamAdm.ListMembers)
admin.POST("/teams/:id/members", teamAdm.AddMember)
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
// Model Roles
rolesH := handlers.NewRolesHandler(stores, roleResolver)
admin.GET("/roles", rolesH.ListRoles)
admin.GET("/roles/:role", rolesH.GetRole)
admin.PUT("/roles/:role", rolesH.UpdateRole)
admin.POST("/roles/:role/test", rolesH.TestRole)
// Usage & Pricing
usageH := handlers.NewUsageHandler(stores)
admin.GET("/usage", usageH.AdminUsage)
admin.GET("/usage/users/:id", usageH.AdminUserUsage)
admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
admin.GET("/pricing", usageH.ListPricing)
admin.PUT("/pricing", usageH.UpsertPricing)
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
// Storage status
storageH := handlers.NewStorageHandler(objStore)
admin.GET("/storage/status", storageH.Status)
// Vault
admin.GET("/vault/status", adm.VaultStatus)
// Storage management (orphan cleanup)
attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue)
admin.GET("/storage/orphans", attachAdm.OrphanCount)
admin.POST("/storage/cleanup", attachAdm.CleanupOrphans)
admin.GET("/storage/extraction", attachAdm.ExtractionStatus)
// Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores)
admin.GET("/extensions", extAdm.AdminListExtensions)
admin.POST("/extensions", extAdm.AdminInstallExtension)
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
}
}
bp := cfg.BasePath
if bp == "" {
bp = "/"
}
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
log.Printf(" Base path: %s", bp)
log.Printf(" Schema: %s", database.SchemaVersion())
log.Printf(" Providers: %v", providers.List())
if objStore != nil {
log.Printf(" Storage: %s", objStore.Backend())
if extQueue != nil {
log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
} else {
log.Printf(" Extraction: disabled")
}
} else {
log.Printf(" Storage: disabled")
}
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
// ── Vault CLI Commands ──────────────────────
func runVaultCommand(subcmd string) {
cfg := config.Load()
switch strings.ToLower(subcmd) {
case "rekey":
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required for vault operations")
}
if err := database.Connect(cfg); err != nil {
log.Fatalf("❌ Database connection failed: %v", err)
}
defer database.Close()
oldKey := os.Getenv("ENCRYPTION_KEY")
newKey := os.Getenv("NEW_ENCRYPTION_KEY")
if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil {
log.Fatalf("❌ Vault rekey failed: %v", err)
}
case "status":
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required for vault operations")
}
if err := database.Connect(cfg); err != nil {
log.Fatalf("❌ Database connection failed: %v", err)
}
defer database.Close()
status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey)
if err != nil {
log.Fatalf("❌ Failed to get vault status: %v", err)
}
fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet)
fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys)
fmt.Printf("Vault users (active): %d\n", status.VaultUsers)
default:
fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd)
fmt.Fprintf(os.Stderr, "Usage: switchboard vault <rekey|status>\n")
os.Exit(1)
}
}

115
rekey.go Normal file
View File

@@ -0,0 +1,115 @@
package crypto
import (
"database/sql"
"fmt"
"log"
)
// Rekey re-encrypts all global/team API keys from oldKey to newKey.
// Personal BYOK keys are unaffected (they're keyed to the user's UEK,
// not the environment-derived key).
//
// Runs in a single transaction — all-or-nothing. On failure, no keys
// are modified.
//
// Usage:
//
// switchboard vault rekey
// ENCRYPTION_KEY = current/old key
// NEW_ENCRYPTION_KEY = new key to re-encrypt with
func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error {
if oldEnvKey == "" {
return fmt.Errorf("ENCRYPTION_KEY (current key) is required")
}
if newEnvKey == "" {
return fmt.Errorf("NEW_ENCRYPTION_KEY is required")
}
if oldEnvKey == newEnvKey {
return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do")
}
oldKey, err := DeriveKeyFromEnv(oldEnvKey)
if err != nil {
return fmt.Errorf("failed to derive old key: %w", err)
}
newKey, err := DeriveKeyFromEnv(newEnvKey)
if err != nil {
return fmt.Errorf("failed to derive new key: %w", err)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() // no-op after commit
// Find all global/team keys that are encrypted
rows, err := tx.Query(`
SELECT id, api_key_enc, key_nonce
FROM provider_configs
WHERE key_scope IN ('global', 'team')
AND api_key_enc IS NOT NULL
`)
if err != nil {
return fmt.Errorf("failed to query encrypted keys: %w", err)
}
type rekeyRow struct {
id string
ciphertext []byte
nonce []byte
}
var toRekey []rekeyRow
for rows.Next() {
var r rekeyRow
if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil {
rows.Close()
return fmt.Errorf("failed to scan row: %w", err)
}
toRekey = append(toRekey, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("row iteration error: %w", err)
}
if len(toRekey) == 0 {
log.Println("No global/team encrypted keys found — nothing to rekey.")
return nil
}
log.Printf("Rekeying %d provider config(s)...", len(toRekey))
// Decrypt with old key, re-encrypt with new key, update in place
for _, r := range toRekey {
plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey)
if err != nil {
return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err)
}
newCiphertext, newNonce, err := Encrypt(plaintext, newKey)
if err != nil {
return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err)
}
_, err = tx.Exec(`
UPDATE provider_configs
SET api_key_enc = $1, key_nonce = $2
WHERE id = $3
`, newCiphertext, newNonce, r.id)
if err != nil {
return fmt.Errorf("failed to update config %s: %w", r.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit rekey transaction: %w", err)
}
log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey))
log.Println("Update ENCRYPTION_KEY to the new value and restart the server.")
return nil
}

View File

@@ -1,4 +1,4 @@
# Chat Switchboard v0.9 - Server Environment Variables
# Chat Switchboard v0.12 - Server Environment Variables
# Copy this file to .env and fill in the values
# ── Server ───────────────────────────────────
@@ -44,3 +44,22 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
# ── Logging ──────────────────────────────────
LOG_LEVEL=info
# ── File Storage ─────────────────────────────
# Backend: "pvc" (local filesystem), "s3" (S3-compatible), or empty (auto-detect)
STORAGE_BACKEND=
STORAGE_PATH=/data/storage
# S3 settings (only when STORAGE_BACKEND=s3)
# Works with MinIO, Ceph RGW, AWS S3.
# S3_ENDPOINT=http://minio:9000
# S3_BUCKET=switchboard-storage
# S3_ACCESS_KEY=
# S3_SECRET_KEY=
# S3_REGION=us-east-1
# S3_PREFIX= # optional key prefix for shared buckets
# S3_FORCE_PATH_STYLE=true # required for MinIO, Ceph RGW
# ── Extraction ───────────────────────────────
# EXTRACTION_MODE=inline # "inline" or "sidecar"
# EXTRACTION_CONCURRENCY=3

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"strconv"
"github.com/joho/godotenv"
)
@@ -26,6 +27,28 @@ type Config struct {
// Used to derive AES-256 key for global/team provider API keys.
// Personal keys use per-user UEK (derived from password).
EncryptionKey string
// File storage (v0.12.0+)
// STORAGE_BACKEND: "pvc" or "s3". Empty = auto-detect (pvc if path writable).
// STORAGE_PATH: mount point for PVC backend (default /data/storage).
StorageBackend string
StoragePath string
// S3-compatible storage (v0.12.0+)
// Works with AWS S3, MinIO, Ceph RGW, or any S3-compatible API.
S3Endpoint string // custom endpoint URL (required for MinIO/Ceph, optional for AWS)
S3Bucket string // bucket name (required when STORAGE_BACKEND=s3)
S3Region string // AWS region (default "us-east-1")
S3AccessKey string // access key ID
S3SecretKey string // secret access key
S3Prefix string // optional key prefix within bucket (e.g. "switchboard/")
S3ForcePathStyle bool // use path-style URLs (required for MinIO, most self-hosted)
// Extraction pipeline (v0.12.0+)
// EXTRACTION_MODE: "inline" (in-process) or "sidecar" (shared PVC watcher).
// EXTRACTION_CONCURRENCY: max concurrent extraction jobs (default 3).
ExtractionMode string
ExtractionConcurrency int
}
// Load reads configuration from environment variables.
@@ -45,6 +68,18 @@ func Load() *Config {
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
SeedUsers: getEnv("SEED_USERS", ""),
EncryptionKey: getEnv("ENCRYPTION_KEY", ""),
StorageBackend: getEnv("STORAGE_BACKEND", ""),
StoragePath: getEnv("STORAGE_PATH", "/data/storage"),
S3Endpoint: getEnv("S3_ENDPOINT", ""),
S3Bucket: getEnv("S3_BUCKET", ""),
S3Region: getEnv("S3_REGION", "us-east-1"),
S3AccessKey: getEnv("S3_ACCESS_KEY", ""),
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
S3Prefix: getEnv("S3_PREFIX", ""),
S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true",
ExtractionMode: getEnv("EXTRACTION_MODE", "inline"),
ExtractionConcurrency: getEnvInt("EXTRACTION_CONCURRENCY", 3),
}
}
@@ -71,3 +106,12 @@ func getEnv(key, fallback string) string {
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}

115
server/crypto/rekey.go Normal file
View File

@@ -0,0 +1,115 @@
package crypto
import (
"database/sql"
"fmt"
"log"
)
// Rekey re-encrypts all global/team API keys from oldKey to newKey.
// Personal BYOK keys are unaffected (they're keyed to the user's UEK,
// not the environment-derived key).
//
// Runs in a single transaction — all-or-nothing. On failure, no keys
// are modified.
//
// Usage:
//
// switchboard vault rekey
// ENCRYPTION_KEY = current/old key
// NEW_ENCRYPTION_KEY = new key to re-encrypt with
func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error {
if oldEnvKey == "" {
return fmt.Errorf("ENCRYPTION_KEY (current key) is required")
}
if newEnvKey == "" {
return fmt.Errorf("NEW_ENCRYPTION_KEY is required")
}
if oldEnvKey == newEnvKey {
return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do")
}
oldKey, err := DeriveKeyFromEnv(oldEnvKey)
if err != nil {
return fmt.Errorf("failed to derive old key: %w", err)
}
newKey, err := DeriveKeyFromEnv(newEnvKey)
if err != nil {
return fmt.Errorf("failed to derive new key: %w", err)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() // no-op after commit
// Find all global/team keys that are encrypted
rows, err := tx.Query(`
SELECT id, api_key_enc, key_nonce
FROM provider_configs
WHERE key_scope IN ('global', 'team')
AND api_key_enc IS NOT NULL
`)
if err != nil {
return fmt.Errorf("failed to query encrypted keys: %w", err)
}
type rekeyRow struct {
id string
ciphertext []byte
nonce []byte
}
var toRekey []rekeyRow
for rows.Next() {
var r rekeyRow
if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil {
rows.Close()
return fmt.Errorf("failed to scan row: %w", err)
}
toRekey = append(toRekey, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("row iteration error: %w", err)
}
if len(toRekey) == 0 {
log.Println("No global/team encrypted keys found — nothing to rekey.")
return nil
}
log.Printf("Rekeying %d provider config(s)...", len(toRekey))
// Decrypt with old key, re-encrypt with new key, update in place
for _, r := range toRekey {
plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey)
if err != nil {
return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err)
}
newCiphertext, newNonce, err := Encrypt(plaintext, newKey)
if err != nil {
return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err)
}
_, err = tx.Exec(`
UPDATE provider_configs
SET api_key_enc = $1, key_nonce = $2
WHERE id = $3
`, newCiphertext, newNonce, r.id)
if err != nil {
return fmt.Errorf("failed to update config %s: %w", r.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit rekey transaction: %w", err)
}
log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey))
log.Println("Update ENCRYPTION_KEY to the new value and restart the server.")
return nil
}

46
server/crypto/status.go Normal file
View File

@@ -0,0 +1,46 @@
package crypto
import (
"database/sql"
"fmt"
)
// VaultStatusInfo holds vault health information for admin display.
type VaultStatusInfo struct {
EncryptionKeySet bool `json:"encryption_key_set"`
EncryptedKeys int `json:"encrypted_keys"` // provider_configs with api_key_enc
VaultUsers int `json:"vault_users"` // users with vault_set = true
}
// VaultStatus gathers vault health metrics from the database.
// Used by both the CLI (`switchboard vault status`) and the admin API endpoint.
func VaultStatus(db *sql.DB, encryptionKey string) (*VaultStatusInfo, error) {
if db == nil {
return nil, fmt.Errorf("database not available")
}
info := &VaultStatusInfo{
EncryptionKeySet: encryptionKey != "",
}
// Count encrypted provider keys (global + team + personal)
err := db.QueryRow(`
SELECT COUNT(*) FROM provider_configs
WHERE api_key_enc IS NOT NULL
`).Scan(&info.EncryptedKeys)
if err != nil {
return nil, fmt.Errorf("count encrypted keys: %w", err)
}
// Count users with active vaults
err = db.QueryRow(`
SELECT COUNT(*) FROM users
WHERE vault_set = true
`).Scan(&info.VaultUsers)
if err != nil {
// vault_set column might not exist on very old installs
info.VaultUsers = 0
}
return info, nil
}

View File

@@ -0,0 +1,32 @@
-- 007_attachments.sql
-- File attachments for chat messages (v0.12.0)
--
-- Blobs live in object storage (PVC / S3). This table holds metadata.
-- Access control: always join through channels — attachments inherit
-- channel membership as their access boundary.
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()
);
-- Primary access pattern: find attachments for a channel (auth check joins here)
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
-- Quota calculation: SUM(size_bytes) WHERE user_id = $1
CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id);
-- Find attachments for a specific message
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id) WHERE message_id IS NOT NULL;
-- Orphan cleanup: find unlinked attachments older than threshold
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE message_id IS NULL;

294
server/extraction/queue.go Normal file
View File

@@ -0,0 +1,294 @@
package extraction
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
)
// ── Status Constants ───────────────────────
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusComplete = "complete"
StatusFailed = "failed"
StatusSkipped = "skipped" // not extractable (images, etc.)
)
// ── Queue Item ─────────────────────────────
// QueueItem is the status.json written to the processing directory.
// The sidecar extractor watches this directory for pending items.
type QueueItem struct {
AttachmentID string `json:"attachment_id"`
StorageKey string `json:"storage_key"`
ContentType string `json:"content_type"`
Filename string `json:"filename"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
OutputKey string `json:"output_key,omitempty"` // path to extracted text
QueuedAt string `json:"queued_at"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
}
// ── Queue Manager ──────────────────────────
// Queue manages the filesystem-based extraction queue.
// Processing state lives in {storagePath}/processing/{attachment_id}/status.json.
// The sidecar extractor watches for pending items and updates status.
type Queue struct {
storagePath string
concurrency int
sem chan struct{} // semaphore for concurrent extraction limit
mu sync.Mutex
}
// NewQueue creates a new extraction queue manager.
// storagePath is the PVC mount (e.g., /data/storage).
// concurrency limits parallel extractions (default 3).
func NewQueue(storagePath string, concurrency int) (*Queue, error) {
if concurrency <= 0 {
concurrency = 3
}
procDir := filepath.Join(storagePath, "processing")
if err := os.MkdirAll(procDir, 0750); err != nil {
return nil, fmt.Errorf("extraction: create processing dir: %w", err)
}
return &Queue{
storagePath: storagePath,
concurrency: concurrency,
sem: make(chan struct{}, concurrency),
}, nil
}
// Enqueue adds an attachment to the extraction queue.
// Creates {storagePath}/processing/{id}/status.json with status "pending".
func (q *Queue) Enqueue(attachmentID, storageKey, contentType, filename string) error {
q.mu.Lock()
defer q.mu.Unlock()
itemDir := filepath.Join(q.storagePath, "processing", attachmentID)
if err := os.MkdirAll(itemDir, 0750); err != nil {
return fmt.Errorf("extraction: create item dir: %w", err)
}
item := QueueItem{
AttachmentID: attachmentID,
StorageKey: storageKey,
ContentType: contentType,
Filename: filename,
Status: StatusPending,
QueuedAt: time.Now().UTC().Format(time.RFC3339),
}
return q.writeStatus(attachmentID, &item)
}
// GetStatus reads the current extraction status for an attachment.
func (q *Queue) GetStatus(attachmentID string) (*QueueItem, error) {
statusPath := filepath.Join(q.storagePath, "processing", attachmentID, "status.json")
data, err := os.ReadFile(statusPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // not queued
}
return nil, fmt.Errorf("extraction: read status: %w", err)
}
var item QueueItem
if err := json.Unmarshal(data, &item); err != nil {
return nil, fmt.Errorf("extraction: parse status: %w", err)
}
return &item, nil
}
// MarkComplete updates status to complete and records the output key.
func (q *Queue) MarkComplete(attachmentID, outputKey string) error {
q.mu.Lock()
defer q.mu.Unlock()
item, err := q.GetStatus(attachmentID)
if err != nil || item == nil {
return fmt.Errorf("extraction: item not found: %s", attachmentID)
}
item.Status = StatusComplete
item.OutputKey = outputKey
item.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return q.writeStatus(attachmentID, item)
}
// MarkFailed updates status to failed with an error message.
func (q *Queue) MarkFailed(attachmentID, errMsg string) error {
q.mu.Lock()
defer q.mu.Unlock()
item, err := q.GetStatus(attachmentID)
if err != nil || item == nil {
return fmt.Errorf("extraction: item not found: %s", attachmentID)
}
item.Status = StatusFailed
item.Error = errMsg
item.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return q.writeStatus(attachmentID, item)
}
// Cleanup removes the processing directory for a completed/failed item.
func (q *Queue) Cleanup(attachmentID string) error {
itemDir := filepath.Join(q.storagePath, "processing", attachmentID)
return os.RemoveAll(itemDir)
}
// RecoverStale scans for items stuck in "processing" state (crash recovery).
// Items older than maxAge are reset to "pending" for re-processing.
func (q *Queue) RecoverStale(maxAge time.Duration) (int, error) {
procDir := filepath.Join(q.storagePath, "processing")
entries, err := os.ReadDir(procDir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
recovered := 0
cutoff := time.Now().Add(-maxAge)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
item, err := q.GetStatus(entry.Name())
if err != nil || item == nil {
continue
}
if item.Status != StatusProcessing {
continue
}
// Check if started_at is older than maxAge
if item.StartedAt != "" {
startedAt, err := time.Parse(time.RFC3339, item.StartedAt)
if err == nil && startedAt.Before(cutoff) {
q.mu.Lock()
item.Status = StatusPending
item.StartedAt = ""
item.Error = "recovered from stale processing state"
q.writeStatus(entry.Name(), item)
q.mu.Unlock()
recovered++
log.Printf("extraction: recovered stale item %s", entry.Name())
}
}
}
return recovered, nil
}
// ListPending returns all items with status "pending".
func (q *Queue) ListPending() ([]QueueItem, error) {
return q.listByStatus(StatusPending)
}
// ListAll returns all items in the processing directory.
func (q *Queue) ListAll() ([]QueueItem, error) {
procDir := filepath.Join(q.storagePath, "processing")
entries, err := os.ReadDir(procDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var items []QueueItem
for _, entry := range entries {
if !entry.IsDir() {
continue
}
item, err := q.GetStatus(entry.Name())
if err != nil || item == nil {
continue
}
items = append(items, *item)
}
return items, nil
}
// ── Internal Helpers ───────────────────────
func (q *Queue) writeStatus(attachmentID string, item *QueueItem) error {
statusPath := filepath.Join(q.storagePath, "processing", attachmentID, "status.json")
data, err := json.MarshalIndent(item, "", " ")
if err != nil {
return err
}
// Atomic write: temp + rename
tmpPath := statusPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0640); err != nil {
return err
}
return os.Rename(tmpPath, statusPath)
}
func (q *Queue) listByStatus(status string) ([]QueueItem, error) {
all, err := q.ListAll()
if err != nil {
return nil, err
}
var filtered []QueueItem
for _, item := range all {
if item.Status == status {
filtered = append(filtered, item)
}
}
return filtered, nil
}
// ── Extractable Check ──────────────────────
// IsExtractable returns true if the content type supports text extraction.
// Images and plain text do not need extraction (handled inline by upload handler).
var extractableTypes = map[string]bool{
"application/pdf": true,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
"application/msword": true,
"application/vnd.ms-excel": true,
"application/vnd.oasis.opendocument.text": true,
"application/vnd.oasis.opendocument.spreadsheet": true,
"application/vnd.oasis.opendocument.presentation": true,
"application/rtf": true,
}
// IsExtractable returns true if the given content type requires sidecar extraction.
func IsExtractable(contentType string) bool {
return extractableTypes[contentType]
}
// ── Null Queue ─────────────────────────────
// NullQueue is a no-op implementation for when extraction is disabled.
type NullQueue struct{}
func (NullQueue) Enqueue(_, _, _, _ string) error { return nil }
func (NullQueue) GetStatus(_ string) (*QueueItem, error) { return nil, nil }
func (NullQueue) MarkComplete(_, _ string) error { return nil }
func (NullQueue) MarkFailed(_, _ string) error { return nil }
func (NullQueue) Cleanup(_ string) error { return nil }
func (NullQueue) RecoverStale(_ time.Duration) (int, error) { return 0, nil }
func (NullQueue) ListPending() ([]QueueItem, error) { return nil, nil }
func (NullQueue) ListAll() ([]QueueItem, error) { return nil, nil }

View File

@@ -0,0 +1,240 @@
package extraction
import (
"os"
"path/filepath"
"testing"
"time"
)
func tempQueue(t *testing.T) *Queue {
t.Helper()
dir := t.TempDir()
q, err := NewQueue(dir, 3)
if err != nil {
t.Fatalf("NewQueue: %v", err)
}
return q
}
func TestEnqueue_CreatesStatusFile(t *testing.T) {
q := tempQueue(t)
err := q.Enqueue("att-123", "attachments/ch/att-123_doc.pdf", "application/pdf", "doc.pdf")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
item, err := q.GetStatus("att-123")
if err != nil {
t.Fatalf("GetStatus: %v", err)
}
if item == nil {
t.Fatal("expected non-nil item")
}
if item.Status != StatusPending {
t.Errorf("status = %q, want %q", item.Status, StatusPending)
}
if item.AttachmentID != "att-123" {
t.Errorf("attachment_id = %q, want att-123", item.AttachmentID)
}
if item.ContentType != "application/pdf" {
t.Errorf("content_type = %q, want application/pdf", item.ContentType)
}
}
func TestGetStatus_NotQueued(t *testing.T) {
q := tempQueue(t)
item, err := q.GetStatus("nonexistent")
if err != nil {
t.Fatalf("GetStatus: %v", err)
}
if item != nil {
t.Error("expected nil for non-queued item")
}
}
func TestMarkComplete(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-456", "key", "application/pdf", "test.pdf")
err := q.MarkComplete("att-456", "processing/att-456/extracted.txt")
if err != nil {
t.Fatalf("MarkComplete: %v", err)
}
item, _ := q.GetStatus("att-456")
if item.Status != StatusComplete {
t.Errorf("status = %q, want %q", item.Status, StatusComplete)
}
if item.OutputKey != "processing/att-456/extracted.txt" {
t.Errorf("output_key = %q", item.OutputKey)
}
if item.CompletedAt == "" {
t.Error("completed_at should be set")
}
}
func TestMarkFailed(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-789", "key", "application/pdf", "test.pdf")
err := q.MarkFailed("att-789", "libreoffice crashed")
if err != nil {
t.Fatalf("MarkFailed: %v", err)
}
item, _ := q.GetStatus("att-789")
if item.Status != StatusFailed {
t.Errorf("status = %q, want %q", item.Status, StatusFailed)
}
if item.Error != "libreoffice crashed" {
t.Errorf("error = %q", item.Error)
}
}
func TestCleanup_RemovesDirectory(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-cleanup", "key", "application/pdf", "test.pdf")
// Verify directory exists
dir := filepath.Join(q.storagePath, "processing", "att-cleanup")
if _, err := os.Stat(dir); err != nil {
t.Fatal("expected processing dir to exist before cleanup")
}
if err := q.Cleanup("att-cleanup"); err != nil {
t.Fatalf("Cleanup: %v", err)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Error("expected processing dir to be removed after cleanup")
}
}
func TestListPending(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-a", "key-a", "application/pdf", "a.pdf")
q.Enqueue("att-b", "key-b", "application/pdf", "b.pdf")
q.MarkComplete("att-b", "out")
pending, err := q.ListPending()
if err != nil {
t.Fatalf("ListPending: %v", err)
}
if len(pending) != 1 {
t.Fatalf("expected 1 pending, got %d", len(pending))
}
if pending[0].AttachmentID != "att-a" {
t.Errorf("pending[0].AttachmentID = %q, want att-a", pending[0].AttachmentID)
}
}
func TestListAll(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-1", "key-1", "application/pdf", "1.pdf")
q.Enqueue("att-2", "key-2", "application/pdf", "2.pdf")
q.Enqueue("att-3", "key-3", "application/pdf", "3.pdf")
all, err := q.ListAll()
if err != nil {
t.Fatalf("ListAll: %v", err)
}
if len(all) != 3 {
t.Errorf("expected 3 items, got %d", len(all))
}
}
func TestRecoverStale(t *testing.T) {
q := tempQueue(t)
// Create item and manually set it to "processing" with old timestamp
q.Enqueue("att-stale", "key", "application/pdf", "stale.pdf")
item, _ := q.GetStatus("att-stale")
item.Status = StatusProcessing
item.StartedAt = time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339)
q.mu.Lock()
q.writeStatus("att-stale", item)
q.mu.Unlock()
// Recover items stale for > 1 hour
recovered, err := q.RecoverStale(1 * time.Hour)
if err != nil {
t.Fatalf("RecoverStale: %v", err)
}
if recovered != 1 {
t.Errorf("recovered = %d, want 1", recovered)
}
// Verify it's back to pending
item, _ = q.GetStatus("att-stale")
if item.Status != StatusPending {
t.Errorf("status = %q, want %q", item.Status, StatusPending)
}
}
func TestRecoverStale_SkipsRecent(t *testing.T) {
q := tempQueue(t)
// Create item "processing" but recent (not stale)
q.Enqueue("att-recent", "key", "application/pdf", "recent.pdf")
item, _ := q.GetStatus("att-recent")
item.Status = StatusProcessing
item.StartedAt = time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339)
q.mu.Lock()
q.writeStatus("att-recent", item)
q.mu.Unlock()
recovered, err := q.RecoverStale(1 * time.Hour)
if err != nil {
t.Fatalf("RecoverStale: %v", err)
}
if recovered != 0 {
t.Errorf("recovered = %d, want 0 (item is recent)", recovered)
}
}
func TestIsExtractable(t *testing.T) {
tests := []struct {
contentType string
want bool
}{
{"application/pdf", true},
{"application/vnd.openxmlformats-officedocument.wordprocessingml.document", true},
{"image/png", false},
{"text/plain", false},
{"image/jpeg", false},
{"application/rtf", true},
}
for _, tc := range tests {
got := IsExtractable(tc.contentType)
if got != tc.want {
t.Errorf("IsExtractable(%q) = %v, want %v", tc.contentType, got, tc.want)
}
}
}
func TestAtomicWrite(t *testing.T) {
q := tempQueue(t)
// Enqueue and verify the file is valid JSON
q.Enqueue("att-atomic", "key", "application/pdf", "test.pdf")
statusPath := filepath.Join(q.storagePath, "processing", "att-atomic", "status.json")
data, err := os.ReadFile(statusPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
// Verify no .tmp file left behind
tmpPath := statusPath + ".tmp"
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Error("temp file should not exist after atomic write")
}
if len(data) == 0 {
t.Error("status.json should not be empty")
}
}

View File

@@ -8,6 +8,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/minio/minio-go/v7 v7.0.82
golang.org/x/crypto v0.14.0
)

View File

@@ -280,6 +280,7 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
"banner": banner,
"branding": branding,
"has_admin_prompt": hasAdminPrompt,
"storage_configured": storageConfigured,
"policies": gin.H{
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],
@@ -288,6 +289,22 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
})
}
// ── Vault Status ────────────────────────────
func (h *AdminHandler) VaultStatus(c *gin.Context) {
hasKey := h.vault != nil && h.vault.HasEnvKey()
keyStr := ""
if hasKey {
keyStr = "set" // non-empty signals to VaultStatus that key is configured
}
status, err := crypto.VaultStatus(database.DB, keyStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"})
return
}
c.JSON(http.StatusOK, status)
}
// ── Provider Configs (Global) ───────────────
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {

View File

@@ -0,0 +1,476 @@
package handlers
import (
"context"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/extraction"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Default Limits ─────────────────────────
// Overridable via global_settings keys.
const (
defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file)
defaultMaxAttachmentsPerMsg = 5 // future: multi-file per message
defaultOrphanMaxAge = 24 * time.Hour
)
// allowedMIMETypes is the default allowlist. Admin can override via global_settings.
var allowedMIMETypes = map[string]bool{
// Images
"image/jpeg": true, "image/png": true, "image/gif": true,
"image/webp": true, "image/svg+xml": true,
// Documents
"application/pdf": true,
"text/plain": true, "text/markdown": true, "text/csv": true,
// Microsoft Office
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
"application/msword": true, "application/vnd.ms-excel": true,
// OpenDocument
"application/vnd.oasis.opendocument.text": true,
"application/vnd.oasis.opendocument.spreadsheet": true,
"application/vnd.oasis.opendocument.presentation": true,
// Other
"application/rtf": true,
}
// ── Handler ────────────────────────────────
type AttachmentHandler struct {
stores store.Stores
objStore storage.ObjectStore
extQueue *extraction.Queue // nil if extraction disabled
}
func NewAttachmentHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *AttachmentHandler {
return &AttachmentHandler{stores: stores, objStore: objStore, extQueue: extQueue}
}
// ── Upload ─────────────────────────────────
// POST /api/v1/channels/:id/attachments
// Multipart form: file field "file", returns attachment metadata.
func (h *AttachmentHandler) Upload(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
channelID := c.Param("id")
// Verify channel ownership
if !h.verifyChannelAccess(c, channelID, userID) {
return
}
// Parse multipart
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
// Size check
if header.Size > defaultMaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file too large (max %d MB)", defaultMaxFileSize/(1024*1024)),
})
return
}
// MIME detection: read first 512 bytes for sniffing, then reset
buf := make([]byte, 512)
n, _ := file.Read(buf)
detectedType := http.DetectContentType(buf[:n])
// Reset reader to start
if _, err := file.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"})
return
}
// Normalize MIME type (strip params like charset)
contentType := detectedType
if idx := strings.Index(contentType, ";"); idx > 0 {
contentType = strings.TrimSpace(contentType[:idx])
}
// For types that DetectContentType can't distinguish (returns application/octet-stream),
// fall back to extension-based detection
if contentType == "application/octet-stream" {
ext := strings.ToLower(filepath.Ext(header.Filename))
if mapped, ok := extToMIME[ext]; ok {
contentType = mapped
}
}
// Allowlist check
if !allowedMIMETypes[contentType] {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file type %q not allowed", contentType),
})
return
}
// Build storage key: attachments/{channel_id}/{attachment_id}_{filename}
// We generate the ID first via a temp UUID, then use it in the key.
att := &models.Attachment{
ChannelID: channelID,
UserID: userID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
SizeBytes: header.Size,
Metadata: models.JSONMap{
"extraction_status": "pending",
},
}
// Create PG row first to get the UUID
// storage_key is set after we have the ID
att.StorageKey = "placeholder"
if err := h.stores.Attachments.Create(c.Request.Context(), att); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create attachment record"})
return
}
// Now build the real storage key and update
att.StorageKey = fmt.Sprintf("attachments/%s/%s_%s", channelID, att.ID, att.Filename)
// Write to object store
if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil {
// Rollback PG row on storage failure
h.stores.Attachments.Delete(c.Request.Context(), att.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
// Update storage_key in PG
database.DB.ExecContext(c.Request.Context(),
`UPDATE attachments SET storage_key = $1 WHERE id = $2`,
att.StorageKey, att.ID)
// For images, mark extraction as not needed (complete immediately)
if strings.HasPrefix(contentType, "image/") {
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
"extraction_status": "complete",
})
att.Metadata["extraction_status"] = "complete"
}
// For text/plain, extract inline (trivial — just read the file)
if contentType == "text/plain" || contentType == "text/markdown" || contentType == "text/csv" {
if _, err := file.Seek(0, io.SeekStart); err == nil {
if textBytes, err := io.ReadAll(file); err == nil {
text := string(textBytes)
h.stores.Attachments.SetExtractedText(c.Request.Context(), att.ID, text)
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
"extraction_status": "complete",
})
att.Metadata["extraction_status"] = "complete"
}
}
}
// For documents requiring extraction (PDF, DOCX, etc.), enqueue for sidecar
if extraction.IsExtractable(contentType) && h.extQueue != nil {
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
log.Printf("extraction enqueue failed for %s: %v", att.ID, err)
// Non-fatal: file is uploaded, just won't have extracted text
}
}
c.JSON(http.StatusCreated, att)
}
// ── Download ───────────────────────────────
// GET /api/v1/attachments/:id/download
// Streams file content with auth check via channel membership.
func (h *AttachmentHandler) Download(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
return
}
// Channel-scoped access check
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
reader, size, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
defer reader.Close()
c.Header("Content-Type", att.ContentType)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename))
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.Status(http.StatusOK)
io.Copy(c.Writer, reader)
}
// ── Get Metadata ───────────────────────────
// GET /api/v1/attachments/:id
func (h *AttachmentHandler) GetMetadata(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
return
}
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
c.JSON(http.StatusOK, att)
}
// ── List Channel Attachments ───────────────
// GET /api/v1/channels/:id/attachments
func (h *AttachmentHandler) ListByChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !h.verifyChannelAccess(c, channelID, userID) {
return
}
attachments, err := h.stores.Attachments.GetByChannel(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list attachments"})
return
}
if attachments == nil {
attachments = []models.Attachment{}
}
c.JSON(http.StatusOK, gin.H{"attachments": attachments})
}
// ── Delete ─────────────────────────────────
// DELETE /api/v1/attachments/:id
func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
return
}
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
// Delete from PG (returns the row for storage cleanup)
deleted, err := h.stores.Attachments.Delete(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete attachment"})
return
}
// Clean up storage (async-safe: fire and forget with background context)
if h.objStore != nil && deleted != nil {
storageKey := deleted.StorageKey
go func() {
ctx := context.Background()
if err := h.objStore.Delete(ctx, storageKey); err != nil {
log.Printf("storage cleanup failed for %s: %v", storageKey, err)
}
// Also clean up thumbnail if it exists
h.objStore.Delete(ctx, storageKey+"_thumb.jpg")
}()
}
c.JSON(http.StatusOK, gin.H{"message": "attachment deleted"})
}
// ── Admin: Orphan Cleanup ──────────────────
// POST /admin/storage/cleanup
func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"})
return
}
deleted := 0
var freedBytes int64
for _, att := range orphans {
if _, err := h.stores.Attachments.Delete(c.Request.Context(), att.ID); err != nil {
log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err)
continue
}
if h.objStore != nil {
h.objStore.Delete(c.Request.Context(), att.StorageKey)
h.objStore.Delete(c.Request.Context(), att.StorageKey+"_thumb.jpg")
}
deleted++
freedBytes += att.SizeBytes
}
c.JSON(http.StatusOK, gin.H{
"deleted": deleted,
"freed_bytes": freedBytes,
"scanned": len(orphans),
})
}
// ── Admin: Orphan Count ────────────────────
// GET /admin/storage/orphans (for the admin panel card)
func (h *AttachmentHandler) OrphanCount(c *gin.Context) {
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"})
return
}
var totalBytes int64
for _, att := range orphans {
totalBytes += att.SizeBytes
}
c.JSON(http.StatusOK, gin.H{
"count": len(orphans),
"reclaimable_bytes": totalBytes,
})
}
// ── Admin: Extraction Queue Status ─────────
// GET /admin/storage/extraction
func (h *AttachmentHandler) ExtractionStatus(c *gin.Context) {
if h.extQueue == nil {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"items": []interface{}{},
})
return
}
items, err := h.extQueue.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list extraction queue"})
return
}
if items == nil {
items = []extraction.QueueItem{}
}
// Count by status
counts := map[string]int{}
for _, item := range items {
counts[item.Status]++
}
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"total": len(items),
"counts": counts,
"items": items,
})
}
// ── Channel Delete Hook ────────────────────
// Called by ChannelHandler.DeleteChannel to clean up storage.
func (h *AttachmentHandler) CleanupChannelStorage(channelID string) {
if h.objStore == nil {
return
}
// CASCADE already deleted PG rows. Clean up filesystem.
prefix := fmt.Sprintf("attachments/%s", channelID)
if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil {
log.Printf("storage cleanup for channel %s failed: %v", channelID, err)
}
}
// ── Helpers ────────────────────────────────
// verifyChannelAccess checks that the requesting user owns the channel.
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
func (h *AttachmentHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(),
`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return false
}
if ownerID != userID {
// Check if user is admin (admins can access any channel)
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return false
}
}
return true
}
// sanitizeFilename cleans a filename for safe storage.
func sanitizeFilename(name string) string {
// Take only the base name (strip path separators)
name = filepath.Base(name)
// Replace problematic characters
replacer := strings.NewReplacer(
"/", "_", "\\", "_", "..", "_", "\x00", "",
)
name = replacer.Replace(name)
if name == "" || name == "." {
name = "unnamed"
}
// Truncate to 200 chars (leave room for UUID prefix in storage key)
if len(name) > 200 {
ext := filepath.Ext(name)
name = name[:200-len(ext)] + ext
}
return name
}
// extToMIME maps file extensions to MIME types for cases where
// http.DetectContentType returns application/octet-stream.
var extToMIME = map[string]string{
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".doc": "application/msword",
".xls": "application/vnd.ms-excel",
".odt": "application/vnd.oasis.opendocument.text",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".odp": "application/vnd.oasis.opendocument.presentation",
".rtf": "application/rtf",
".md": "text/markdown",
".csv": "text/csv",
".svg": "image/svg+xml",
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"database/sql"
"encoding/json"
"math"
"net/http"
"strconv"
@@ -36,6 +37,7 @@ type updateChannelRequest struct {
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
}
type channelResponse struct {
@@ -51,6 +53,7 @@ type channelResponse struct {
IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"`
Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
@@ -72,6 +75,16 @@ func NewChannelHandler() *ChannelHandler {
return &ChannelHandler{}
}
// channelDeleteHook is called after a channel is successfully deleted.
// Set at startup by SetChannelDeleteHook to clean up storage files.
var channelDeleteHook func(channelID string)
// SetChannelDeleteHook registers a callback invoked after channel deletion.
// Used to clean up attachment files on the storage backend.
func SetChannelDeleteHook(fn func(channelID string)) {
channelDeleteHook = fn
}
// ── Helpers ─────────────────────────────────
// getUserID extracts the authenticated user's ID from context.
@@ -141,7 +154,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
// Fetch channels with message count
query := `
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM channels c
@@ -186,7 +199,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
err := rows.Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags),
pq.Array(&tags), &ch.Settings,
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
@@ -236,13 +249,13 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, tags, created_at, updated_at
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags),
).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.CreatedAt, &ch.UpdatedAt,
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
@@ -284,7 +297,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
var tags []string
err := database.DB.QueryRow(`
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM channels c
@@ -295,7 +308,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
`, channelID, userID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags),
pq.Array(&tags), &ch.Settings,
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
@@ -383,6 +396,12 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
if req.Tags != nil {
addClause("tags", pq.Array(req.Tags))
}
if req.Settings != nil {
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
args = append(args, []byte(*req.Settings))
argN++
}
if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
@@ -430,5 +449,10 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
return
}
// Clean up storage files (CASCADE already removed PG attachment rows)
if channelDeleteHook != nil {
go channelDeleteHook(channelID)
}
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
}

View File

@@ -3,21 +3,24 @@ package handlers
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -34,6 +37,7 @@ type completionRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream *bool `json:"stream,omitempty"`
AttachmentIDs []string `json:"attachment_ids,omitempty"` // staged attachment UUIDs to include in request
}
// CompletionHandler proxies LLM requests through the backend.
@@ -41,11 +45,12 @@ type CompletionHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub // WebSocket hub for browser tool bridge
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
}
// NewCompletionHandler creates a new handler.
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores, hub: hub}
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
}
// ── Chat Completion ─────────────────────────
@@ -138,26 +143,53 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return
}
// Add the new user message
messages = append(messages, providers.Message{
// Resolve capabilities early — needed for vision gating below
caps := h.getModelCapabilities(c, model, configID)
// Build user message — multimodal if attachments are present
userMsg := providers.Message{
Role: "user",
Content: req.Content,
})
}
// Persist user message
if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil); err != nil {
if len(req.AttachmentIDs) > 0 && h.objStore != nil {
parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.AttachmentIDs, caps)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(parts) > 0 {
// Multimodal (images present): use ContentParts
userMsg.ContentParts = parts
} else if augContent != req.Content {
// Doc-only: use augmented text content
userMsg.Content = augContent
}
}
messages = append(messages, userMsg)
// Persist user message (text-only for storage — multimodal parts are ephemeral)
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil)
if err != nil {
log.Printf("Failed to persist user message: %v", err)
}
// Link attachments to the persisted message
if msgID != "" && len(req.AttachmentIDs) > 0 {
for _, attID := range req.AttachmentIDs {
if err := h.stores.Attachments.SetMessageID(c.Request.Context(), attID, msgID); err != nil {
log.Printf("Failed to link attachment %s to message %s: %v", attID, msgID, err)
}
}
}
// Build provider request
provReq := providers.CompletionRequest{
Model: model,
Messages: messages,
}
// Resolve capabilities for this model — auto-set defaults
caps := h.getModelCapabilities(c, model, configID)
if req.MaxTokens > 0 {
provReq.MaxTokens = req.MaxTokens
} else {
@@ -405,6 +437,128 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
return ResolveModelCaps(c, model, apiConfigID)
}
// ── Multimodal Assembly ─────────────────────
// Builds content parts from attachments for the user message.
//
// Returns:
// - parts: ContentParts array (non-nil only when images are present)
// - augContent: enriched text content with document context (for doc-only case)
// - validIDs: attachment IDs that were successfully processed
// - error: if any attachment is invalid or vision is needed but missing
//
// Rules:
// - Images → base64 data URI (requires vision capability)
// - Documents with extracted_text → text injection
// - Documents without extraction → filename placeholder
// - Text is always the first part
func (h *CompletionHandler) buildMultimodalParts(
c *gin.Context,
channelID, textContent string,
attachmentIDs []string,
caps models.ModelCapabilities,
) ([]providers.ContentPart, string, []string, error) {
parts := []providers.ContentPart{
{Type: "text", Text: textContent},
}
var docTexts []string
var validIDs []string
hasImage := false
for _, attID := range attachmentIDs {
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
if err != nil {
return nil, "", nil, fmt.Errorf("attachment %s not found", attID)
}
// Security: verify attachment belongs to this channel
if att.ChannelID != channelID {
return nil, "", nil, fmt.Errorf("attachment %s does not belong to this channel", attID)
}
if isImageContentType(att.ContentType) {
// Vision gating: reject images if model lacks vision
if !caps.Vision {
return nil, "", nil, fmt.Errorf("model does not support image input; remove image attachments or choose a vision-capable model")
}
// Read image from storage, base64 encode, build data URI
reader, _, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
if err != nil {
log.Printf("Failed to read attachment %s from storage: %v", attID, err)
parts = append(parts, providers.ContentPart{
Type: "text",
Text: fmt.Sprintf("[Image: %s — failed to read from storage]", att.Filename),
})
validIDs = append(validIDs, attID)
continue
}
data, err := io.ReadAll(reader)
reader.Close()
if err != nil {
log.Printf("Failed to read attachment %s bytes: %v", attID, err)
validIDs = append(validIDs, attID)
continue
}
b64 := base64.StdEncoding.EncodeToString(data)
dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
parts = append(parts, providers.ContentPart{
Type: "image_url",
ImageURL: &providers.ImageURL{
URL: dataURI,
Detail: "auto",
},
})
hasImage = true
} else if att.ExtractedText != nil && *att.ExtractedText != "" {
// Document with extracted text → inject as context
docText := fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText)
parts = append(parts, providers.ContentPart{
Type: "text",
Text: docText,
})
docTexts = append(docTexts, docText)
} else {
// Document without extraction (pending, failed, or not extractable)
status := "pending"
if s, ok := att.Metadata["extraction_status"].(string); ok {
status = s
}
placeholder := fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status)
parts = append(parts, providers.ContentPart{
Type: "text",
Text: placeholder,
})
docTexts = append(docTexts, placeholder)
}
validIDs = append(validIDs, attID)
}
// If images present → use ContentParts (multimodal array)
if hasImage {
return parts, textContent, validIDs, nil
}
// Doc-only: merge document context into a single text string (more efficient)
augmented := textContent
for _, dt := range docTexts {
augmented += "\n\n" + dt
}
return nil, augmented, validIDs, nil
}
// isImageContentType returns true for MIME types that should be sent as
// base64 image content parts rather than text extraction.
func isImageContentType(ct string) bool {
return strings.HasPrefix(ct, "image/")
}
// ── Config Resolution ───────────────────────
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config

View File

@@ -17,7 +17,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store/postgres"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
)
// ── Test Harness ────────────────────────────
@@ -132,9 +132,19 @@ func setupHarness(t *testing.T) *testHarness {
channels := NewChannelHandler()
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Attachments (nil storage = upload returns 503, but metadata works)
attachH := NewAttachmentHandler(stores, nil, nil)
protected.POST("/channels/:id/attachments", attachH.Upload)
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
protected.GET("/attachments/:id", attachH.GetMetadata)
protected.GET("/attachments/:id/download", attachH.Download)
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
// Completions
completions := NewCompletionHandler(nil, stores, nil)
completions := NewCompletionHandler(nil, stores, nil, nil)
protected.POST("/chat/completions", completions.Complete)
// Admin routes

View File

@@ -15,6 +15,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -61,11 +62,12 @@ type MessageHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub
objStore storage.ObjectStore
}
// NewMessageHandler creates a new message handler.
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores, hub: hub}
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
}
// ── List Messages (flat, all branches) ──────
@@ -360,7 +362,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Resolve model + provider ──
comp := NewCompletionHandler(h.vault, h.stores, h.hub)
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
var presetSystemPrompt string
model := req.Model

View File

@@ -0,0 +1,62 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/storage"
)
// storageConfigured is a package-level flag set during init.
// Read by PublicSettings to include in the boot payload.
var storageConfigured bool
// SetStorageConfigured sets the package-level flag indicating whether
// file storage is available. Called from main.go after storage init.
func SetStorageConfigured(configured bool) {
storageConfigured = configured
}
// StorageHandler handles file storage admin endpoints.
type StorageHandler struct {
store storage.ObjectStore
}
// NewStorageHandler creates a StorageHandler.
// store may be nil if storage is not configured.
func NewStorageHandler(store storage.ObjectStore) *StorageHandler {
return &StorageHandler{store: store}
}
// Configured returns true if a storage backend is available.
func (h *StorageHandler) Configured() bool {
return h.store != nil
}
// Status returns storage backend status and statistics.
//
// GET /admin/storage/status
func (h *StorageHandler) Status(c *gin.Context) {
if h.store == nil {
c.JSON(http.StatusOK, gin.H{
"backend": "none",
"configured": false,
"healthy": false,
})
return
}
stats, err := h.store.Stats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to collect storage stats",
"backend": h.store.Backend(),
"configured": true,
"healthy": false,
})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -1,7 +1,11 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -9,16 +13,30 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/extraction"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
)
func main() {
// ── Subcommand dispatch ──────────────────
// Usage: switchboard vault rekey
if len(os.Args) > 2 && os.Args[1] == "vault" {
runVaultCommand(os.Args[2])
return
}
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Println("switchboard", Version)
return
}
// ── Server startup ──────────────────────
cfg := config.Load()
// Register LLM providers
@@ -27,6 +45,7 @@ func main() {
var stores store.Stores
uekCache := crypto.NewUEKCache()
var keyResolver *crypto.KeyResolver
var objStore storage.ObjectStore
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
@@ -73,6 +92,51 @@ func main() {
}
defer database.Close()
// ── File Storage ─────────────────────────
// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
// overrides auto-detection. nil objStore = storage features disabled.
var s3Cfg *storage.S3Config
if cfg.S3Bucket != "" {
s3Cfg = &storage.S3Config{
Endpoint: cfg.S3Endpoint,
Bucket: cfg.S3Bucket,
Region: cfg.S3Region,
AccessKey: cfg.S3AccessKey,
SecretKey: cfg.S3SecretKey,
Prefix: cfg.S3Prefix,
ForcePathStyle: cfg.S3ForcePathStyle,
}
}
if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath, s3Cfg); err != nil {
if cfg.StorageBackend != "" {
// Explicit backend requested but failed — fatal
log.Fatalf("❌ Storage init failed: %v", err)
}
log.Printf("⚠ Storage init failed: %v", err)
} else {
objStore = s
}
handlers.SetStorageConfigured(objStore != nil)
// ── Extraction Queue ────────────────────
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
// Nil if storage is disabled.
var extQueue *extraction.Queue
if objStore != nil && cfg.StoragePath != "" {
q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
if err != nil {
log.Printf("⚠ Extraction queue init failed: %v", err)
} else {
extQueue = q
// Recover items stuck in "processing" from previous crash
if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil {
log.Printf("⚠ Extraction recovery failed: %v", err)
} else if recovered > 0 {
log.Printf(" 📋 Recovered %d stale extraction items", recovered)
}
}
}
// Role resolver for model role dispatch (needs stores + vault)
roleResolver := roles.NewResolver(stores, keyResolver)
@@ -148,7 +212,7 @@ func main() {
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
msgs := handlers.NewMessageHandler(keyResolver, stores, hub)
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
@@ -160,7 +224,7 @@ func main() {
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores, hub)
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore)
protected.POST("/chat/completions", comp.Complete)
// Summarize & Continue
@@ -222,6 +286,17 @@ func main() {
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
// Attachments (file upload/download)
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
protected.POST("/channels/:id/attachments", attachH.Upload)
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
protected.GET("/attachments/:id", attachH.GetMetadata)
protected.GET("/attachments/:id/download", attachH.Download)
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
// Hook: clean up storage files when channels are deleted
handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage)
// Teams (user: my teams)
teams := handlers.NewTeamHandler(keyResolver)
protected.GET("/teams/mine", teams.MyTeams)
@@ -352,6 +427,19 @@ func main() {
admin.PUT("/pricing", usageH.UpsertPricing)
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
// Storage status
storageH := handlers.NewStorageHandler(objStore)
admin.GET("/storage/status", storageH.Status)
// Vault
admin.GET("/vault/status", adm.VaultStatus)
// Storage management (orphan cleanup)
attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue)
admin.GET("/storage/orphans", attachAdm.OrphanCount)
admin.POST("/storage/cleanup", attachAdm.CleanupOrphans)
admin.GET("/storage/extraction", attachAdm.ExtractionStatus)
// Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores)
admin.GET("/extensions", extAdm.AdminListExtensions)
@@ -369,8 +457,64 @@ func main() {
log.Printf(" Base path: %s", bp)
log.Printf(" Schema: %s", database.SchemaVersion())
log.Printf(" Providers: %v", providers.List())
if objStore != nil {
log.Printf(" Storage: %s", objStore.Backend())
if extQueue != nil {
log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
} else {
log.Printf(" Extraction: disabled")
}
} else {
log.Printf(" Storage: disabled")
}
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
// ── Vault CLI Commands ──────────────────────
func runVaultCommand(subcmd string) {
cfg := config.Load()
switch strings.ToLower(subcmd) {
case "rekey":
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required for vault operations")
}
if err := database.Connect(cfg); err != nil {
log.Fatalf("❌ Database connection failed: %v", err)
}
defer database.Close()
oldKey := os.Getenv("ENCRYPTION_KEY")
newKey := os.Getenv("NEW_ENCRYPTION_KEY")
if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil {
log.Fatalf("❌ Vault rekey failed: %v", err)
}
case "status":
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required for vault operations")
}
if err := database.Connect(cfg); err != nil {
log.Fatalf("❌ Database connection failed: %v", err)
}
defer database.Close()
status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey)
if err != nil {
log.Fatalf("❌ Failed to get vault status: %v", err)
}
fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet)
fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys)
fmt.Printf("Vault users (active): %d\n", status.VaultUsers)
default:
fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd)
fmt.Fprintf(os.Stderr, "Usage: switchboard vault <rekey|status>\n")
os.Exit(1)
}
}

View File

@@ -389,6 +389,24 @@ type Note struct {
TeamID *string `json:"team_id,omitempty" db:"team_id"`
}
// =========================================
// ATTACHMENTS
// =========================================
type Attachment struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
MessageID *string `json:"message_id,omitempty" db:"message_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:"-" db:"storage_key"` // never expose filesystem path
ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// =========================================
// AUDIT LOG
// =========================================

View File

@@ -263,6 +263,40 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
Content: m.Content,
},
}
} else if len(m.ContentParts) > 0 && m.Role == "user" {
// Multimodal user message: convert ContentParts to Anthropic blocks
for _, p := range m.ContentParts {
switch p.Type {
case "image_url":
if p.ImageURL != nil {
// Parse data URI: "data:image/jpeg;base64,{data}"
mediaType, b64Data := parseDataURI(p.ImageURL.URL)
if b64Data != "" {
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
Type: "image",
Source: &anthropicImageSource{
Type: "base64",
MediaType: mediaType,
Data: b64Data,
},
})
}
}
default: // "text", "document"
if p.Text != "" {
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
Type: "text",
Text: p.Text,
})
}
}
}
if len(antMsg.Content) == 0 {
// Fallback: shouldn't happen, but safety net
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: m.Content},
}
}
} else {
// Regular text message
antMsg.Content = []anthropicContentBlock{
@@ -354,6 +388,8 @@ type anthropicContentBlock struct {
Type string `json:"type"`
// text
Text string `json:"text,omitempty"`
// image (type="image")
Source *anthropicImageSource `json:"source,omitempty"`
// tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
@@ -363,6 +399,15 @@ type anthropicContentBlock struct {
Content string `json:"content,omitempty"`
}
// anthropicImageSource is the Anthropic-native image format.
// Anthropic uses {"type":"base64","media_type":"image/jpeg","data":"..."}
// rather than OpenAI's data URI approach.
type anthropicImageSource struct {
Type string `json:"type"` // "base64"
MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc.
Data string `json:"data"` // raw base64 (no data: prefix)
}
type anthropicMessage struct {
Role string `json:"role"`
Content []anthropicContentBlock `json:"content"`
@@ -425,3 +470,33 @@ type anthropicStreamEvent struct {
Name string `json:"name"`
} `json:"content_block,omitempty"`
}
// parseDataURI splits a data URI into media type and base64 data.
// Input: "data:image/jpeg;base64,/9j/4AAQ..."
// Output: "image/jpeg", "/9j/4AAQ..."
// Returns empty strings if the URI is malformed.
func parseDataURI(uri string) (mediaType, data string) {
// Strip "data:" prefix
if !strings.HasPrefix(uri, "data:") {
return "", ""
}
rest := uri[5:]
// Split on comma (separates header from data)
commaIdx := strings.Index(rest, ",")
if commaIdx < 0 {
return "", ""
}
header := rest[:commaIdx]
data = rest[commaIdx+1:]
// Extract media type from header (before ";base64")
if semiIdx := strings.Index(header, ";"); semiIdx >= 0 {
mediaType = header[:semiIdx]
} else {
mediaType = header
}
return mediaType, data
}

View File

@@ -0,0 +1,202 @@
package providers
import (
"encoding/json"
"testing"
)
// ── ContentPart Tests ──────────────────────
func TestContentPart_TextOnly(t *testing.T) {
msg := Message{
Role: "user",
Content: "hello",
ContentParts: []ContentPart{
{Type: "text", Text: "hello"},
},
}
if len(msg.ContentParts) != 1 {
t.Fatalf("expected 1 part, got %d", len(msg.ContentParts))
}
if msg.ContentParts[0].Type != "text" {
t.Errorf("type = %q, want text", msg.ContentParts[0].Type)
}
}
func TestContentPart_WithImage(t *testing.T) {
msg := Message{
Role: "user",
Content: "describe this",
ContentParts: []ContentPart{
{Type: "text", Text: "describe this"},
{Type: "image_url", ImageURL: &ImageURL{URL: "data:image/png;base64,abc123", Detail: "auto"}},
},
}
if len(msg.ContentParts) != 2 {
t.Fatalf("expected 2 parts, got %d", len(msg.ContentParts))
}
if msg.ContentParts[1].ImageURL == nil {
t.Fatal("expected non-nil ImageURL")
}
if msg.ContentParts[1].ImageURL.URL != "data:image/png;base64,abc123" {
t.Errorf("URL = %q", msg.ContentParts[1].ImageURL.URL)
}
}
// ── OpenAI Multimodal Serialization ────────
func TestOpenAI_TextOnlyContent_String(t *testing.T) {
// When Content is a string, it should serialize as a JSON string
msg := openaiMessage{
Role: "user",
Content: "hello world",
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var raw map[string]interface{}
json.Unmarshal(data, &raw)
content := raw["content"]
if s, ok := content.(string); !ok || s != "hello world" {
t.Errorf("content = %v (%T), want string 'hello world'", content, content)
}
}
func TestOpenAI_MultimodalContent_Array(t *testing.T) {
// When Content is an array, it should serialize as a JSON array
msg := openaiMessage{
Role: "user",
Content: []openaiContentPart{
{Type: "text", Text: "describe this"},
{Type: "image_url", ImageURL: &openaiImageURL{URL: "data:image/png;base64,abc", Detail: "auto"}},
},
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var raw map[string]interface{}
json.Unmarshal(data, &raw)
content := raw["content"]
arr, ok := content.([]interface{})
if !ok {
t.Fatalf("content is %T, want array", content)
}
if len(arr) != 2 {
t.Fatalf("content has %d elements, want 2", len(arr))
}
// Verify first part is text
part0 := arr[0].(map[string]interface{})
if part0["type"] != "text" {
t.Errorf("part[0].type = %v, want text", part0["type"])
}
// Verify second part is image_url
part1 := arr[1].(map[string]interface{})
if part1["type"] != "image_url" {
t.Errorf("part[1].type = %v, want image_url", part1["type"])
}
}
// ── Anthropic Image Source ─────────────────
func TestAnthropic_ImageSourceBlock(t *testing.T) {
block := anthropicContentBlock{
Type: "image",
Source: &anthropicImageSource{
Type: "base64",
MediaType: "image/jpeg",
Data: "abc123",
},
}
data, err := json.Marshal(block)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var raw map[string]interface{}
json.Unmarshal(data, &raw)
if raw["type"] != "image" {
t.Errorf("type = %v", raw["type"])
}
source := raw["source"].(map[string]interface{})
if source["type"] != "base64" {
t.Errorf("source.type = %v", source["type"])
}
if source["media_type"] != "image/jpeg" {
t.Errorf("source.media_type = %v", source["media_type"])
}
if source["data"] != "abc123" {
t.Errorf("source.data = %v", source["data"])
}
}
func TestAnthropic_TextBlockUnchanged(t *testing.T) {
block := anthropicContentBlock{
Type: "text",
Text: "hello",
}
data, err := json.Marshal(block)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var raw map[string]interface{}
json.Unmarshal(data, &raw)
if raw["type"] != "text" {
t.Errorf("type = %v", raw["type"])
}
if raw["text"] != "hello" {
t.Errorf("text = %v", raw["text"])
}
// Should NOT have source field
if _, ok := raw["source"]; ok {
t.Error("text block should not have source field")
}
}
// ── parseDataURI ───────────────────────────
func TestParseDataURI_Valid(t *testing.T) {
tests := []struct {
uri string
wantType string
wantData string
}{
{"data:image/jpeg;base64,/9j/4AAQ", "image/jpeg", "/9j/4AAQ"},
{"data:image/png;base64,abc123", "image/png", "abc123"},
{"data:image/webp;base64,RIFF", "image/webp", "RIFF"},
}
for _, tc := range tests {
mediaType, data := parseDataURI(tc.uri)
if mediaType != tc.wantType {
t.Errorf("parseDataURI(%q) mediaType = %q, want %q", tc.uri, mediaType, tc.wantType)
}
if data != tc.wantData {
t.Errorf("parseDataURI(%q) data = %q, want %q", tc.uri, data, tc.wantData)
}
}
}
func TestParseDataURI_Invalid(t *testing.T) {
tests := []string{
"",
"not-a-data-uri",
"data:nocomma",
"https://example.com/image.png",
}
for _, uri := range tests {
mediaType, data := parseDataURI(uri)
if mediaType != "" || data != "" {
t.Errorf("parseDataURI(%q) = (%q, %q), want empty", uri, mediaType, data)
}
}
}

View File

@@ -37,8 +37,14 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig,
return nil, fmt.Errorf("no choices in response")
}
// Extract content string from response (responses are always string, not array)
var contentStr string
if s, ok := resp.Choices[0].Message.Content.(string); ok {
contentStr = s
}
result := &CompletionResponse{
Content: resp.Choices[0].Message.Content,
Content: contentStr,
Model: resp.Model,
FinishReason: resp.Choices[0].FinishReason,
InputTokens: resp.Usage.PromptTokens,
@@ -360,11 +366,42 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
})
}
// Convert messages — handle all roles including tool
// Convert messages — handle all roles including tool and multimodal
for _, m := range req.Messages {
oaiMsg := openaiMessage{
Role: m.Role,
Content: m.Content,
}
// Build content: multimodal parts or plain string
if len(m.ContentParts) > 0 && m.Role == "user" {
// Multimodal: convert to OpenAI content array format
parts := make([]openaiContentPart, 0, len(m.ContentParts))
for _, p := range m.ContentParts {
switch p.Type {
case "image_url":
if p.ImageURL != nil {
detail := p.ImageURL.Detail
if detail == "" {
detail = "auto"
}
parts = append(parts, openaiContentPart{
Type: "image_url",
ImageURL: &openaiImageURL{
URL: p.ImageURL.URL,
Detail: detail,
},
})
}
default: // "text", "document" → text
parts = append(parts, openaiContentPart{
Type: "text",
Text: p.Text,
})
}
}
oaiMsg.Content = parts
} else {
oaiMsg.Content = m.Content
}
// Assistant messages with tool calls
@@ -426,12 +463,25 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
type openaiMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
Name string `json:"name,omitempty"` // tool name for role=tool
}
// openaiContentPart represents a single element in a multimodal content array.
// OpenAI format: [{"type":"text","text":"..."}, {"type":"image_url","image_url":{"url":"..."}}]
type openaiContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *openaiImageURL `json:"image_url,omitempty"`
}
type openaiImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
type openaiToolCallFunction struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`

View File

@@ -52,12 +52,36 @@ type Message struct {
Role string `json:"role"` // user, assistant, system, tool
Content string `json:"content"`
// Multimodal content parts (v0.12.0+). When set, providers use these
// instead of Content for the user message. Content is still set as
// a fallback and for message persistence (extracted text summary).
ContentParts []ContentPart `json:"content_parts,omitempty"`
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
Name string `json:"name,omitempty"` // tool name for role="tool"
}
// ContentPart is a single element in a multimodal message.
// Providers convert these to their native wire format.
type ContentPart struct {
Type string `json:"type"` // "text", "image_url", "document"
// Text content (type="text")
Text string `json:"text,omitempty"`
// Image content (type="image_url") — base64 data URI
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL holds image data for multimodal requests.
// URL is a data URI: "data:image/jpeg;base64,..."
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
}
// ToolCall represents a function call requested by the LLM.
type ToolCall struct {
ID string `json:"id"`

78
server/storage/init.go Normal file
View File

@@ -0,0 +1,78 @@
package storage
import (
"fmt"
"log"
"os"
)
// Init creates an ObjectStore based on the configuration.
//
// Auto-detection when backend is empty:
// - If storagePath is writable → PVC (implicit)
// - If storagePath is missing/unwritable → nil (storage disabled)
//
// Explicit backends:
// - "pvc" → PVC, fail if path is not writable
// - "s3" → S3-compatible (MinIO, Ceph RGW, AWS), fail if not reachable
func Init(backend, storagePath string, s3Cfg *S3Config) (ObjectStore, error) {
switch backend {
case "pvc":
// Explicit PVC — fail if not writable
s, err := NewPVC(storagePath)
if err != nil {
return nil, fmt.Errorf("storage: PVC backend at %q: %w", storagePath, err)
}
log.Printf(" 📁 Storage: PVC at %s", storagePath)
return s, nil
case "s3":
if s3Cfg == nil {
return nil, fmt.Errorf("storage: S3 backend requires S3_BUCKET and credentials")
}
s, err := NewS3(*s3Cfg)
if err != nil {
return nil, fmt.Errorf("storage: S3 backend: %w", err)
}
endpoint := s3Cfg.Endpoint
if endpoint == "" {
endpoint = "AWS"
}
log.Printf(" 📁 Storage: S3 at %s/%s (prefix=%q)", endpoint, s3Cfg.Bucket, s3Cfg.Prefix)
return s, nil
case "":
// Auto-detect: try PVC if path exists or is creatable
s, err := NewPVC(storagePath)
if err != nil {
// Path not writable — storage disabled, not an error
log.Printf(" 📁 Storage: disabled (path %s not writable)", storagePath)
return nil, nil
}
log.Printf(" 📁 Storage: PVC at %s (auto-detected)", storagePath)
return s, nil
default:
return nil, fmt.Errorf("storage: unknown backend %q (expected pvc or s3)", backend)
}
}
// IsPathWritable checks if a directory path exists and is writable.
// Used by auto-detection. Does not create the directory.
func IsPathWritable(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
if !info.IsDir() {
return false
}
// Try writing a probe file
probe := path + "/.writable-probe"
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
return false
}
os.Remove(probe)
return true
}

278
server/storage/pvc.go Normal file
View File

@@ -0,0 +1,278 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
)
// ── PVC Store ──────────────────────────────
// PVCStore implements ObjectStore using the local filesystem.
// Used with Kubernetes PersistentVolumeClaims (CephFS, NFS, local PV)
// or plain Docker volume mounts.
type PVCStore struct {
basePath string
// Content-type cache: key → content type.
// Populated on Put, used by Get. PVC doesn't store metadata
// alongside files, so we keep MIME types in memory. On restart,
// Get falls back to "application/octet-stream" — the DB has the
// authoritative content_type for serving to clients.
mu sync.RWMutex
mimeMap map[string]string
}
// NewPVC creates a PVCStore rooted at basePath.
// Returns an error if the path does not exist or is not writable.
func NewPVC(basePath string) (*PVCStore, error) {
// Ensure base path exists
if err := os.MkdirAll(basePath, 0o755); err != nil {
return nil, fmt.Errorf("storage/pvc: cannot create base path %q: %w", basePath, err)
}
// Create well-known subdirectories
for _, sub := range []string{"attachments", "processing"} {
if err := os.MkdirAll(filepath.Join(basePath, sub), 0o755); err != nil {
return nil, fmt.Errorf("storage/pvc: cannot create %s dir: %w", sub, err)
}
}
s := &PVCStore{
basePath: basePath,
mimeMap: make(map[string]string),
}
// Validate writability
if err := s.Healthy(context.Background()); err != nil {
return nil, err
}
return s, nil
}
func (s *PVCStore) Backend() string { return "pvc" }
// Put writes data to the filesystem at the resolved key path.
func (s *PVCStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
if err := validateKey(key); err != nil {
return err
}
absPath := s.resolve(key)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return fmt.Errorf("storage/pvc: mkdir for %q: %w", key, err)
}
// Write to temp file first, then rename (atomic on same filesystem)
tmp, err := os.CreateTemp(filepath.Dir(absPath), ".upload-*")
if err != nil {
return fmt.Errorf("storage/pvc: create temp for %q: %w", key, err)
}
tmpName := tmp.Name()
defer func() {
tmp.Close()
// Clean up temp file on failure
if err != nil {
os.Remove(tmpName)
}
}()
written, err := io.Copy(tmp, r)
if err != nil {
return fmt.Errorf("storage/pvc: write %q: %w", key, err)
}
if err = tmp.Close(); err != nil {
return fmt.Errorf("storage/pvc: close %q: %w", key, err)
}
// Verify size if provided (0 means unknown)
if size > 0 && written != size {
os.Remove(tmpName)
return fmt.Errorf("storage/pvc: size mismatch for %q: expected %d, got %d", key, size, written)
}
// Atomic rename
if err = os.Rename(tmpName, absPath); err != nil {
return fmt.Errorf("storage/pvc: rename %q: %w", key, err)
}
// Cache content type
if contentType != "" {
s.mu.Lock()
s.mimeMap[key] = contentType
s.mu.Unlock()
}
return nil
}
// Get returns a reader for the file at key.
func (s *PVCStore) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) {
if err := validateKey(key); err != nil {
return nil, 0, "", err
}
absPath := s.resolve(key)
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return nil, 0, "", ErrNotFound
}
return nil, 0, "", fmt.Errorf("storage/pvc: stat %q: %w", key, err)
}
f, err := os.Open(absPath)
if err != nil {
return nil, 0, "", fmt.Errorf("storage/pvc: open %q: %w", key, err)
}
// Look up cached content type
s.mu.RLock()
ct := s.mimeMap[key]
s.mu.RUnlock()
if ct == "" {
ct = "application/octet-stream"
}
return f, info.Size(), ct, nil
}
// Delete removes the file at key.
func (s *PVCStore) Delete(ctx context.Context, key string) error {
if err := validateKey(key); err != nil {
return err
}
absPath := s.resolve(key)
err := os.Remove(absPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("storage/pvc: delete %q: %w", key, err)
}
// Clean up cache
s.mu.Lock()
delete(s.mimeMap, key)
s.mu.Unlock()
return nil
}
// DeletePrefix removes all files and directories under the given prefix.
func (s *PVCStore) DeletePrefix(ctx context.Context, prefix string) error {
if err := validateKey(prefix); err != nil {
return err
}
absPath := s.resolve(prefix)
// Check if the path exists
if _, err := os.Stat(absPath); os.IsNotExist(err) {
return nil // Nothing to delete
}
if err := os.RemoveAll(absPath); err != nil {
return fmt.Errorf("storage/pvc: delete prefix %q: %w", prefix, err)
}
// Clean up cache entries with this prefix
s.mu.Lock()
for k := range s.mimeMap {
if strings.HasPrefix(k, prefix) {
delete(s.mimeMap, k)
}
}
s.mu.Unlock()
return nil
}
// Exists checks if a file exists at key.
func (s *PVCStore) Exists(ctx context.Context, key string) (bool, error) {
if err := validateKey(key); err != nil {
return false, err
}
_, err := os.Stat(s.resolve(key))
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("storage/pvc: exists %q: %w", key, err)
}
// Healthy verifies the storage path is writable.
func (s *PVCStore) Healthy(ctx context.Context) error {
probe := filepath.Join(s.basePath, ".health-probe")
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
return fmt.Errorf("storage/pvc: not writable: %w", err)
}
os.Remove(probe)
return nil
}
// Stats returns file count and total size under the base path.
func (s *PVCStore) Stats(ctx context.Context) (*StorageStats, error) {
stats := &StorageStats{
Backend: "pvc",
Path: s.basePath,
Configured: true,
}
// Check health
if err := s.Healthy(ctx); err != nil {
stats.Healthy = false
return stats, nil
}
stats.Healthy = true
// Walk attachments directory for counts
attDir := filepath.Join(s.basePath, "attachments")
err := filepath.Walk(attDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip errors (e.g. permission denied)
}
if !info.IsDir() {
stats.TotalFiles++
stats.TotalBytes += info.Size()
}
return nil
})
if err != nil && !os.IsNotExist(err) {
return stats, fmt.Errorf("storage/pvc: stats walk: %w", err)
}
return stats, nil
}
// ── Helpers ────────────────────────────────
// resolve converts a storage key to an absolute filesystem path.
func (s *PVCStore) resolve(key string) string {
return filepath.Join(s.basePath, filepath.FromSlash(key))
}
// validateKey ensures the key is safe (no path traversal, no absolute paths).
func validateKey(key string) error {
if key == "" {
return fmt.Errorf("storage: empty key")
}
if strings.HasPrefix(key, "/") || strings.HasPrefix(key, "\\") {
return fmt.Errorf("storage: absolute key not allowed: %q", key)
}
cleaned := filepath.Clean(key)
if strings.Contains(cleaned, "..") {
return fmt.Errorf("storage: path traversal not allowed: %q", key)
}
return nil
}

295
server/storage/pvc_test.go Normal file
View File

@@ -0,0 +1,295 @@
package storage
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"testing"
)
func tempStore(t *testing.T) *PVCStore {
t.Helper()
dir := t.TempDir()
s, err := NewPVC(dir)
if err != nil {
t.Fatalf("NewPVC(%q): %v", dir, err)
}
return s
}
func TestPVC_PutGetRoundTrip(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
data := []byte("hello, storage world")
key := "attachments/channel-1/att-1_test.txt"
// Put
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
if err != nil {
t.Fatalf("Put: %v", err)
}
// Get
rc, size, ct, err := s.Get(ctx, key)
if err != nil {
t.Fatalf("Get: %v", err)
}
defer rc.Close()
if size != int64(len(data)) {
t.Errorf("size = %d, want %d", size, len(data))
}
if ct != "text/plain" {
t.Errorf("content_type = %q, want %q", ct, "text/plain")
}
got, _ := io.ReadAll(rc)
if !bytes.Equal(got, data) {
t.Errorf("data = %q, want %q", got, data)
}
}
func TestPVC_PutAtomicWrite(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
key := "attachments/ch/file.bin"
data := []byte("atomic test data")
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "application/octet-stream")
if err != nil {
t.Fatalf("Put: %v", err)
}
// File should exist at final path, no temp files left
absPath := filepath.Join(s.basePath, "attachments", "ch")
entries, _ := os.ReadDir(absPath)
for _, e := range entries {
if e.Name() != "file.bin" {
t.Errorf("unexpected file in dir: %s", e.Name())
}
}
}
func TestPVC_PutSizeMismatch(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
data := []byte("short")
err := s.Put(ctx, "attachments/ch/f.txt", bytes.NewReader(data), 999, "text/plain")
if err == nil {
t.Fatal("expected size mismatch error")
}
}
func TestPVC_GetNotFound(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
_, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt")
if err != ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestPVC_Delete(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
key := "attachments/ch/delete-me.txt"
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
err := s.Delete(ctx, key)
if err != nil {
t.Fatalf("Delete: %v", err)
}
exists, _ := s.Exists(ctx, key)
if exists {
t.Error("file still exists after Delete")
}
}
func TestPVC_DeleteIdempotent(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
// Delete a file that doesn't exist — should not error
err := s.Delete(ctx, "attachments/no-such/file.txt")
if err != nil {
t.Errorf("Delete nonexistent: %v", err)
}
}
func TestPVC_DeletePrefix(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
// Create several files under a channel prefix
prefix := "attachments/channel-abc/"
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
key := prefix + name
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
}
// Also create a file in a different channel
other := "attachments/channel-other/keep.txt"
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
// Delete the channel prefix
err := s.DeletePrefix(ctx, "attachments/channel-abc")
if err != nil {
t.Fatalf("DeletePrefix: %v", err)
}
// Verify channel files are gone
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
exists, _ := s.Exists(ctx, prefix+name)
if exists {
t.Errorf("%s still exists after DeletePrefix", name)
}
}
// Verify other channel is untouched
exists, _ := s.Exists(ctx, other)
if !exists {
t.Error("other channel file was incorrectly deleted")
}
}
func TestPVC_DeletePrefixNonexistent(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
// Should not error
err := s.DeletePrefix(ctx, "attachments/does-not-exist/")
if err != nil {
t.Errorf("DeletePrefix nonexistent: %v", err)
}
}
func TestPVC_Exists(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
key := "attachments/ch/exists.txt"
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
exists, err := s.Exists(ctx, key)
if err != nil {
t.Fatalf("Exists: %v", err)
}
if !exists {
t.Error("Exists returned false for existing file")
}
exists, err = s.Exists(ctx, "attachments/ch/nope.txt")
if err != nil {
t.Fatalf("Exists (missing): %v", err)
}
if exists {
t.Error("Exists returned true for missing file")
}
}
func TestPVC_Healthy(t *testing.T) {
s := tempStore(t)
if err := s.Healthy(context.Background()); err != nil {
t.Errorf("Healthy: %v", err)
}
}
func TestPVC_Stats(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
// Empty store
stats, err := s.Stats(ctx)
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.TotalFiles != 0 || stats.TotalBytes != 0 {
t.Errorf("empty store: files=%d bytes=%d", stats.TotalFiles, stats.TotalBytes)
}
if !stats.Healthy || !stats.Configured {
t.Error("expected healthy + configured")
}
// Add some files
for i := 0; i < 3; i++ {
key := filepath.Join("attachments", "ch", string(rune('a'+i))+".txt")
data := bytes.Repeat([]byte("x"), 100*(i+1))
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
}
stats, err = s.Stats(ctx)
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.TotalFiles != 3 {
t.Errorf("files = %d, want 3", stats.TotalFiles)
}
if stats.TotalBytes != 600 { // 100 + 200 + 300
t.Errorf("bytes = %d, want 600", stats.TotalBytes)
}
}
func TestPVC_PathTraversal(t *testing.T) {
s := tempStore(t)
ctx := context.Background()
bad := []string{
"../etc/passwd",
"attachments/../../etc/shadow",
"/absolute/path",
"",
}
for _, key := range bad {
err := s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
if err == nil {
t.Errorf("Put(%q) should have failed", key)
}
_, _, _, err = s.Get(ctx, key)
if err == nil {
t.Errorf("Get(%q) should have failed", key)
}
err = s.Delete(ctx, key)
if err == nil {
t.Errorf("Delete(%q) should have failed", key)
}
}
}
func TestPVC_SubdirCreation(t *testing.T) {
s := tempStore(t)
// Verify well-known subdirs were created
for _, sub := range []string{"attachments", "processing"} {
info, err := os.Stat(filepath.Join(s.basePath, sub))
if err != nil {
t.Errorf("subdir %q not created: %v", sub, err)
continue
}
if !info.IsDir() {
t.Errorf("%q is not a directory", sub)
}
}
}
func TestNewPVC_InvalidPath(t *testing.T) {
// Path that can't be created (nested under a file)
tmp := t.TempDir()
filePath := filepath.Join(tmp, "blocker")
os.WriteFile(filePath, []byte("x"), 0o644)
_, err := NewPVC(filepath.Join(filePath, "subdir"))
if err == nil {
t.Error("expected error for invalid path")
}
}

312
server/storage/s3.go Normal file
View File

@@ -0,0 +1,312 @@
package storage
import (
"bytes"
"context"
"fmt"
"io"
"log"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// ── S3 Store ──────────────────────────────
// S3Config holds all configuration for the S3 backend.
type S3Config struct {
Endpoint string // e.g. "minio.example.com:9000" or "s3.amazonaws.com"
Bucket string // bucket name
Region string // AWS region (default "us-east-1")
AccessKey string // access key ID
SecretKey string // secret access key
Prefix string // optional key prefix (e.g. "switchboard/")
ForcePathStyle bool // path-style URLs (required for MinIO, most self-hosted)
UseSSL bool // use HTTPS (derived from endpoint scheme)
}
// S3Store implements ObjectStore using any S3-compatible API.
// Tested with: MinIO, Ceph RGW, AWS S3.
type S3Store struct {
client *minio.Client
bucket string
prefix string // prepended to all keys (e.g. "switchboard/")
endpoint string // original endpoint for display in Stats
}
// NewS3 creates an S3Store connected to the given endpoint.
// Returns an error if the bucket does not exist or is not accessible.
func NewS3(cfg S3Config) (*S3Store, error) {
if cfg.Bucket == "" {
return nil, fmt.Errorf("storage/s3: bucket name is required")
}
if cfg.AccessKey == "" || cfg.SecretKey == "" {
return nil, fmt.Errorf("storage/s3: access key and secret key are required")
}
if cfg.Region == "" {
cfg.Region = "us-east-1"
}
// Parse endpoint: strip scheme if present, detect SSL
endpoint := cfg.Endpoint
useSSL := cfg.UseSSL
if strings.HasPrefix(endpoint, "https://") {
endpoint = strings.TrimPrefix(endpoint, "https://")
useSSL = true
} else if strings.HasPrefix(endpoint, "http://") {
endpoint = strings.TrimPrefix(endpoint, "http://")
useSSL = false
}
// Default to SSL for AWS endpoints
if endpoint == "" || strings.Contains(endpoint, "amazonaws.com") {
useSSL = true
}
opts := &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: useSSL,
Region: cfg.Region,
}
// BucketLookup controls path-style vs virtual-hosted-style URLs.
// MinIO, Ceph, and most self-hosted S3 require path-style.
if cfg.ForcePathStyle {
opts.BucketLookup = minio.BucketLookupPath
} else {
opts.BucketLookup = minio.BucketLookupAuto
}
client, err := minio.New(endpoint, opts)
if err != nil {
return nil, fmt.Errorf("storage/s3: client init: %w", err)
}
// Normalize prefix: ensure trailing slash if non-empty, no leading slash
prefix := strings.TrimPrefix(cfg.Prefix, "/")
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
s := &S3Store{
client: client,
bucket: cfg.Bucket,
prefix: prefix,
endpoint: endpoint,
}
// Validate: check bucket exists and is accessible
ctx := context.Background()
exists, err := client.BucketExists(ctx, cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("storage/s3: cannot reach endpoint: %w", err)
}
if !exists {
return nil, fmt.Errorf("storage/s3: bucket %q does not exist", cfg.Bucket)
}
return s, nil
}
func (s *S3Store) Backend() string { return "s3" }
// Put writes data to S3 at the given key.
func (s *S3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
if err := validateKey(key); err != nil {
return err
}
if contentType == "" {
contentType = "application/octet-stream"
}
opts := minio.PutObjectOptions{
ContentType: contentType,
}
// If size is unknown (0), we need to buffer or use -1.
// minio-go handles -1 with multipart upload.
uploadSize := size
if uploadSize == 0 {
uploadSize = -1
}
_, err := s.client.PutObject(ctx, s.bucket, s.fullKey(key), r, uploadSize, opts)
if err != nil {
return fmt.Errorf("storage/s3: put %q: %w", key, err)
}
return nil
}
// Get returns a reader for the object at key.
func (s *S3Store) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) {
if err := validateKey(key); err != nil {
return nil, 0, "", err
}
obj, err := s.client.GetObject(ctx, s.bucket, s.fullKey(key), minio.GetObjectOptions{})
if err != nil {
return nil, 0, "", fmt.Errorf("storage/s3: get %q: %w", key, err)
}
// Stat the object to get size and content type.
// GetObject is lazy — the actual HTTP request happens on Read or Stat.
info, err := obj.Stat()
if err != nil {
obj.Close()
if isS3NotFound(err) {
return nil, 0, "", ErrNotFound
}
return nil, 0, "", fmt.Errorf("storage/s3: stat %q: %w", key, err)
}
ct := info.ContentType
if ct == "" {
ct = "application/octet-stream"
}
return obj, info.Size, ct, nil
}
// Delete removes the object at key.
func (s *S3Store) Delete(ctx context.Context, key string) error {
if err := validateKey(key); err != nil {
return err
}
err := s.client.RemoveObject(ctx, s.bucket, s.fullKey(key), minio.RemoveObjectOptions{})
if err != nil {
// S3 RemoveObject is already idempotent (no error for missing keys)
return fmt.Errorf("storage/s3: delete %q: %w", key, err)
}
return nil
}
// DeletePrefix removes all objects under the given prefix.
func (s *S3Store) DeletePrefix(ctx context.Context, prefix string) error {
if err := validateKey(prefix); err != nil {
return err
}
fullPrefix := s.fullKey(prefix)
// Ensure trailing slash for directory-like prefix
if !strings.HasSuffix(fullPrefix, "/") {
fullPrefix += "/"
}
// List all objects under prefix
objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
Prefix: fullPrefix,
Recursive: true,
})
// Build removal channel
errCh := s.client.RemoveObjects(ctx, s.bucket, toRemoveChannel(objectsCh), minio.RemoveObjectsOptions{})
// Drain errors
var firstErr error
for e := range errCh {
if e.Err != nil && firstErr == nil {
firstErr = fmt.Errorf("storage/s3: delete prefix %q: %w", prefix, e.Err)
}
}
return firstErr
}
// Exists checks if an object exists at key.
func (s *S3Store) Exists(ctx context.Context, key string) (bool, error) {
if err := validateKey(key); err != nil {
return false, err
}
_, err := s.client.StatObject(ctx, s.bucket, s.fullKey(key), minio.StatObjectOptions{})
if err != nil {
if isS3NotFound(err) {
return false, nil
}
return false, fmt.Errorf("storage/s3: exists %q: %w", key, err)
}
return true, nil
}
// Healthy checks connectivity by performing a HeadBucket.
func (s *S3Store) Healthy(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
return fmt.Errorf("storage/s3: health check failed: %w", err)
}
if !exists {
return fmt.Errorf("storage/s3: bucket %q no longer exists", s.bucket)
}
// Write + read a probe object to verify full access
probeKey := s.fullKey(".health-probe")
_, err = s.client.PutObject(ctx, s.bucket, probeKey,
bytes.NewReader([]byte("ok")), 2,
minio.PutObjectOptions{ContentType: "text/plain"})
if err != nil {
return fmt.Errorf("storage/s3: not writable: %w", err)
}
s.client.RemoveObject(ctx, s.bucket, probeKey, minio.RemoveObjectOptions{})
return nil
}
// Stats returns object count and total size in the bucket (under prefix).
func (s *S3Store) Stats(ctx context.Context) (*StorageStats, error) {
stats := &StorageStats{
Backend: "s3",
Bucket: s.bucket,
Endpoint: s.endpoint,
Configured: true,
}
// Check health first
if err := s.Healthy(ctx); err != nil {
stats.Healthy = false
return stats, nil
}
stats.Healthy = true
// Count objects under the attachments prefix
attPrefix := s.fullKey("attachments/")
objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
Prefix: attPrefix,
Recursive: true,
})
for obj := range objectsCh {
if obj.Err != nil {
log.Printf("storage/s3: stats list error: %v", obj.Err)
break
}
stats.TotalFiles++
stats.TotalBytes += obj.Size
}
return stats, nil
}
// ── Helpers ────────────────────────────────
// fullKey prepends the configured prefix to a storage key.
func (s *S3Store) fullKey(key string) string {
return s.prefix + key
}
// isS3NotFound checks if an error represents a "not found" condition.
func isS3NotFound(err error) bool {
if err == nil {
return false
}
resp := minio.ToErrorResponse(err)
return resp.Code == "NoSuchKey" || resp.StatusCode == 404
}
// toRemoveChannel converts a ListObjects channel to a RemoveObjects input channel.
func toRemoveChannel(objects <-chan minio.ObjectInfo) <-chan minio.ObjectInfo {
// RemoveObjects accepts the same channel type — pass through directly.
// The channel is already producing ObjectInfo values with Key set.
return objects
}

326
server/storage/s3_test.go Normal file
View File

@@ -0,0 +1,326 @@
package storage
import (
"bytes"
"context"
"io"
"os"
"testing"
)
// ── Unit Tests (always run) ──────────────
func TestS3_NewS3_Validation(t *testing.T) {
// Missing bucket
_, err := NewS3(S3Config{
AccessKey: "test",
SecretKey: "test",
})
if err == nil {
t.Error("expected error for missing bucket")
}
// Missing credentials
_, err = NewS3(S3Config{
Bucket: "test-bucket",
})
if err == nil {
t.Error("expected error for missing credentials")
}
// Missing access key only
_, err = NewS3(S3Config{
Bucket: "test-bucket",
SecretKey: "test",
})
if err == nil {
t.Error("expected error for missing access key")
}
}
func TestS3_FullKey(t *testing.T) {
tests := []struct {
prefix string
key string
want string
}{
{"", "attachments/ch/f.txt", "attachments/ch/f.txt"},
{"switchboard/", "attachments/ch/f.txt", "switchboard/attachments/ch/f.txt"},
{"prefix", "attachments/ch/f.txt", "prefix/attachments/ch/f.txt"}, // trailing slash added by NewS3
}
for _, tt := range tests {
s := &S3Store{prefix: tt.prefix}
// Normalize prefix same as NewS3 does
if s.prefix != "" && s.prefix[len(s.prefix)-1] != '/' {
s.prefix += "/"
}
got := s.fullKey(tt.key)
if got != tt.want {
t.Errorf("fullKey(%q, %q) = %q, want %q", tt.prefix, tt.key, got, tt.want)
}
}
}
func TestS3_KeyValidation(t *testing.T) {
// validateKey is shared between PVC and S3 — test with S3 context
bad := []string{
"",
"../etc/passwd",
"/absolute/path",
"attachments/../../etc/shadow",
}
for _, key := range bad {
if err := validateKey(key); err == nil {
t.Errorf("validateKey(%q) should have failed", key)
}
}
good := []string{
"attachments/ch/f.txt",
"attachments/channel-1/att-abc_test.pdf",
"processing/abc123/status.json",
}
for _, key := range good {
if err := validateKey(key); err != nil {
t.Errorf("validateKey(%q) failed: %v", key, err)
}
}
}
// ── Integration Tests (require live S3 endpoint) ──────────────
//
// Set these environment variables to run:
//
// S3_TEST_ENDPOINT=localhost:9000
// S3_TEST_BUCKET=switchboard-test
// S3_TEST_ACCESS_KEY=minioadmin
// S3_TEST_SECRET_KEY=minioadmin
//
// Example with MinIO:
//
// docker run -d -p 9000:9000 -p 9001:9001 \
// -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
// minio/minio server /data --console-address :9001
// mc alias set local http://localhost:9000 minioadmin minioadmin
// mc mb local/switchboard-test
func testS3Store(t *testing.T) *S3Store {
t.Helper()
endpoint := os.Getenv("S3_TEST_ENDPOINT")
bucket := os.Getenv("S3_TEST_BUCKET")
accessKey := os.Getenv("S3_TEST_ACCESS_KEY")
secretKey := os.Getenv("S3_TEST_SECRET_KEY")
if endpoint == "" || bucket == "" {
t.Skip("S3_TEST_ENDPOINT and S3_TEST_BUCKET not set — skipping S3 integration tests")
}
s, err := NewS3(S3Config{
Endpoint: endpoint,
Bucket: bucket,
Region: "us-east-1",
AccessKey: accessKey,
SecretKey: secretKey,
Prefix: "test-" + t.Name() + "/",
ForcePathStyle: true,
})
if err != nil {
t.Fatalf("NewS3: %v", err)
}
// Clean up prefix after test
t.Cleanup(func() {
ctx := context.Background()
_ = s.DeletePrefix(ctx, "attachments")
_ = s.DeletePrefix(ctx, "processing")
})
return s
}
func TestS3_Integration_PutGetRoundTrip(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
data := []byte("hello, S3 storage world")
key := "attachments/channel-1/att-1_test.txt"
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
if err != nil {
t.Fatalf("Put: %v", err)
}
rc, size, ct, err := s.Get(ctx, key)
if err != nil {
t.Fatalf("Get: %v", err)
}
defer rc.Close()
if size != int64(len(data)) {
t.Errorf("size = %d, want %d", size, len(data))
}
if ct != "text/plain" {
t.Errorf("content_type = %q, want %q", ct, "text/plain")
}
got, _ := io.ReadAll(rc)
if !bytes.Equal(got, data) {
t.Errorf("data = %q, want %q", got, data)
}
}
func TestS3_Integration_GetNotFound(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
_, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt")
if err != ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestS3_Integration_Delete(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
key := "attachments/ch/delete-me.txt"
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
err := s.Delete(ctx, key)
if err != nil {
t.Fatalf("Delete: %v", err)
}
exists, _ := s.Exists(ctx, key)
if exists {
t.Error("file still exists after Delete")
}
}
func TestS3_Integration_DeleteIdempotent(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
// S3 delete is inherently idempotent
err := s.Delete(ctx, "attachments/no-such/file.txt")
if err != nil {
t.Errorf("Delete nonexistent: %v", err)
}
}
func TestS3_Integration_DeletePrefix(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
prefix := "attachments/channel-abc/"
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
key := prefix + name
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
}
other := "attachments/channel-other/keep.txt"
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
err := s.DeletePrefix(ctx, "attachments/channel-abc")
if err != nil {
t.Fatalf("DeletePrefix: %v", err)
}
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
exists, _ := s.Exists(ctx, prefix+name)
if exists {
t.Errorf("%s still exists after DeletePrefix", name)
}
}
exists, _ := s.Exists(ctx, other)
if !exists {
t.Error("other channel file was incorrectly deleted")
}
}
func TestS3_Integration_Exists(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
key := "attachments/ch/exists.txt"
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
exists, err := s.Exists(ctx, key)
if err != nil {
t.Fatalf("Exists: %v", err)
}
if !exists {
t.Error("Exists returned false for existing file")
}
exists, err = s.Exists(ctx, "attachments/ch/nope.txt")
if err != nil {
t.Fatalf("Exists (missing): %v", err)
}
if exists {
t.Error("Exists returned true for missing file")
}
}
func TestS3_Integration_Healthy(t *testing.T) {
s := testS3Store(t)
if err := s.Healthy(context.Background()); err != nil {
t.Errorf("Healthy: %v", err)
}
}
func TestS3_Integration_Stats(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
// Add some files
for i := 0; i < 3; i++ {
key := "attachments/ch/" + string(rune('a'+i)) + ".txt"
data := bytes.Repeat([]byte("x"), 100*(i+1))
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
}
stats, err := s.Stats(ctx)
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.TotalFiles != 3 {
t.Errorf("files = %d, want 3", stats.TotalFiles)
}
if stats.TotalBytes != 600 {
t.Errorf("bytes = %d, want 600", stats.TotalBytes)
}
if !stats.Healthy || !stats.Configured {
t.Error("expected healthy + configured")
}
if stats.Backend != "s3" {
t.Errorf("backend = %q, want s3", stats.Backend)
}
}
func TestS3_Integration_PutUnknownSize(t *testing.T) {
s := testS3Store(t)
ctx := context.Background()
data := []byte("unknown size upload")
key := "attachments/ch/unknown-size.txt"
// size=0 means unknown — S3 should handle via multipart
err := s.Put(ctx, key, bytes.NewReader(data), 0, "text/plain")
if err != nil {
t.Fatalf("Put with size=0: %v", err)
}
rc, size, _, err := s.Get(ctx, key)
if err != nil {
t.Fatalf("Get: %v", err)
}
defer rc.Close()
if size != int64(len(data)) {
t.Errorf("size = %d, want %d", size, len(data))
}
}

73
server/storage/storage.go Normal file
View File

@@ -0,0 +1,73 @@
package storage
import (
"context"
"errors"
"io"
)
// ── Errors ─────────────────────────────────
var (
// ErrNotFound is returned when a requested object does not exist.
ErrNotFound = errors.New("storage: object not found")
// ErrNotConfigured is returned when storage operations are attempted
// but no backend has been configured or the backend is unhealthy.
ErrNotConfigured = errors.New("storage: backend not configured")
)
// ── ObjectStore Interface ──────────────────
// ObjectStore is the abstraction for blob storage.
// Implementations: PVC (filesystem), S3 (minio-go v7).
//
// Keys are slash-delimited paths relative to the storage root:
//
// attachments/{channel_id}/{attachment_id}_{filename}
//
// Implementations must create intermediate directories as needed.
type ObjectStore interface {
// Put writes data to the given key.
// Creates parent directories as needed. Overwrites if exists.
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
// Get returns a reader for the given key.
// Returns the reader, size in bytes, content type, and error.
// Caller must close the returned reader.
// 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 the key does not exist (idempotent).
Delete(ctx context.Context, key string) error
// DeletePrefix removes all objects under the given prefix.
// Used for channel deletion (bulk cleanup).
// Example: DeletePrefix(ctx, "attachments/{channel_id}/")
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 (writable).
Healthy(ctx context.Context) error
// Stats returns aggregate storage statistics.
Stats(ctx context.Context) (*StorageStats, error)
// Backend returns the backend type identifier ("pvc", "s3").
Backend() string
}
// StorageStats holds aggregate storage metrics.
type StorageStats struct {
Backend string `json:"backend"`
Path string `json:"path,omitempty"` // PVC only
Endpoint string `json:"endpoint,omitempty"` // S3 only
Bucket string `json:"bucket,omitempty"` // S3 only
Healthy bool `json:"healthy"`
Configured bool `json:"configured"`
TotalFiles int64 `json:"total_files"`
TotalBytes int64 `json:"total_bytes"`
}

View File

@@ -33,6 +33,7 @@ type Stores struct {
Usage UsageStore
Pricing PricingStore
Extensions ExtensionStore
Attachments AttachmentStore
}
// =========================================
@@ -340,6 +341,24 @@ type ExtensionStore interface {
DeleteUserSettings(ctx context.Context, extID, userID string) error
}
// =========================================
// ATTACHMENT STORE
// =========================================
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, attachmentID, messageID string) error
UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error
SetExtractedText(ctx context.Context, id string, text string) error
Delete(ctx context.Context, id string) (*models.Attachment, error) // returns deleted row for storage cleanup
DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys
UserUsageBytes(ctx context.Context, userID string) (int64, error)
ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error)
}
// =========================================
// SHARED TYPES
// =========================================

View File

@@ -0,0 +1,187 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type AttachmentStore struct{}
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
// ── columns shared across queries ──────────
const attachmentCols = `id, channel_id, user_id, message_id, filename, content_type,
size_bytes, storage_key, extracted_text, metadata, created_at`
// scanAttachment scans a row into an Attachment struct.
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
var a models.Attachment
var messageID sql.NullString
var extractedText sql.NullString
var metadataJSON []byte
err := row.Scan(
&a.ID, &a.ChannelID, &a.UserID, &messageID,
&a.Filename, &a.ContentType, &a.SizeBytes,
&a.StorageKey, &extractedText, &metadataJSON, &a.CreatedAt,
)
if err != nil {
return nil, err
}
a.MessageID = NullableStringPtr(messageID)
if extractedText.Valid {
a.ExtractedText = &extractedText.String
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &a.Metadata)
}
return &a, nil
}
func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error {
return DB.QueryRowContext(ctx, `
INSERT INTO attachments (channel_id, user_id, message_id, filename, content_type,
size_bytes, storage_key, extracted_text, metadata)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, created_at`,
a.ChannelID, a.UserID, models.NullString(a.MessageID),
a.Filename, a.ContentType, a.SizeBytes,
a.StorageKey, models.NullString(a.ExtractedText),
ToJSON(a.Metadata),
).Scan(&a.ID, &a.CreatedAt)
}
func (s *AttachmentStore) GetByID(ctx context.Context, id string) (*models.Attachment, error) {
row := DB.QueryRowContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE id = $1`, id)
return scanAttachment(row)
}
func (s *AttachmentStore) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE channel_id = $1 ORDER BY created_at`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE message_id = $1 ORDER BY created_at`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
_, err := DB.ExecContext(ctx,
`UPDATE attachments SET message_id = $1 WHERE id = $2`,
messageID, attachmentID)
return err
}
func (s *AttachmentStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
// Merge into existing metadata using jsonb || operator
metaJSON, err := json.Marshal(metadata)
if err != nil {
return err
}
_, err = DB.ExecContext(ctx,
`UPDATE attachments SET metadata = metadata || $1::jsonb WHERE id = $2`,
metaJSON, id)
return err
}
func (s *AttachmentStore) SetExtractedText(ctx context.Context, id string, text string) error {
_, err := DB.ExecContext(ctx,
`UPDATE attachments SET extracted_text = $1 WHERE id = $2`,
text, id)
return err
}
// Delete removes an attachment and returns the deleted row (for storage cleanup).
func (s *AttachmentStore) Delete(ctx context.Context, id string) (*models.Attachment, error) {
row := DB.QueryRowContext(ctx,
`DELETE FROM attachments WHERE id = $1
RETURNING `+attachmentCols, id)
return scanAttachment(row)
}
// DeleteByChannel removes all attachments for a channel and returns storage keys.
func (s *AttachmentStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`DELETE FROM attachments WHERE channel_id = $1 RETURNING storage_key`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return keys, err
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func (s *AttachmentStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
var total sql.NullInt64
err := DB.QueryRowContext(ctx,
`SELECT COALESCE(SUM(size_bytes), 0) FROM attachments WHERE user_id = $1`,
userID).Scan(&total)
if err != nil {
return 0, err
}
return total.Int64, nil
}
func (s *AttachmentStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) {
cutoff := time.Now().Add(-olderThan)
rows, err := DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments
WHERE message_id IS NULL AND created_at < $1
ORDER BY created_at`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}

View File

@@ -26,5 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
Attachments: NewAttachmentStore(),
}
}

View File

@@ -901,6 +901,133 @@ a:hover { text-decoration: underline; }
.stop-btn { display: none; color: var(--warning); }
.stop-btn.visible { display: flex; }
/* ── Attachments ─────────────────────────── */
.attach-btn { color: var(--text-3); padding: 6px 4px 12px 10px; }
.attach-btn:hover { color: var(--text); }
.attachment-strip {
max-width: 768px; margin: 0 auto; padding: 6px 8px 2px;
display: flex; gap: 6px; flex-wrap: wrap;
}
.att-chip {
display: flex; align-items: center; gap: 6px;
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 4px 6px;
font-size: 12px; max-width: 220px; position: relative;
}
.att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
.att-chip.att-error { border-color: color-mix(in srgb, var(--error) 40%, var(--border)); }
.att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
.att-thumb {
width: 32px; height: 32px; object-fit: cover;
border-radius: 3px; flex-shrink: 0;
}
.att-icon { font-size: 18px; flex-shrink: 0; line-height: 1; }
.att-info { display: flex; flex-direction: column; min-width: 0; }
.att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 500; }
.att-meta { color: var(--text-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.att-remove {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 2px 4px; font-size: 13px; line-height: 1; border-radius: 3px;
flex-shrink: 0; margin-left: auto;
}
.att-remove:hover { background: var(--bg-hover); color: var(--text); }
/* Drag-and-drop overlay on chat area */
.messages.drag-over {
outline: 2px dashed var(--accent);
outline-offset: -4px;
background: color-mix(in srgb, var(--accent) 4%, var(--bg));
}
/* ── Message Attachments ────────────────────── */
.msg-attachments {
display: flex; gap: 8px; flex-wrap: wrap;
margin-top: 8px;
}
.msg-att-image {
cursor: pointer; position: relative; border-radius: var(--radius);
overflow: hidden; border: 1px solid var(--border);
transition: border-color var(--transition);
}
.msg-att-image:hover { border-color: var(--accent); }
.msg-att-image img {
display: block; max-height: 280px; border-radius: var(--radius);
background: var(--bg-raised);
}
.msg-att-image img.att-load-error {
min-width: 120px; min-height: 60px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; color: var(--text-3);
}
.att-vision-hint {
position: absolute; top: 4px; right: 4px;
background: color-mix(in srgb, var(--bg) 85%, transparent);
border-radius: 4px; padding: 2px 5px; font-size: 14px;
cursor: help;
}
.msg-att-doc {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg-raised);
text-decoration: none; color: var(--text);
max-width: 280px; transition: border-color var(--transition);
}
.msg-att-doc:hover { border-color: var(--accent); }
.att-doc-icon { font-size: 20px; flex-shrink: 0; }
.att-doc-info { display: flex; flex-direction: column; min-width: 0; }
.att-doc-name {
font-size: 13px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.att-doc-size { font-size: 11px; color: var(--text-3); }
.att-doc-dl { color: var(--text-3); font-size: 14px; margin-left: auto; }
/* ── Image Lightbox ─────────────────────────── */
.lightbox-overlay {
display: none; position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
}
.lightbox-overlay.active { display: flex; }
.lightbox-overlay img {
max-width: 92vw; max-height: 90vh; object-fit: contain;
border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.lightbox-close {
position: absolute; top: 16px; right: 20px;
background: rgba(255,255,255,0.15); border: none; color: #fff;
font-size: 22px; width: 40px; height: 40px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.lightbox-close:hover { background: rgba(255,255,255,0.3); }
/* ── Admin Storage Panel ────────────────────── */
.admin-storage-card {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; margin-bottom: 12px;
}
.admin-storage-card h4 { margin: 0 0 10px 0; font-size: 14px; }
.admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 10px 0; }
.admin-storage-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.admin-storage-item { display: flex; flex-direction: column; gap: 2px; }
.admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
.admin-storage-value { font-size: 14px; font-weight: 500; }
/* ── Buttons ─────────────────────────────── */
button { font-family: var(--font); cursor: pointer; }
@@ -1354,6 +1481,9 @@ button { font-family: var(--font); cursor: pointer; }
.team-admin-back:hover { opacity: 1; }
.team-tab-content { padding: 0; }
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-success { background: rgba(34,197,94,0.2); color: #4ade80; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-warning { background: rgba(234,179,8,0.2); color: #fbbf24; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-danger { background: rgba(239,68,68,0.2); color: #f87171; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.admin-user-row.user-inactive { opacity: 0.7; }
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }

View File

@@ -147,7 +147,12 @@
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss"></button>
</div>
<div class="attachment-strip" id="attachmentStrip" style="display:none"></div>
<div class="input-wrap">
<button class="action-btn attach-btn" id="attachBtn" title="Attach file" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="fileInput" multiple style="display:none">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
<div class="input-actions">
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
@@ -566,6 +571,7 @@
<button class="admin-tab" data-tab="roles">Roles</button>
<button class="admin-tab" data-tab="usage">Usage</button>
<button class="admin-tab" data-tab="extensions">Extensions</button>
<button class="admin-tab" data-tab="storage">Storage</button>
<button class="admin-tab" data-tab="stats">Stats</button>
<button class="admin-tab" data-tab="audit">Audit</button>
</div>
@@ -706,6 +712,10 @@
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
</div>
</section>
<section class="admin-section">
<h4>🔐 Encryption</h4>
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
</section>
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div>
@@ -766,6 +776,7 @@
</div>
</div>
</div>
<div class="admin-tab-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:8px">
@@ -858,6 +869,7 @@
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
@@ -908,5 +920,12 @@ window.addEventListener('appinstalled', () => {
_deferredInstallPrompt = null;
});
</script>
<!-- Image lightbox overlay -->
<div class="lightbox-overlay" id="lightbox">
<button class="lightbox-close" title="Close (Esc)"></button>
<img id="lightboxImg" alt="">
</div>
</body>
</html>

View File

@@ -226,7 +226,7 @@ const API = {
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
const body = { channel_id: channelId, content, stream: true };
if (presetId) {
body.preset_id = presetId;
@@ -238,6 +238,7 @@ const API = {
if (apiConfigId) body.provider_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
let resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
@@ -455,6 +456,66 @@ const API = {
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
// ── Attachments ─────────────────────────
/**
* Upload a file to a channel. Uses multipart/form-data (not JSON).
* Returns the created attachment object with id, metadata, etc.
*/
async uploadAttachment(channelId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
// No Content-Type — browser sets multipart boundary automatically
body: form,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
},
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
async downloadAttachmentBlob(id) {
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
headers: { 'Authorization': `Bearer ${this.accessToken}` },
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
}
return resp.blob();
},
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
// ── Admin Storage ───────────────────────
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
// Vault
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
// ── Admin Roles ─────────────────────────
adminListRoles() { return this._get('/api/v1/admin/roles'); },
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },

View File

@@ -14,6 +14,9 @@ const App = {
abortController: null,
serverSettings: {},
policies: {},
stagedAttachments: [],
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
storageConfigured: false,
// Find model by composite ID, with fallback to bare model_id match
findModel(id) {
@@ -183,6 +186,7 @@ async function startApp() {
}
await initBanners();
initAttachments();
UI.renderChatList();
UI.updateModelSelector();
UI.updateUser();
@@ -370,6 +374,7 @@ function initListeners() {
_initSettingsListeners(); // from settings-handlers.js
_initAdminListeners(); // from admin-handlers.js
_initNotesListeners(); // from notes.js
_initAttachmentListeners(); // from attachments.js
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
_initSidePanelResize(); // from ui-format.js
}
@@ -380,6 +385,9 @@ function _initGlobalKeyboard() {
// Escape: stop generation → close command palette → close side panel → close topmost modal
if (e.key === 'Escape') {
if (App.isGenerating) { stopGeneration(); return; }
if (document.getElementById('lightbox')?.classList.contains('active')) {
closeLightbox(); return;
}
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
closeCmdPalette(); return;
}
@@ -532,6 +540,9 @@ async function initBanners() {
// Store policies for user-facing checks (allow_user_byok, etc.)
App.policies = data.policies || {};
// Storage capabilities (file upload, vision)
App.storageConfigured = !!data.storage_configured;
// Also flatten into serverSettings for backward compat
App.serverSettings = {};
if (data.banner) App.serverSettings.banner = data.banner;

836
src/js/attachments.js Normal file
View File

@@ -0,0 +1,836 @@
// ==========================================
// Chat Switchboard Attachments
// ==========================================
// File upload, smart paste, drag-and-drop, extraction
// polling, staged lifecycle, message rendering, lightbox,
// and admin storage panel.
//
// Depends on: API, App, UI (toast, getModelValue, renderChatList)
// Consumed by: chat.js (send flow), ui-core.js (message rendering),
// ui-admin.js (storage tab)
// ── Constants ───────────────────────────────
const ATTACHMENT_POLL_INTERVAL = 2000; // ms between extraction status checks
const ATTACHMENT_MAX_PER_MSG = 5; // mirrors backend defaultMaxAttachmentsPerMsg
// MIME categories for capability-aware filtering
const IMAGE_TYPES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
]);
// ── State ───────────────────────────────────
// Staged attachments waiting to be sent with the next message.
// Each entry:
// {
// localId: string — crypto.randomUUID(), stable key for DOM
// serverId: string? — attachment UUID from backend (null while uploading)
// filename: string
// contentType: string
// sizeBytes: number
// previewUrl: string? — object URL for image preview (revoked on remove)
// status: 'uploading' | 'queued' | 'processing' | 'complete' | 'failed' | 'error'
// uploadProgress: number — 0100 (only meaningful during 'uploading')
// extractionStatus: string? — mirrors backend metadata.extraction_status
// queuePosition: number? — extraction queue position (0 = processing)
// error: string? — error message if status is 'error' or 'failed'
// }
// App.stagedAttachments is initialized in app.js.
// Polling timers are tracked here for cleanup.
const _pollTimers = new Map(); // localId → intervalId
// ── Upload ──────────────────────────────────
/**
* Stage a file for upload. Creates the staged entry, uploads to backend,
* and starts extraction polling.
*
* If no current chat exists, creates one first (same pattern as sendMessage).
*
* @param {File} file — File object from input, paste, or drop
* @returns {Promise<void>}
*/
async function stageFile(file) {
if (!App.storageConfigured) return;
// Enforce per-message limit
if (App.stagedAttachments.length >= ATTACHMENT_MAX_PER_MSG) {
UI.toast(`Maximum ${ATTACHMENT_MAX_PER_MSG} files per message`, 'warning');
return;
}
// Build staged entry
const staged = {
localId: crypto.randomUUID(),
serverId: null,
filename: file.name,
contentType: file.type || 'application/octet-stream',
sizeBytes: file.size,
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
status: 'uploading',
uploadProgress: 0,
extractionStatus: null,
queuePosition: null,
error: null,
};
App.stagedAttachments.push(staged);
_renderStrip();
_updateSendBlock();
// Ensure a channel exists (attachments are channel-scoped)
if (!App.currentChatId) {
try {
const title = file.name.slice(0, 50);
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = {
id: resp.id, title: resp.title, type: 'direct',
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
};
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();
} catch (e) {
staged.status = 'error';
staged.error = 'Failed to create chat: ' + e.message;
_renderStrip();
_updateSendBlock();
return;
}
}
// Upload
try {
const result = await API.uploadAttachment(App.currentChatId, file);
staged.serverId = result.id;
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
staged.extractionStatus = result.metadata?.extraction_status || null;
staged.queuePosition = result.metadata?.queue_position ?? null;
// Start polling if extraction is pending/processing
if (staged.status === 'queued' || staged.status === 'processing') {
_startPolling(staged);
}
} catch (e) {
staged.status = 'error';
staged.error = e.message;
console.error('Attachment upload failed:', e);
}
_renderStrip();
_updateSendBlock();
}
/**
* Remove a staged attachment by localId.
* Revokes object URL, stops polling, and optionally deletes from backend.
*/
async function unstageFile(localId) {
const idx = App.stagedAttachments.findIndex(a => a.localId === localId);
if (idx === -1) return;
const staged = App.stagedAttachments[idx];
// Clean up preview blob
if (staged.previewUrl) {
URL.revokeObjectURL(staged.previewUrl);
}
// Stop polling
_stopPolling(localId);
// Delete from backend if it was uploaded
if (staged.serverId) {
try {
await API.deleteAttachment(staged.serverId);
} catch (e) {
console.warn('Failed to delete staged attachment:', e.message);
}
}
App.stagedAttachments.splice(idx, 1);
_renderStrip();
_updateSendBlock();
}
/**
* Clear all staged attachments (chat switch, cancel, etc.)
* Does NOT delete from backend — orphan cleanup handles abandoned uploads.
*/
function clearStaged() {
for (const staged of App.stagedAttachments) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedAttachments = [];
_renderStrip();
_updateSendBlock();
}
// ── Send Integration ────────────────────────
/**
* Returns attachment IDs (server-side UUIDs) for the current staged set.
* Only includes successfully uploaded attachments.
*/
function getStagedAttachmentIds() {
return App.stagedAttachments
.filter(a => a.serverId)
.map(a => a.serverId);
}
/**
* Whether the send button should be blocked due to in-flight uploads.
* Per design: blocked ONLY while any file is actively uploading (bytes in flight).
* Queued/processing/failed extraction does NOT block send.
*/
function isSendBlocked() {
return App.stagedAttachments.some(a => a.status === 'uploading');
}
/**
* Whether there are any staged attachments (for UI visibility).
*/
function hasStagedAttachments() {
return App.stagedAttachments.length > 0;
}
/**
* Post-send cleanup: detach staged list without deleting from backend
* (attachments are now linked to the sent message).
*/
function consumeStaged() {
for (const staged of App.stagedAttachments) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedAttachments = [];
_renderStrip();
_updateSendBlock();
}
// ── Extraction Polling ──────────────────────
function _startPolling(staged) {
if (_pollTimers.has(staged.localId)) return;
const timer = setInterval(async () => {
if (!staged.serverId) return;
try {
const data = await API.getAttachment(staged.serverId);
const meta = data.metadata || {};
staged.extractionStatus = meta.extraction_status || null;
staged.queuePosition = meta.queue_position ?? null;
staged.status = _mapExtractionStatus(meta.extraction_status);
if (meta.extraction_error) {
staged.error = meta.extraction_error;
}
_renderStrip();
// Stop polling on terminal state
if (staged.status === 'complete' || staged.status === 'failed') {
_stopPolling(staged.localId);
}
} catch (e) {
console.warn('Extraction poll failed for', staged.localId, e.message);
// Don't stop polling on transient errors — backend might be restarting
}
}, ATTACHMENT_POLL_INTERVAL);
_pollTimers.set(staged.localId, timer);
}
function _stopPolling(localId) {
const timer = _pollTimers.get(localId);
if (timer) {
clearInterval(timer);
_pollTimers.delete(localId);
}
}
function _stopAllPolling() {
for (const [id, timer] of _pollTimers) {
clearInterval(timer);
}
_pollTimers.clear();
}
// ── Status Helpers ──────────────────────────
/**
* Map backend extraction_status to frontend staged status.
* Images have no extraction — they go straight to 'complete'.
*/
function _mapExtractionStatus(backendStatus) {
switch (backendStatus) {
case 'pending': return 'queued';
case 'processing': return 'processing';
case 'complete': return 'complete';
case 'failed': return 'failed';
case 'unavailable': return 'complete'; // no extraction needed (e.g., images)
default: return 'queued';
}
}
/**
* Human-readable status label for a staged attachment.
*/
function attachmentStatusLabel(staged) {
switch (staged.status) {
case 'uploading':
return staged.uploadProgress > 0 ? `Uploading ${staged.uploadProgress}%` : 'Uploading…';
case 'queued':
if (staged.queuePosition != null && staged.queuePosition > 0) {
return `Queued (${staged.queuePosition} ahead)`;
}
return 'Queued';
case 'processing': return 'Extracting…';
case 'complete': return '✓ Ready';
case 'failed': return '⚠ Extraction failed';
case 'error': return '✕ ' + (staged.error || 'Upload failed');
default: return '';
}
}
/**
* Whether the given content type is an image (for vision gating).
*/
function isImageAttachment(contentType) {
return IMAGE_TYPES.has(contentType);
}
/**
* Format file size for display.
*/
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ── Strip Rendering ─────────────────────────
function _renderStrip() {
const strip = document.getElementById('attachmentStrip');
if (!strip) return;
if (App.stagedAttachments.length === 0) {
strip.style.display = 'none';
strip.innerHTML = '';
return;
}
strip.style.display = 'flex';
strip.innerHTML = App.stagedAttachments.map(a => {
const icon = isImageAttachment(a.contentType) ? '🖼' : '📄';
const name = _truncFilename(a.filename, 20);
const size = formatFileSize(a.sizeBytes);
const label = attachmentStatusLabel(a);
const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' :
a.status === 'complete' ? 'att-ready' : 'att-pending';
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
<span class="att-info">
<span class="att-name" title="${_esc(a.filename)}">${_esc(name)}</span>
<span class="att-meta">${_esc(size)}${label ? ' · ' + label : ''}</span>
</span>
<button class="att-remove" onclick="unstageFile('${a.localId}')" title="Remove">✕</button>
</div>`;
}).join('');
}
function _truncFilename(name, max) {
if (name.length <= max) return name;
const ext = name.lastIndexOf('.');
if (ext > 0 && name.length - ext <= 6) {
// Preserve extension: "very-long-name...pdf"
const suffix = name.slice(ext);
return name.slice(0, max - suffix.length - 1) + '…' + suffix;
}
return name.slice(0, max - 1) + '…';
}
function _esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── Channel Attachment Loading ──────────────
/**
* Fetch all attachments for a channel, build messageId → [att] map.
* Called after loading messages in selectChat / reloadActivePath.
* Non-blocking: failures just leave the map empty (attachments are optional UX).
*/
async function loadChannelAttachments(channelId) {
App.channelAttachments = {};
if (!channelId || !App.storageConfigured) return;
try {
const data = await API.listChannelAttachments(channelId);
const list = data.attachments || data || [];
for (const att of list) {
if (!att.message_id) continue;
if (!App.channelAttachments[att.message_id]) {
App.channelAttachments[att.message_id] = [];
}
App.channelAttachments[att.message_id].push(att);
}
} catch (e) {
console.debug('Attachment load skipped:', e.message);
}
}
// ── Message Attachment Rendering ────────────
/**
* Returns HTML string for attachments associated with a message.
* Called from ui-core.js _messageHTML(). Returns '' if none.
*
* Images use data-att-id; actual src is loaded post-render with auth
* via loadAuthImages(). Doc downloads use onclick handlers.
*/
function renderMessageAttachments(msgId) {
if (!msgId) return '';
const atts = App.channelAttachments[msgId];
if (!atts || atts.length === 0) return '';
// Check current model vision capability for hint
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const hasVision = !!modelInfo?.capabilities?.vision;
const items = atts.map(att => {
const isImage = isImageAttachment(att.content_type);
if (isImage) {
const dims = att.metadata?.thumb_dimensions || att.metadata?.dimensions;
const w = Math.min(dims?.width || 280, 320);
const visionHint = !hasVision
? '<span class="att-vision-hint" title="Current model does not support images">👁️‍🗨️</span>'
: '';
return `<div class="msg-att msg-att-image" onclick="openLightbox('${_esc(att.id)}')">
<img class="msg-att-img" data-att-id="${_esc(att.id)}" alt="${_esc(att.filename)}"
style="max-width:${w}px" loading="lazy">
${visionHint}
</div>`;
}
// Document chip
const icon = _docIcon(att.content_type);
const size = formatFileSize(att.size_bytes);
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadAttachment('${_esc(att.id)}','${_esc(att.filename)}');return false">
<span class="att-doc-icon">${icon}</span>
<span class="att-doc-info">
<span class="att-doc-name">${_esc(att.filename)}</span>
<span class="att-doc-size">${size}</span>
</span>
<span class="att-doc-dl">⬇</span>
</a>`;
});
return `<div class="msg-attachments">${items.join('')}</div>`;
}
function _docIcon(contentType) {
if (contentType === 'application/pdf') return '📕';
if (contentType?.includes('spreadsheet') || contentType?.includes('excel')) return '📊';
if (contentType?.includes('presentation') || contentType?.includes('powerpoint')) return '📙';
if (contentType?.includes('word') || contentType?.includes('document')) return '📄';
if (contentType?.startsWith('text/')) return '📝';
return '📎';
}
// ── Image Lightbox ──────────────────────────
function openLightbox(attachmentId) {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay || !img) return;
img.src = '';
img.alt = 'Loading…';
overlay.classList.add('active');
document.body.style.overflow = 'hidden';
// Fetch full-size image with auth
API.downloadAttachmentBlob(attachmentId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => { img.alt = 'Failed to load image'; });
}
function closeLightbox() {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay) return;
overlay.classList.remove('active');
document.body.style.overflow = '';
// Revoke previous blob URL to free memory
if (img?.src?.startsWith('blob:')) URL.revokeObjectURL(img.src);
if (img) { img.src = ''; img.alt = ''; }
}
// ── Auth Image Loading ──────────────────────
/**
* Post-render pass: finds all <img data-att-id="..."> in the given
* container and fetches them with auth headers, setting blob URLs.
* Called after renderMessages and reloadActivePath.
*/
function loadAuthImages(container) {
if (!container) container = document.getElementById('chatMessages');
if (!container) return;
const imgs = container.querySelectorAll('img[data-att-id]:not([data-loaded])');
for (const img of imgs) {
const attId = img.dataset.attId;
if (!attId) continue;
img.dataset.loaded = '1';
API.downloadAttachmentBlob(attId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => {
img.alt = '⚠ Load failed';
img.classList.add('att-load-error');
});
}
}
// ── Document Download ───────────────────────
/**
* Download an attachment file via auth-aware fetch.
* Creates a temporary <a> element to trigger the browser download dialog.
*/
async function downloadAttachment(attachmentId, filename) {
try {
const blob = await API.downloadAttachmentBlob(attachmentId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
UI.toast('Download failed: ' + e.message, 'error');
}
}
// ── Admin Storage Panel ─────────────────────
async function loadAdminStorage() {
const el = document.getElementById('adminStorageContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const [status, orphans] = await Promise.all([
API.adminGetStorageStatus().catch(() => null),
API.adminGetOrphanCount().catch(() => null),
]);
let html = '';
// Status card
if (status) {
const healthClass = status.healthy ? 'badge-success' : 'badge-danger';
const healthLabel = status.healthy ? 'Healthy' : 'Unhealthy';
html += `<div class="admin-storage-card">
<h4>Storage Backend</h4>
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Backend</span>
<span class="admin-storage-value">${_esc(status.backend || 'none')}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Status</span>
<span class="badge ${healthClass}">${healthLabel}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Total Files</span>
<span class="admin-storage-value">${status.total_files ?? '—'}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Total Size</span>
<span class="admin-storage-value">${status.total_bytes ? formatFileSize(status.total_bytes) : '—'}</span>
</div>
${status.path ? `<div class="admin-storage-item">
<span class="admin-storage-label">Path</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.path)}</span>
</div>` : ''}
${status.endpoint ? `<div class="admin-storage-item">
<span class="admin-storage-label">Endpoint</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.endpoint)}</span>
</div>` : ''}
${status.bucket ? `<div class="admin-storage-item">
<span class="admin-storage-label">Bucket</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.bucket)}</span>
</div>` : ''}
</div>
</div>`;
} else {
html += `<div class="admin-storage-card">
<h4>Storage Backend</h4>
<p class="empty-hint">Storage not configured. Set STORAGE_BACKEND=pvc or STORAGE_BACKEND=s3 to enable file uploads.</p>
</div>`;
}
// Orphan cleanup card
if (orphans) {
const count = orphans.count ?? 0;
const bytes = orphans.total_bytes ?? 0;
html += `<div class="admin-storage-card">
<h4>Orphan Files</h4>
<p class="admin-storage-desc">Files uploaded but never linked to a message for &gt;24 hours.</p>
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Orphaned</span>
<span class="admin-storage-value">${count} file${count !== 1 ? 's' : ''}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Reclaimable</span>
<span class="admin-storage-value">${formatFileSize(bytes)}</span>
</div>
</div>
${count > 0 ? `<button class="btn-small" id="adminRunCleanup" style="margin-top:8px">Run Cleanup Now</button>` : ''}
</div>`;
}
el.innerHTML = html;
// Wire cleanup button
document.getElementById('adminRunCleanup')?.addEventListener('click', async (e) => {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Cleaning…';
try {
const result = await API.adminRunStorageCleanup();
UI.toast(`Cleaned ${result.deleted || 0} orphan${result.deleted !== 1 ? 's' : ''}, freed ${formatFileSize(result.freed_bytes || 0)}`, 'success');
await loadAdminStorage(); // refresh counts
} catch (err) {
UI.toast('Cleanup failed: ' + err.message, 'error');
btn.disabled = false;
btn.textContent = 'Run Cleanup Now';
}
});
} catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${_esc(e.message)}</div>`;
}
}
// ── Constants (Paste) ───────────────────────
// Threshold for auto-attaching pasted text as a file.
// TODO: sync from backend global_settings (storage_paste_to_file_chars)
// once it's included in the PublicSettings boot payload.
const PASTE_TO_FILE_CHARS = 2000;
// Accept string for file picker — mirrors backend allowedMIMETypes.
// Backend is authoritative (rejects disallowed types server-side),
// this just gives the OS file dialog a better filter.
const FILE_ACCEPT = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
'text/plain', 'text/markdown', 'text/csv',
'.docx', '.xlsx', '.pptx', '.doc', '.xls', '.odt', '.ods', '.odp', '.rtf',
].join(',');
// ── MIME → Extension ────────────────────────
const _mimeToExt = {
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/svg+xml': 'svg',
'application/pdf': 'pdf', 'text/plain': 'txt', 'text/markdown': 'md',
'text/csv': 'csv', 'text/html': 'html',
};
function _extFromMime(mime) {
return _mimeToExt[mime] || mime.split('/').pop() || 'bin';
}
// ── Send Button Blocking ────────────────────
function _updateSendBlock() {
const sendBtn = document.getElementById('sendBtn');
if (!sendBtn) return;
// Merge with existing generation check — don't override if generating
if (App.isGenerating) return;
if (isSendBlocked()) {
sendBtn.disabled = true;
sendBtn.title = 'Upload in progress…';
} else {
sendBtn.disabled = false;
sendBtn.title = 'Send';
}
}
// ── Init + Listeners ────────────────────────
/**
* Called from app.js startApp(). Reads storage_configured from boot
* payload and sets up attachment UI visibility.
*/
function initAttachments() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
if (attachBtn) {
attachBtn.style.display = App.storageConfigured ? '' : 'none';
}
if (fileInput) {
fileInput.accept = FILE_ACCEPT;
}
console.log(`📎 Attachments: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
}
/**
* Called from app.js initListeners(). Wires up all attachment input
* handlers: button, file picker, paste, and drag-and-drop.
*/
function _initAttachmentListeners() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const messageInput = document.getElementById('messageInput');
const chatArea = document.getElementById('chatMessages');
const lightbox = document.getElementById('lightbox');
// ── 📎 Button → File Picker ─────────────
if (attachBtn && fileInput) {
attachBtn.addEventListener('click', () => {
if (!App.storageConfigured) return;
fileInput.click();
});
fileInput.addEventListener('change', () => {
const files = fileInput.files;
if (!files?.length) return;
for (const file of files) stageFile(file);
fileInput.value = ''; // reset so same file can be re-selected
});
}
// ── Smart Paste ─────────────────────────
if (messageInput) {
messageInput.addEventListener('paste', (e) => {
if (!App.storageConfigured) return; // fall through to normal paste
const items = [...(e.clipboardData?.items || [])];
// Binary detection: images/files from clipboard
const binaryItems = items.filter(i => i.kind === 'file');
if (binaryItems.length > 0) {
e.preventDefault();
for (const item of binaryItems) {
const file = item.getAsFile();
if (!file) continue;
// Generate UUID filename — original clipboard items have no useful name
const ext = _extFromMime(file.type);
const renamed = new File([file], `${crypto.randomUUID()}.${ext}`, { type: file.type });
stageFile(renamed);
}
return;
}
// Large text detection — auto-attach as .txt file
if (PASTE_TO_FILE_CHARS > 0) {
const text = e.clipboardData?.getData('text/plain');
if (text && text.length > PASTE_TO_FILE_CHARS) {
e.preventDefault();
const blob = new Blob([text], { type: 'text/plain' });
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
stageFile(file);
return;
}
}
// Below threshold or no text: normal paste (no preventDefault)
});
}
// ── Drag-and-Drop ───────────────────────
if (chatArea) {
// Track drag enter/leave depth to avoid flicker from child elements
let dragDepth = 0;
chatArea.addEventListener('dragenter', (e) => {
if (!App.storageConfigured) return;
if (!_hasDragFiles(e)) return;
e.preventDefault();
dragDepth++;
if (dragDepth === 1) chatArea.classList.add('drag-over');
});
chatArea.addEventListener('dragover', (e) => {
if (!App.storageConfigured) return;
if (!_hasDragFiles(e)) return;
e.preventDefault(); // required to allow drop
e.dataTransfer.dropEffect = 'copy';
});
chatArea.addEventListener('dragleave', (e) => {
if (!App.storageConfigured) return;
dragDepth--;
if (dragDepth <= 0) {
dragDepth = 0;
chatArea.classList.remove('drag-over');
}
});
chatArea.addEventListener('drop', (e) => {
if (!App.storageConfigured) return;
e.preventDefault();
dragDepth = 0;
chatArea.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (!files?.length) return;
for (const file of files) stageFile(file);
});
}
// ── Lightbox ────────────────────────────
if (lightbox) {
// Click overlay (not the image) to close
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox || e.target.classList.contains('lightbox-close')) {
closeLightbox();
}
});
}
}
/**
* Check if a drag event contains files (not just text selection drag).
*/
function _hasDragFiles(e) {
if (!e.dataTransfer?.types) return false;
return e.dataTransfer.types.includes('Files');
}

View File

@@ -64,7 +64,8 @@ async function loadChats() {
model: c.model || '',
messageCount: c.message_count || 0,
messages: [],
updatedAt: c.updated_at
updatedAt: c.updated_at,
settings: c.settings || {},
}));
} catch (e) {
console.error('Failed to load chats:', e.message);
@@ -73,6 +74,7 @@ async function loadChats() {
}
async function selectChat(chatId) {
clearStaged(); // Discard any staged attachments from previous chat
App.currentChatId = chatId;
UI.renderChatList();
if (window.innerWidth <= 768) {
@@ -109,6 +111,9 @@ async function selectChat(chatId) {
// Restore the last-used model/preset for this chat
_restoreChatModel(chatId, chat);
// Load attachments for message rendering (non-blocking, best-effort)
await loadChannelAttachments(chatId);
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
Tokens._warningDismissed = false;
@@ -117,46 +122,63 @@ async function selectChat(chatId) {
}
// ── Per-Chat Model/Preset Persistence ────────
// BANDAID: localStorage — doesn't roam across devices.
// TODO: move to channel.settings.last_selector_id (server-side). See ROADMAP TBD.
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
// localStorage used as write-through cache for instant restore without
// waiting for the channel list API call.
function _saveChatModel(chatId) {
if (!chatId) return;
const selectorId = UI.getModelValue();
if (!selectorId) return;
// Write-through: localStorage for instant restore on next visit
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
map[chatId] = selectorId;
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* quota exceeded, etc */ }
// Update in-memory chat object
const chat = App.chats.find(c => c.id === chatId);
if (chat) {
if (!chat.settings) chat.settings = {};
chat.settings.last_selector_id = selectorId;
}
// Persist to server (fire-and-forget — non-critical)
API.updateChannel(chatId, { settings: { last_selector_id: selectorId } })
.catch(e => console.debug('Failed to persist model selection:', e.message));
}
function _restoreChatModel(chatId, chat) {
// Priority: stored selector ID → channel base model → keep current
// Priority: server settings → localStorage fallback → channel base model → keep current
let targetId = null;
// 1. Check localStorage for exact selector ID (handles presets)
// 1. Server-side settings (most authoritative, roams across devices)
if (chat?.settings?.last_selector_id) {
targetId = chat.settings.last_selector_id;
}
// 2. localStorage fallback (covers race where settings haven't loaded yet)
if (!targetId) {
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
if (map[chatId]) targetId = map[chatId];
} catch (e) { /* */ }
}
// 2. Verify the stored ID still exists in available models
// 3. Verify the stored ID still exists in available models
if (targetId) {
const found = App.models.find(m => m.id === targetId);
if (found) {
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
return;
}
// Stored model removed — clean up stale entry
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
// Stored model removed — clean up stale entries
_cleanStaleChatModel(chatId);
}
// 3. Fall back to channel's base model ID (match by baseModelId)
// 4. Fall back to channel's base model ID (match by baseModelId)
if (chat?.model) {
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
if (match) {
@@ -165,7 +187,7 @@ function _restoreChatModel(chatId, chat) {
}
}
// 4. Stored model gone — fall back to admin default → first visible
// 5. Stored model gone — fall back to admin default → first visible
const visible = App.models.filter(m => !m.hidden);
const def = App.defaultModel
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
@@ -176,7 +198,22 @@ function _restoreChatModel(chatId, chat) {
}
}
function _cleanStaleChatModel(chatId) {
// Remove from localStorage
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
// Clear from in-memory settings (server cleanup is fire-and-forget)
const chat = App.chats.find(c => c.id === chatId);
if (chat?.settings?.last_selector_id) {
delete chat.settings.last_selector_id;
}
}
async function newChat() {
clearStaged(); // Discard any staged attachments
App.currentChatId = null;
UI.renderChatList();
UI.showEmptyState();
@@ -213,11 +250,17 @@ async function deleteChat(chatId) {
async function sendMessage() {
const input = document.getElementById('messageInput');
const text = input.value.trim();
if (!text || App.isGenerating) return;
const hasAttachments = hasStagedAttachments();
// Need text or attachments, not generating, not blocked by uploads
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
input.value = '';
input.style.height = 'auto';
// Snapshot attachment IDs before clearing staged state
const attachmentIds = getStagedAttachmentIds();
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
// For presets, send the base model ID; for regular models, send the model ID
@@ -239,15 +282,19 @@ async function sendMessage() {
if (!chat) return;
// Optimistic: show user message immediately
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.length > 1 ? 's' : ''} attached]` : '');
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
UI.renderMessages(chat.messages);
// Clear staged attachments (they're now part of this message)
if (hasAttachments) consumeStaged();
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
// Reload active path from server to get proper IDs and sibling metadata
@@ -307,6 +354,7 @@ async function reloadActivePath() {
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
await loadChannelAttachments(App.currentChatId);
UI.renderMessages(chat.messages);
updateContextWarning();
} catch (e) {

View File

@@ -76,6 +76,7 @@ Object.assign(UI, {
if (tab === 'roles') await this.loadAdminRoles();
if (tab === 'usage') await this.loadAdminUsage();
if (tab === 'extensions') await this.loadAdminExtensions();
if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage();
},
async loadAdminUsers(quiet) {
@@ -679,6 +680,34 @@ Object.assign(UI, {
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
});
UI._bannerPresets = presets;
// Vault / Encryption status
const vaultEl = document.getElementById('adminVaultStatus');
if (vaultEl) {
try {
const vault = await API.adminGetVaultStatus();
const keyBadge = vault.encryption_key_set
? '<span class="badge badge-success">Active</span>'
: '<span class="badge badge-warning">Not Set</span>';
vaultEl.innerHTML = `
<div class="admin-storage-grid" style="margin-top:6px">
<div class="admin-storage-item">
<span class="admin-storage-label">Encryption</span>
${keyBadge}
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Encrypted Keys</span>
<span class="admin-storage-value">${vault.encrypted_keys}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Vault Users</span>
<span class="admin-storage-value">${vault.vault_users}</span>
</div>
</div>`;
} catch (e) {
vaultEl.innerHTML = '<span class="empty-hint">Could not load vault status</span>';
}
}
} catch (e) { console.debug('Failed to load admin settings:', e); }
},

View File

@@ -353,6 +353,7 @@ const UI = {
el.innerHTML = html;
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
if (typeof loadAuthImages === 'function') loadAuthImages(el);
this._scrollToBottom(true);
},
@@ -420,6 +421,7 @@ const UI = {
</div>
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
<div class="msg-text">${formatMessage(msg.content)}</div>
${typeof renderMessageAttachments === 'function' ? renderMessageAttachments(msgId) : ''}
</div>
</div>
</div>`;

View File

@@ -21,6 +21,7 @@ const SHELL_FILES = [
'./js/ui-admin.js',
'./js/tokens.js',
'./js/notes.js',
'./js/attachments.js',
'./js/chat.js',
'./js/settings-handlers.js',
'./js/admin-handlers.js',

46
status.go Normal file
View File

@@ -0,0 +1,46 @@
package crypto
import (
"database/sql"
"fmt"
)
// VaultStatusInfo holds vault health information for admin display.
type VaultStatusInfo struct {
EncryptionKeySet bool `json:"encryption_key_set"`
EncryptedKeys int `json:"encrypted_keys"` // provider_configs with api_key_enc
VaultUsers int `json:"vault_users"` // users with vault_set = true
}
// VaultStatus gathers vault health metrics from the database.
// Used by both the CLI (`switchboard vault status`) and the admin API endpoint.
func VaultStatus(db *sql.DB, encryptionKey string) (*VaultStatusInfo, error) {
if db == nil {
return nil, fmt.Errorf("database not available")
}
info := &VaultStatusInfo{
EncryptionKeySet: encryptionKey != "",
}
// Count encrypted provider keys (global + team + personal)
err := db.QueryRow(`
SELECT COUNT(*) FROM provider_configs
WHERE api_key_enc IS NOT NULL
`).Scan(&info.EncryptedKeys)
if err != nil {
return nil, fmt.Errorf("count encrypted keys: %w", err)
}
// Count users with active vaults
err = db.QueryRow(`
SELECT COUNT(*) FROM users
WHERE vault_set = true
`).Scan(&info.VaultUsers)
if err != nil {
// vault_set column might not exist on very old installs
info.VaultUsers = 0
}
return info, nil
}

1893
styles.css Normal file

File diff suppressed because it is too large Load Diff

727
ui-admin.js Normal file
View File

@@ -0,0 +1,727 @@
// ==========================================
// Chat Switchboard UI Admin
// ==========================================
// Extends UI with admin modal tabs: users, stats, roles,
// usage, audit, providers, models, presets, teams, settings.
Object.assign(UI, {
// ── Admin Modal ──────────────────────────
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
closeAdmin() { closeModal('adminModal'); },
openTeamAdmin() {
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
if (adminTeams.length === 0) {
UI.toast('You are not an admin of any teams', 'error');
return;
}
openModal('teamAdminModal');
if (adminTeams.length === 1) {
// Skip picker, go directly to the team
UI.openTeamManage(adminTeams[0].id, adminTeams[0].name);
} else {
// Show team picker
document.getElementById('teamAdminTitle').textContent = 'Team Management';
document.getElementById('teamAdminPicker').style.display = '';
document.getElementById('teamAdminContent').style.display = 'none';
document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => `
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
<div class="team-card-info">
<strong>${esc(t.name)}</strong>
<span class="badge-admin">admin</span>
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
</div>
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
}
},
closeTeamAdmin() { closeModal('teamAdminModal'); },
switchTeamTab(tab) {
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
tabs.forEach(t => {
t.classList.remove('active');
if (t.dataset.ttab === tab) t.classList.add('active');
});
document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`);
if (panel) panel.style.display = '';
// Lazy-load tab data
const teamId = UI._managingTeamId;
if (tab === 'members') UI.loadTeamManageMembers(teamId);
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
},
async switchAdminTab(tab) {
document.querySelectorAll('#adminModal .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'users') await this.loadAdminUsers();
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'audit') await this.loadAuditLog();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'teams') await this.loadAdminTeams();
if (tab === 'settings') await this.loadAdminSettings();
if (tab === 'roles') await this.loadAdminRoles();
if (tab === 'usage') await this.loadAdminUsage();
if (tab === 'extensions') await this.loadAdminExtensions();
if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage();
},
async loadAdminUsers(quiet) {
const el = document.getElementById('adminUserList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListUsers();
const users = resp.users || resp.data || [];
el.innerHTML = users.map(u => {
const teamBadges = (u.teams || []).map(t =>
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
).join(' ');
const isPending = !u.is_active;
return `
<div class="admin-user-row${isPending ? ' user-inactive' : ''}">
<div class="admin-user-info">
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
${isPending ? '<span class="badge-pending">pending</span>' : ''}
${teamBadges}</div>
<div class="admin-user-email">${esc(u.email)}</div>
</div>
<div class="admin-user-actions">
${isPending
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>`
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
}
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
</div>
</div>
<div class="admin-approve-form" id="approveForm-${u.id}" style="display:none">
<div class="approve-teams" id="approveTeams-${u.id}"></div>
<div class="form-row" style="margin-top:8px">
<button class="btn-small btn-primary" onclick="submitApproval('${u.id}')">Activate & Assign</button>
<button class="btn-small" onclick="hideApproveForm('${u.id}')">Cancel</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No users</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminStats() {
const el = document.getElementById('adminStats');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const s = await API.adminGetStats();
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
el.innerHTML = '<div class="stats-grid">' +
Object.entries(s).map(([k, v]) => `
<div class="stat-card">
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
</div>`).join('') +
'</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Admin: Roles ────────────────────────
async loadAdminRoles() {
const el = document.getElementById('adminRolesContent');
if (!el) return;
if (!UI._adminRoleConfig) {
UI._adminRoleConfig = renderRoleConfig(el, {
scope: 'admin',
showFallback: true,
modelIdField: 'model_id',
modelNameField: 'display_name',
providerIdField: 'provider_config_id',
fetchProviders: async () => {
const configs = await API.adminListGlobalConfigs();
return configs.configs || configs || [];
},
fetchModels: async () => {
const models = await API.adminListModels();
return models.models || models.data || models || [];
},
fetchRoles: () => API.adminListRoles(),
onSave: async (roleId, config) => {
await API.adminUpdateRole(roleId, config);
},
onTest: (roleId) => API.adminTestRole(roleId),
});
}
await UI._adminRoleConfig.refresh();
},
// ── Admin: Usage ────────────────────────
async loadAdminUsage() {
// Usage stats via primitive
const totalsEl = document.getElementById('adminUsageTotals');
if (!totalsEl) return;
// Wrap totals+results in a container for the primitive (first time only)
let usageContainer = document.getElementById('_adminUsageContainer');
if (!usageContainer) {
usageContainer = document.createElement('div');
usageContainer.id = '_adminUsageContainer';
const resultsEl = document.getElementById('adminUsageResults');
totalsEl.parentElement.insertBefore(usageContainer, totalsEl);
usageContainer.appendChild(totalsEl);
if (resultsEl) usageContainer.appendChild(resultsEl);
}
if (!UI._adminUsageDash) {
UI._adminUsageDash = renderUsageDashboard(usageContainer, {
apiFetch: (p) => API.adminGetUsage(p),
periodElId: 'usagePeriod',
groupByElId: 'usageGroupBy',
compact: false,
showUserColumn: true,
});
}
await UI._adminUsageDash.refresh();
// Pricing table (admin-only, separate from usage primitive)
const pricingEl = document.getElementById('adminPricingTable');
if (pricingEl) {
try {
const pricing = await API.adminListPricing();
const entries = pricing || [];
if (entries.length === 0) {
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}
},
async loadAdminExtensions() {
const el = document.getElementById('adminExtensionsList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API._get('/api/v1/admin/extensions');
const exts = resp.data || [];
this._adminExtensions = exts; // store for edit access
if (exts.length === 0) {
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
return;
}
el.innerHTML = exts.map(ext => {
const statusBadge = ext.is_enabled
? '<span class="badge-admin" style="font-size:11px">enabled</span>'
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>';
const systemBadge = ext.is_system
? ' <span class="badge-user" style="font-size:11px">system</span>'
: '';
return `
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start">
<div class="admin-user-info" style="flex:1">
<div style="display:flex;align-items:center;gap:8px">
<strong>${esc(ext.name)}</strong>
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code>
${statusBadge}${systemBadge}
</div>
<div class="text-muted" style="font-size:12px;margin-top:2px">
${esc(ext.description || 'No description')}
${ext.author ? ` · by ${esc(ext.author)}` : ''}
· v${esc(ext.version)} · ${ext.tier}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn-small" onclick="editAdminExtension('${ext.id}')">
Edit
</button>
<button class="btn-small" onclick="toggleAdminExtension('${ext.id}', ${!ext.is_enabled})">
${ext.is_enabled ? 'Disable' : 'Enable'}
</button>
<button class="btn-small btn-danger" onclick="deleteAdminExtension('${ext.id}', '${esc(ext.name)}')">
Uninstall
</button>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</div>';
}
},
_auditPage: 1,
_auditPerPage: 30,
async loadAuditLog(page) {
if (page) UI._auditPage = page;
const el = document.getElementById('adminAuditList');
el.innerHTML = '<div class="loading">Loading...</div>';
// Populate action filter on first load
const actionSel = document.getElementById('auditFilterAction');
if (actionSel && actionSel.options.length <= 1) {
try {
const resp = await API.adminListAuditActions();
(resp.actions || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
actionSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
const params = {
page: UI._auditPage,
per_page: UI._auditPerPage,
action: document.getElementById('auditFilterAction')?.value || '',
resource_type: document.getElementById('auditFilterResource')?.value || '',
};
try {
const resp = await API.adminListAudit(params);
const entries = resp.data || [];
const total = resp.total || 0;
const totalPages = Math.ceil(total / UI._auditPerPage);
if (entries.length === 0) {
el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
} else {
el.innerHTML = entries.map(e => {
const ts = new Date(e.created_at);
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const actor = e.actor_name || 'system';
const meta = UI._formatAuditMeta(e.metadata);
return `<div class="audit-row">
<div class="audit-main">
<span class="audit-action">${esc(e.action)}</span>
<span class="audit-actor">${esc(actor)}</span>
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
</div>
<div class="audit-meta">
${meta ? `<span class="audit-details">${meta}</span>` : ''}
<span class="audit-time">${timeStr}</span>
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
</div>
</div>`;
}).join('');
}
// Pagination
const pgEl = document.getElementById('auditPagination');
if (totalPages > 1) {
pgEl.style.display = 'flex';
document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`;
document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1;
document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages;
} else {
pgEl.style.display = 'none';
}
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
_formatAuditMeta(metaStr) {
try {
const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr;
if (!m || Object.keys(m).length === 0) return '';
return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · ');
} catch { return ''; }
},
async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList');
const formEl = document.getElementById('adminAddProviderForm');
// Initialize admin provider form primitive (once)
if (!UI._adminProvForm && formEl) {
UI._adminProvForm = renderProviderForm(formEl, {
prefix: 'adminProv',
showPrivate: true,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
try {
if (isEdit) {
const updates = {};
if (vals.name) updates.name = vals.name;
if (vals.endpoint) updates.endpoint = vals.endpoint;
if (vals.api_key) updates.api_key = vals.api_key;
updates.model_default = vals.model_default;
updates.is_private = vals.is_private || false;
await API.adminUpdateGlobalConfig(vals._editId, updates);
UI.toast('Provider updated', 'success');
} else {
if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
UI.toast('Provider added', 'success');
}
formEl.style.display = 'none';
UI._adminProvForm.setCreateMode();
UI._adminProvList.refresh();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
UI._adminProvForm.setCreateMode();
},
});
}
// Initialize admin provider list primitive (once)
if (!UI._adminProvList) {
UI._adminProvList = renderProviderList(el, {
apiFetch: () => API.adminListGlobalConfigs(),
showEndpoint: true,
showPrivate: true,
emptyMsg: 'No global providers — add one above',
onEdit: (prov) => {
if (UI._adminProvForm) {
UI._adminProvForm.setEditMode(prov.id, prov);
formEl.style.display = '';
}
},
onDelete: async (prov) => {
if (!await showConfirm('Delete this global provider?')) return;
try {
await API.adminDeleteGlobalConfig(prov.id);
UI.toast('Provider deleted', 'success');
UI._adminProvList.refresh();
} catch (e) { UI.toast(e.message, 'error'); }
},
});
}
await UI._adminProvList.refresh(quiet);
},
async loadAdminModels(quiet) {
const el = document.getElementById('adminModelList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListModels();
const list = data.models || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(m => {
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true });
// Support both old is_enabled (bool) and new visibility (string)
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
return `<div class="admin-model-row">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
</div>`;
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminPresets(quiet) {
const el = document.getElementById('adminPresetList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPresets();
const list = data.presets || [];
// Ensure form is initialized for dropdown population
ensureAdminPresetForm();
// Populate model dropdown from admin model list (all synced models)
const modelSel = _adminPresetForm?.getModelSelect();
if (modelSel) {
modelSel.innerHTML = '<option value="">Select base model...</option>';
try {
const modelData = await API.adminListModels();
const allModels = modelData.models || modelData.data || [];
const arr = Array.isArray(allModels) ? allModels : [];
arr.forEach(m => {
const opt = document.createElement('option');
const mid = m.model_id || m.id;
opt.value = mid;
opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for preset form:', e.message); }
}
// Populate config dropdown
const cfgSel = _adminPresetForm?.getConfigSelect();
if (cfgSel && cfgSel.options.length <= 1) {
try {
const cfgData = await API.adminListGlobalConfigs();
const cfgs = cfgData.configs || cfgData.data || [];
(Array.isArray(cfgs) ? cfgs : []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name + ' (' + c.provider + ')';
cfgSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
UI._presetCache = {};
el.innerHTML = list.map(p => {
UI._presetCache[p.id] = p;
const avatarEl = p.avatar
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
: '';
// Scope badge with attribution
let scopeBadge = '';
if (p.scope === 'global') {
scopeBadge = '<span class="badge-admin">global</span>';
} else if (p.scope === 'team' && p.team_name) {
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`;
} else if (p.scope === 'personal') {
scopeBadge = '<span class="badge-user">personal</span>';
}
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
</div>
<div class="preset-actions">
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Teams Admin ─────────────────────────
_teamEditId: null,
async loadAdminTeams(quiet) {
const el = document.getElementById('adminTeamList');
const detail = document.getElementById('adminTeamDetail');
if (!quiet) {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddTeamForm').style.display = 'none';
}
try {
const resp = await API.adminListTeams();
const teams = resp.data || [];
el.innerHTML = teams.map(t => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(t.name)}</strong>
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
</div>
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
<div class="admin-user-actions">
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openTeamDetail(teamId, teamName) {
this._teamEditId = teamId;
document.getElementById('adminTeamList').style.display = 'none';
document.getElementById('adminAddTeamForm').style.display = 'none';
const detail = document.getElementById('adminTeamDetail');
detail.style.display = '';
document.getElementById('adminTeamDetailName').textContent = teamName;
document.getElementById('adminAddMemberForm').style.display = 'none';
// Load team settings for policy checkboxes
try {
const team = await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
// allow_team_providers defaults to true if not explicitly set
document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false;
} catch (e) { /* proceed with defaults */ }
await this.loadTeamMembers(teamId);
},
async loadTeamMembers(teamId) {
const el = document.getElementById('adminMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListMembers(teamId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(m.display_name || m.email)}</strong>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
</div>
<div class="text-muted">${esc(m.email)}</div>
</div>
<div class="admin-user-actions">
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
</select>
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMemberUserDropdown(teamId) {
const sel = document.getElementById('adminMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.users || resp.data || [];
// Also get existing members to exclude
const memberResp = await API.adminListMembers(teamId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
sel.innerHTML = '<option value="">Select user...</option>' +
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();
// v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } }
const settings = data.settings || {};
const policies = data.policies || {};
// Helper to read from settings map (JSONB values)
const getSetting = (key, fallback) => {
const v = settings[key];
if (v === undefined || v === null) return fallback;
// Unwrap {value: X} wrapper if present
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
};
// Registration (policy: allow_registration)
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
// Admin system prompt (global_settings)
const sysPrompt = getSetting('system_prompt', {}) || {};
document.getElementById('adminSystemPrompt').value = sysPrompt.content || '';
// Registration default state (policy: default_user_active → 'true' means auto-active)
const defaultActive = policies.default_user_active === 'true';
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
// User providers / BYOK (policy: allow_user_byok)
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
// User presets / personas (policy: allow_user_personas)
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
// Default model (policy: default_model) — populate from current model list
const defModelSel = document.getElementById('adminDefaultModel');
if (defModelSel) {
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
defModelSel.appendChild(opt);
});
defModelSel.value = policies.default_model || '';
}
// Banner (global_settings)
const banner = getSetting('banner', {}) || {};
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
document.getElementById('adminBannerText').value = banner.text || '';
document.getElementById('adminBannerPosition').value = banner.position || 'both';
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
UI.updateBannerPreview();
// Load banner presets (global_settings)
const presets = getSetting('banner_presets', {}) || {};
const sel = document.getElementById('adminBannerPreset');
sel.innerHTML = '<option value="">Custom</option>';
Object.entries(presets).forEach(([key, p]) => {
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
});
UI._bannerPresets = presets;
// Vault / Encryption status
const vaultEl = document.getElementById('adminVaultStatus');
if (vaultEl) {
try {
const vault = await API.adminGetVaultStatus();
const keyBadge = vault.encryption_key_set
? '<span class="badge badge-success">Active</span>'
: '<span class="badge badge-warning">Not Set</span>';
vaultEl.innerHTML = `
<div class="admin-storage-grid" style="margin-top:6px">
<div class="admin-storage-item">
<span class="admin-storage-label">Encryption</span>
${keyBadge}
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Encrypted Keys</span>
<span class="admin-storage-value">${vault.encrypted_keys}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Vault Users</span>
<span class="admin-storage-value">${vault.vault_users}</span>
</div>
</div>`;
} catch (e) {
vaultEl.innerHTML = '<span class="empty-hint">Could not load vault status</span>';
}
}
} catch (e) { console.debug('Failed to load admin settings:', e); }
},
_bannerPresets: {},
updateBannerPreview() {
const prev = document.getElementById('bannerPreview');
if (!prev) return;
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
const bg = document.getElementById('adminBannerBg').value;
const fg = document.getElementById('adminBannerFg').value;
prev.textContent = text;
prev.style.background = bg;
prev.style.color = fg;
},
});