Changeset 0.6.0 (#36)

This commit is contained in:
2026-02-20 22:24:47 +00:00
parent 30d0c11219
commit 925b70f98c
34 changed files with 2575 additions and 637 deletions

View File

@@ -1,6 +1,6 @@
# .gitea/workflows/ci.yaml # .gitea/workflows/ci.yaml
# ============================================ # ============================================
# Chat Switchboard - CI/CD Pipeline (v0.5.4) # Chat Switchboard - CI/CD Pipeline (v0.6.2)
# ============================================ # ============================================
# Cluster deployments use SEPARATE FE + BE images. # Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (docker-compose use). # Unified image is for Docker Hub only (docker-compose use).
@@ -9,16 +9,17 @@
# 1. Go test (all PRs and pushes) # 1. Go test (all PRs and pushes)
# 2. Build + Deploy (depends on test passing) # 2. Build + Deploy (depends on test passing)
# #
# Deployment mapping: # Deployment mapping (single domain, path-based):
# PR → FE + BE :dev → dev.DOMAIN (DB wipe + fresh schema) # PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema)
# Push to main → FE + BE :test → test.DOMAIN (migrate only) # Push to main → FE + BE :test → switchboard.DOMAIN/test/ (migrate only)
# Tag v* → FE + BE :latest → www.DOMAIN (migrate only) # Tag v* → FE + BE :latest → switchboard.DOMAIN/ (migrate only)
# → Unified :latest → Docker Hub (not deployed) # → Unified :latest → Docker Hub (not deployed)
# #
# Database lifecycle: # Database lifecycle:
# CI: bootstrap (admin creds) → create DB + role + extensions # CI: bootstrap (admin creds) → create DB + role + extensions
# CI: dev wipe (app creds) → drop tables for fresh install test # CI: dev wipe (app creds) → drop tables for fresh install test
# BE: auto-migrate on startup → schema_migrations tracking # BE: auto-migrate on startup → schema_migrations tracking
# BE: bootstrap admin from SWITCHBOARD_ADMIN_* env vars (upsert on every restart)
# #
# Shared PG safety: # Shared PG safety:
# - Bootstrap uses IF NOT EXISTS (idempotent) # - Bootstrap uses IF NOT EXISTS (idempotent)
@@ -32,6 +33,7 @@
# Required Gitea Secrets: # Required Gitea Secrets:
# POSTGRES_USER, POSTGRES_PASSWORD # POSTGRES_USER, POSTGRES_PASSWORD
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD # POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
# SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional) # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
# #
# Global Variables (Gitea org-level): # Global Variables (Gitea org-level):
@@ -111,10 +113,15 @@ jobs:
- name: Determine environment - name: Determine environment
id: env id: env
run: | run: |
# All environments share one host: switchboard.DOMAIN
# Environments are separated by path prefix (BASE_PATH)
DEPLOY_HOST="switchboard.${DOMAIN}"
echo "DEPLOY_HOST=${DEPLOY_HOST}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
echo "ENVIRONMENT=dev" >> "$GITHUB_OUTPUT" echo "ENVIRONMENT=dev" >> "$GITHUB_OUTPUT"
echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT"
echo "DEPLOY_HOST=dev.${DOMAIN}" >> "$GITHUB_OUTPUT" echo "BASE_PATH=/dev" >> "$GITHUB_OUTPUT"
echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT"
echo "DB_NAME=chat_switchboard_dev" >> "$GITHUB_OUTPUT" echo "DB_NAME=chat_switchboard_dev" >> "$GITHUB_OUTPUT"
echo "DB_WIPE=true" >> "$GITHUB_OUTPUT" echo "DB_WIPE=true" >> "$GITHUB_OUTPUT"
@@ -134,7 +141,7 @@ jobs:
echo "ENVIRONMENT=production" >> "$GITHUB_OUTPUT" echo "ENVIRONMENT=production" >> "$GITHUB_OUTPUT"
echo "IMAGE_TAG=latest" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=latest" >> "$GITHUB_OUTPUT"
echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT" echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT"
echo "DEPLOY_HOST=www.${DOMAIN}" >> "$GITHUB_OUTPUT" echo "BASE_PATH=" >> "$GITHUB_OUTPUT"
echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT"
echo "DB_NAME=chat_switchboard" >> "$GITHUB_OUTPUT" echo "DB_NAME=chat_switchboard" >> "$GITHUB_OUTPUT"
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
@@ -153,7 +160,7 @@ jobs:
else else
echo "ENVIRONMENT=test" >> "$GITHUB_OUTPUT" echo "ENVIRONMENT=test" >> "$GITHUB_OUTPUT"
echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT"
echo "DEPLOY_HOST=test.${DOMAIN}" >> "$GITHUB_OUTPUT" echo "BASE_PATH=/test" >> "$GITHUB_OUTPUT"
echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT" echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT"
echo "DB_NAME=chat_switchboard_test" >> "$GITHUB_OUTPUT" echo "DB_NAME=chat_switchboard_test" >> "$GITHUB_OUTPUT"
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
@@ -350,11 +357,24 @@ jobs:
kubectl create namespace ${NAMESPACE} 2>/dev/null || true kubectl create namespace ${NAMESPACE} 2>/dev/null || true
- name: Sync secrets - name: Sync secrets
env:
PG_USER: ${{ secrets.POSTGRES_USER }}
PG_PASS: ${{ secrets.POSTGRES_PASSWORD }}
SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }}
SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }}
SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }}
run: | run: |
kubectl create secret generic switchboard-db-credentials \ kubectl create secret generic switchboard-db-credentials \
--namespace=${NAMESPACE} \ --namespace=${NAMESPACE} \
--from-literal=POSTGRES_USER=${{ secrets.POSTGRES_USER }} \ --from-literal=POSTGRES_USER="${PG_USER}" \
--from-literal=POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \ --from-literal=POSTGRES_PASSWORD="${PG_PASS}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic switchboard-admin \
--namespace=${NAMESPACE} \
--from-literal=username="${SW_ADMIN_USER}" \
--from-literal=password="${SW_ADMIN_PASS}" \
--from-literal=email="${SW_ADMIN_EMAIL}" \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
- name: Render and apply manifests - name: Render and apply manifests
@@ -363,6 +383,7 @@ jobs:
IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }} IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }}
DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }} DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }}
DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }} DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }}
BASE_PATH: ${{ steps.env.outputs.BASE_PATH }}
DB_NAME: ${{ steps.env.outputs.DB_NAME }} DB_NAME: ${{ steps.env.outputs.DB_NAME }}
FE_REPLICAS: ${{ steps.env.outputs.FE_REPLICAS }} FE_REPLICAS: ${{ steps.env.outputs.FE_REPLICAS }}
BE_REPLICAS: ${{ steps.env.outputs.BE_REPLICAS }} BE_REPLICAS: ${{ steps.env.outputs.BE_REPLICAS }}
@@ -415,7 +436,8 @@ jobs:
run: | run: |
sleep 5 sleep 5
HOST="${{ steps.env.outputs.DEPLOY_HOST }}" HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
HEALTH=$(curl -sf "https://${HOST}/api/v1/health" 2>/dev/null || echo "unreachable") BP="${{ steps.env.outputs.BASE_PATH }}"
HEALTH=$(curl -sf "https://${HOST}${BP}/api/v1/health" 2>/dev/null || echo "unreachable")
echo "Health: ${HEALTH}" echo "Health: ${HEALTH}"
# Extract fields (|| true prevents set -e from killing on no-match) # Extract fields (|| true prevents set -e from killing on no-match)
@@ -432,11 +454,12 @@ jobs:
- name: Summary - name: Summary
run: | run: |
HOST="${{ steps.env.outputs.DEPLOY_HOST }}" HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
BP="${{ steps.env.outputs.BASE_PATH }}"
echo "## ✅ Deployed to ${{ steps.env.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY echo "## ✅ Deployed to ${{ steps.env.outputs.env_label }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "| | |" >> $GITHUB_STEP_SUMMARY echo "| | |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|" >> $GITHUB_STEP_SUMMARY echo "|---|---|" >> $GITHUB_STEP_SUMMARY
echo "| **URL** | https://${HOST} |" >> $GITHUB_STEP_SUMMARY echo "| **URL** | https://${HOST}${BP}/ |" >> $GITHUB_STEP_SUMMARY
echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY

View File

@@ -179,7 +179,7 @@ authentication. Keycloak, Okta, Azure AD, etc. The backend is a
relying party — it validates tokens against the IdP's JWKS relying party — it validates tokens against the IdP's JWKS
endpoint and extracts claims (email, groups, roles). endpoint and extracts claims (email, groups, roles).
**Classification Banner** — Thin header/footer bar indicating the **Environment Banner** — Thin header/footer bar indicating the
environment designation. Content area is environment designation. Content area is
`100vh - banner_top - banner_bottom`. When no banners are `100vh - banner_top - banner_bottom`. When no banners are
configured, the full viewport is available. Banner text and color configured, the full viewport is available. Banner text and color
@@ -900,7 +900,7 @@ solo-user value (Jeff is the first user), and complexity.
- Backward-compatible: `/api/v1/chats/*` aliases still work. - Backward-compatible: `/api/v1/chats/*` aliases still work.
- 1:1 behavior unchanged (auto-complete rule). - 1:1 behavior unchanged (auto-complete rule).
- Folders + Projects for organization. - Folders + Projects for organization.
- Classification banners: CSS custom properties, banner settings - Environment banners: CSS custom properties, banner settings
in admin, `/api/v1/settings/banners` endpoint, `initBanners()` in admin, `/api/v1/settings/banners` endpoint, `initBanners()`
in frontend. Small, zero-risk, and required before any in frontend. Small, zero-risk, and required before any
enterprise deployment. enterprise deployment.
@@ -1180,7 +1180,7 @@ gets `user_id` from the auth middleware like every other handler.
--- ---
## 13. Classification Banners & Layout ## 13. Environment Banners & Layout
**What:** Configurable header and footer banners that indicate the **What:** Configurable header and footer banners that indicate the
environment designation. When present, the content environment designation. When present, the content

View File

@@ -5,6 +5,11 @@
# in k8s, the Ingress routes /api/ and /ws to # in k8s, the Ingress routes /api/ and /ws to
# the backend service directly. # the backend service directly.
# #
# Supports path-based deployment via BASE_PATH
# env var (e.g. /dev, /test, or empty for root).
# The entrypoint generates the nginx config and
# injects BASE_PATH into index.html at startup.
#
# Build context: repo root # Build context: repo root
# ========================================== # ==========================================
@@ -18,14 +23,21 @@ RUN npm pack marked@16.3.0 dompurify@3.2.4 2>/dev/null && \
tar xzf dompurify-*.tgz -C /tmp && cp /tmp/package/dist/purify.min.js /vendor/purify.min.js && \ tar xzf dompurify-*.tgz -C /tmp && cp /tmp/package/dist/purify.min.js /vendor/purify.min.js && \
rm -rf /tmp/package /tmp/*.tgz rm -rf /tmp/package /tmp/*.tgz
# Stage 2: nginx # Stage 2: nginx + entrypoint
FROM nginx:1-alpine FROM nginx:1-alpine
COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf # Remove default config — entrypoint generates it
RUN rm -f /etc/nginx/conf.d/default.conf
COPY src/ /usr/share/nginx/html/ COPY src/ /usr/share/nginx/html/
COPY --from=vendor /vendor/ /usr/share/nginx/html/vendor/ COPY --from=vendor /vendor/ /usr/share/nginx/html/vendor/
COPY docker-entrypoint-fe.sh /docker-entrypoint-fe.sh
RUN chmod +x /docker-entrypoint-fe.sh
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost/healthz 2>/dev/null || \
wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
ENTRYPOINT ["/docker-entrypoint-fe.sh"]

View File

@@ -4,177 +4,376 @@
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design (Notes, KBs, Tasks, Channels, Embeddings) - [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design (Notes, KBs, Tasks, Channels, Embeddings)
- [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers, tools, surfaces) - [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers, tools, surfaces)
## Current State: v0.5.4 **Versioning (pre-1.0):** `0.<major>.<minor>` — hotfixes use quad: `0.x.y.z`
No compatibility guarantees before 1.0. Post-1.0: major = long-lived (break nothing),
minor = add features (deprecate, don't remove), patch = fix something.
---
## Current State: v0.6.2
### ✅ Done ### ✅ Done
**Backend (Go)** **Backend (Go)**
- [x] PostgreSQL schema + auto-migrations (go:embed, startup) - [x] PostgreSQL schema + auto-migrations (go:embed, startup)
- [x] JWT auth with refresh token rotation - [x] JWT auth with refresh token rotation
- [x] Chat CRUD + message persistence - [x] **Unified channel model** — chats→channels, "everything is a channel"
- [x] Channel CRUD + message persistence (`/api/v1/channels`)
- [x] Message tree (`parent_id`) with linear backfill
- [x] Participant tracking (`participant_type`/`participant_id` on messages)
- [x] `channel_members` + `channel_models` tables (schema foundation)
- [x] `channel_cursors` for branch tracking (schema foundation)
- [x] Folders + Projects tables for organization
- [x] Environment banner system (global_settings, admin presets, position control)
- [x] Streaming completion proxy (OpenAI + Anthropic + Venice + OpenRouter) - [x] Streaming completion proxy (OpenAI + Anthropic + Venice + OpenRouter)
- [x] Per-user + global API config management - [x] Per-user + global API config management
- [x] Admin endpoints (users, providers, models, settings, stats) - [x] Admin endpoints (users, providers, models, settings, stats)
- [x] Admin bootstrap from env vars (K8s secret → upsert on every restart)
- [x] Registration with pending state (admin approval workflow)
- [x] Registration default state setting (active / pending)
- [x] User providers toggle (admin can restrict to global-only providers)
- [x] Bulk model enable/disable
- [x] Public settings endpoint (non-admin users read safe subset)
- [x] Rate limiting, error middleware, request logging - [x] Rate limiting, error middleware, request logging
- [x] Health check endpoint (includes schema_version) - [x] Health check endpoint (includes schema_version)
- [x] EventBus with WebSocket hub - [x] EventBus with WebSocket hub (rooms, JWT auth, heartbeat, reconnection)
- [x] Provider capability system (known models, heuristic detection, resolution chain) - [x] Provider capability system (known models, heuristic detection, resolution chain)
- [x] Dynamic max_tokens resolution (no more hardcoded 4096) - [x] Dynamic max_tokens resolution
- [x] User + admin provider model listing with capabilities - [x] User + admin provider model listing with capabilities
**Frontend (Vanilla JS)** **Frontend (Vanilla JS)**
- [x] Professional splash page (split-panel hero + tabbed auth)
- [x] Split "New Chat" button with dropdown (Group Chat, Channel — coming soon)
- [x] Environment banner system (CSS custom props + JS init from settings)
- [x] Collapsible sidebar with time-grouped chat history - [x] Collapsible sidebar with time-grouped chat history
- [x] User menu flyout (Settings, Admin, Debug, Sign Out) - [x] User menu flyout (Settings, Admin, Debug, Sign Out)
- [x] Model selector with capability badges (output, context, tools, vision, thinking) - [x] Model selector with capability badges (output, context, tools, vision, thinking)
- [x] Client-side known model table with backend overlay - [x] Settings modal with tabs (General, Providers, Models)
- [x] Admin modal with tabs (Users, Providers, Models, Settings, Stats)
- [x] Admin settings: registration, user providers, banner config with presets
- [x] Pending user badge + approve workflow in admin Users tab
- [x] Streaming SSE display with smart scroll - [x] Streaming SSE display with smart scroll
- [x] Full markdown rendering (marked.js + DOMPurify) - [x] Full markdown rendering (marked.js + DOMPurify, vendor + CDN fallback)
- [x] Thinking block display (`<think>`/`<thinking>` tags) - [x] Thinking block display (`<think>`/`<thinking>` tags)
- [x] Settings modal (profile, chat prefs, provider management)
- [x] Admin modal (users, providers, models with cap badges, settings, stats)
- [x] Debug modal (console intercept, network log, state inspector) - [x] Debug modal (console intercept, network log, state inspector)
- [x] EventBus client with exponential backoff + max retries - [x] EventBus client with exponential backoff + max retries
- [x] Export (Markdown, JSON, Text) - [x] Export (Markdown, JSON, Text)
- [x] Vendor libs with CDN fallback (SCIF-safe)
- [x] 3-stage Dockerfile (Go build → vendor download → nginx)
**CI/CD (Gitea Actions)** **CI/CD (Gitea Actions)**
- [x] Three-env pipeline (dev/test/prod) - [x] Three-env pipeline (dev/test/prod)
- [x] Dev: upgrade test → schema validate → wipe → fresh install test - [x] Dev: upgrade test → schema validate → wipe → fresh install test
- [x] Backend auto-migrates on startup (no CI migration step) - [x] Backend auto-migrates on startup (no CI migration step)
- [x] Admin secret sync (Gitea secrets → K8s secret → env vars)
- [x] Shared PG safety (_dev suffix guard on wipe) - [x] Shared PG safety (_dev suffix guard on wipe)
- [x] Post-deploy schema verification via /api/v1/health - [x] Post-deploy schema verification via /api/v1/health
**Architecture Design (v0.5.4)** **Architecture Design**
- [x] ARCHITECTURE.md: core services spec (13 sections) - [x] ARCHITECTURE.md: core services spec (13 sections)
- [x] EXTENSIONS.md: three-tier extension system spec - [x] EXTENSIONS.md: three-tier extension system spec
- [x] Terminology: Roles, Teams, Channels, Message Tree, etc. - [x] "Everything is a channel" — unified model with type:'direct'
- [x] "Everything is a channel" — chat is a channel with type:'direct' - [x] Message tree (parent_id for conversation forking)
- [x] Conversation forking (message tree via parent_id) - [x] Auth strategy design (builtin/mTLS/OIDC)
- [x] Auth strategy (builtin/mTLS/OIDC)
- [x] Classification banners (CSS custom props)
### Implementation Phasing
**See ARCHITECTURE.md §10 for the authoritative implementation sequence.**
Phases 18, from channel foundation through RBAC.
--- ---
## Phase 1: Real-Time Foundation ## 0.7.0 — Custom Models / Presets
### 1.1 WebSocket Hub Named wrappers around base models with bundled configuration. Admins create
**Priority:** HIGH — blocks Channels, typing indicators, live updates org-wide presets, users create personal ones (if user providers are enabled).
- [ ] WebSocket upgrade endpoint (`/ws`) - [ ] `model_presets` table: name, base_model_id, system_prompt, temperature,
- [ ] Connection manager (register, unregister, broadcast) max_tokens, tools_enabled (jsonb), created_by, scope (global/team/personal),
- [ ] Room-based pub/sub (per-chat, per-channel, global) team_id (nullable, for future team scoping), is_shared
- [ ] JWT auth on WebSocket handshake - [ ] Permission gating: personal presets require user_providers_enabled
- [ ] Heartbeat / ping-pong keepalive - [ ] Admin preset management UI (create, edit, delete org-wide presets)
- [ ] Reconnection handling (frontend) - [ ] User preset management UI in Settings (personal presets)
- [ ] Message types: `chat.message`, `chat.typing`, `user.presence`, `system.notify` - [ ] Presets appear as first-class entries in model selector
("Code Reviewer (GPT-4o)", "Research Assistant (Claude Opus)")
- [ ] Completion handler unwraps preset → base model + config overrides
### 1.2 Live Chat Updates ## 0.7.x — Branding + Polish
- [ ] New messages pushed via WebSocket (not just SSE streaming)
- [ ] Typing indicators White-label support and UX improvements that exploit existing infrastructure.
- [ ] Chat list updates when other sessions create/delete chats
- [ ] Online user presence **Branding**
- [ ] Admin branding settings in global_settings: org name, tagline, accent color
- [ ] `/branding/` volume mount (K8s ConfigMap, optional) for favicon, logo
- [ ] Splash page reads branding config on load, falls back to Switchboard defaults
- [ ] CSS accent color override from branding settings
**Message Editing + Forking**
- [ ] Edit message → creates sibling (uses existing parent_id tree)
- [ ] Regenerate → creates sibling model response
- [ ] Branch indicator at fork points (← 1/2 →)
- [ ] Context assembly follows active path, not full channel history
**UX Polish**
- [ ] Chat search / filter in sidebar
- [ ] UI preferences (theme toggle, font size — stored in user settings)
- [ ] Keyboard shortcuts (Ctrl+K command palette)
- [ ] PWA manifest + offline shell + install prompt
--- ---
## Phase 2: Channels ## 0.8.0 — Teams + Onboarding
### 2.1 Backend The missing middle tier: scoped administration without system-admin access.
- [ ] Channel model (name, description, type: public/private/DM, creator)
- [ ] Membership model (user_id, channel_id, role: owner/member)
- [ ] Channel messages (extends message model with channel_id)
- [ ] CRUD endpoints: `/api/v1/channels`, `/api/v1/channels/:id/messages`
- [ ] @mention parsing — users and AI models
- [ ] AI response triggered by @model-name mentions
- [ ] WebSocket broadcast per channel room
### 2.2 Frontend **Schema**
- [ ] Channels section in sidebar (below chats, collapsible) - [ ] `teams` table: id, name, description, created_by
- [ ] Channel creation modal (name, description, public/private) - [ ] `team_members` table: team_id, user_id, role ('admin' | 'member')
- [ ] Member management (invite, remove, role change) - [ ] Add `team_id` (nullable) to: model_presets, channels, (future: notes, KBs)
- [ ] Message display with user avatars and @mention highlighting - [ ] Team admin role: scoped per-team, not system-wide
- [ ] AI responses inline with user messages (one person can be admin of Team A, member of Team B)
- [ ] Unread count badges
**Onboarding**
- [ ] Registration approval → assign role + team(s) during approval
(upgrade from current binary approve → assign-and-activate)
- [ ] System admin creates teams + assigns first team admin
- [ ] Team admin self-manages: add/remove members, create team presets,
manage team channels, view team usage
**Private Provider Policy**
- [ ] `is_private` flag on provider configs (marks local/self-hosted endpoints)
- [ ] `require_private_providers` policy per team
- [ ] Completion handler enforces policy — team members restricted to private providers
- [ ] Enables HIPAA/compliance posture: provable data boundary per team
## 0.8.x — Audit + Usage Tracking
Required for enterprise and compliance. Cheap to build, expensive to retrofit.
**Audit Log**
- [ ] `audit_log` table: actor_id, action, resource_type, resource_id,
metadata (jsonb), ip_address, timestamp
- [ ] Every mutating handler inserts audit entry
- [ ] Admin audit viewer (filter by actor, action, resource, date range)
- [ ] Team admin sees audit entries scoped to their team
**Usage / Cost Tracking**
- [ ] Capture from provider responses: prompt_tokens, completion_tokens,
cache_creation_tokens, cache_read_tokens
- [ ] `usage_log` table: channel_id, user_id, model_id, provider_id,
token counts, timestamp
- [ ] Model pricing fields on `model_configs`:
cost_input, cost_output, cost_cache_input, cost_cache_output
(per M tokens, nullable, decimal)
- [ ] `cost_source` field: 'provider' | 'admin' | null
— provider APIs populate on model fetch, admin can override,
source tracking prevents silent overwrites on re-fetch
- [ ] Cost calculated at query time (join usage × pricing)
— never store computed cost; pricing changes and historical
token counts should recalculate against current rates
- [ ] Per-user and per-team usage stats in admin panel
- [ ] Team admins see usage for their team scope
--- ---
## Phase 3: Plugin System ## 0.9.0 — Tool Execution + Notes
### 3.1 Architecture (see discussion below) The tool-calling infrastructure that everything downstream depends on.
- [ ] Plugin manifest format (`plugin.json`)
- [ ] Plugin lifecycle (install, enable, disable, uninstall)
- [ ] Plugin storage (per-plugin isolated DB namespace or key-value)
- [ ] Hook system (pre-completion, post-completion, on-message, on-channel-message)
- [ ] Admin UI for plugin management (install, configure, enable/disable)
- [ ] Plugin API (what plugins can access: messages, user context, settings)
### 3.2 Built-in Plugin: Web Search **Tool Framework**
- [ ] Tool-use integration in completion flow - [ ] Tool calling pipeline in completion handler
- [ ] Search provider abstraction (DuckDuckGo, SearXNG, Brave) - [ ] Tool registry (built-in + future plugin tools)
- [ ] Results injected into context - [ ] Tool execution loop: model requests tool → backend executes → result fed back
- [ ] Tool permission model (which tools enabled per preset/channel)
### 3.3 Built-in Plugin: Token Counter **Notes**
- [ ] Per-message token estimation - [ ] `notes` table: title, content, folder_id, tags, team_id (nullable), created_by
- [ ] Per-chat cost tracking - [ ] Notes CRUD endpoints
- [ ] Provider-specific pricing data - [ ] `note_create`, `note_update`, `note_search`, `note_list` tools
---
## Phase 4: Notes & Knowledge Base
### 4.1 Notes
- [ ] Note model (title, content, folder, tags)
- [ ] CRUD endpoints
- [ ] Folder organization
- [ ] Full-text search (PostgreSQL `tsvector`) - [ ] Full-text search (PostgreSQL `tsvector`)
- [ ] Markdown editor in frontend - [ ] Markdown editor in frontend
- [ ] Link notes to chats - [ ] Team-scoped notes (schema ready from 0.8.0)
### 4.2 RAG (Retrieval Augmented Generation) **Conversation Forking UI**
- [ ] Document upload (PDF, DOCX, TXT, MD) - [ ] Edit-and-resubmit creates siblings (tree structure from 0.6.0 schema)
- [ ] Chunking strategies (recursive, semantic) - [ ] Branch indicator ← 1/2 → at fork points
- [ ] Embedding generation (OpenAI, local models) - [ ] Context assembly follows active path
- [ ] pgvector storage and similarity search
## 0.9.x — Context Management
Usability before compaction exists — long conversations shouldn't silently fail.
- [ ] Token counting on outbound requests (estimate before sending)
- [ ] Truncation strategy (sliding window or drop oldest, configurable)
- [ ] "Conversation is getting long" warning in UI
- [ ] Manual "summarize and continue" action (user-triggered pre-compaction)
- [ ] Groundwork for automated compaction in 0.14.0
---
## 0.10.0 — Web Search + URL Fetch
First real external tools, built on 0.9.0 framework.
- [ ] `web_search` tool: search provider abstraction (DuckDuckGo, SearXNG, Brave)
- [ ] `url_fetch` tool: retrieve and extract content from URLs
- [ ] Sidecar or direct HTTP from backend (configurable)
- [ ] Results injected into context
- [ ] Paired with Notes: "research X and save findings to my notes"
---
## 0.11.0 — File Handling + Vision
File input into chat — table stakes for serious use. Blobs live in object
storage, metadata and search indexes live in PostgreSQL.
**Storage Backend Abstraction**
- [ ] S3-compatible API as primary interface
(MinIO, Ceph RGW, AWS S3, GCS — anything with S3 API)
- [ ] Local PVC as zero-config fallback (single-node / dev)
- [ ] Admin config: storage backend selection, endpoint, credentials,
bucket/path, per-file and total size limits
- [ ] Reused by 0.13.0 (KB documents) and 0.14.0 (compaction snapshots)
**Metadata + Search Index (PostgreSQL)**
- [ ] `attachments` table (metadata only, never blobs):
id, message_id, channel_id, team_id, filename, mime_type,
size_bytes, storage_backend ('s3' | 'pvc'), storage_path,
checksum_sha256, uploaded_by, created_at,
search_text (tsvector, nullable)
- [ ] Text extraction on upload (PDF, DOCX, TXT, MD → tsvector)
- [ ] Full-text search across attachment content
- [ ] Access control via JOIN: attachments → channels → team_members
**Chat Integration**
- [ ] Image/file upload in chat messages
- [ ] Multimodal message assembly for vision-capable models
- [ ] Document preview in chat (images inline, files as download links)
- [ ] Paste-to-upload (clipboard image support)
*Note: media generation (image gen, video models) is a separate concern —
those are tool-use actions that depend on the tool framework (0.9.0) and
produce attachments as output. Tracked under Future.*
---
## 0.12.0 — @mention Routing + Multi-model
The channel schema exists from 0.6.0. This phase adds the routing logic.
- [ ] @mention parsing in messages (users and AI models)
- [ ] Resolve mentions against `channel_models`
- [ ] Multi-model channels: fire completions per mentioned model
- [ ] "Add a model" UI per channel
- [ ] Enables: editor mode, second opinions, cross-model conversations
---
## 0.13.0 — Embeddings + Knowledge Bases
- [ ] Embedding pipeline: chunking (recursive, semantic), generation (OpenAI, local)
- [ ] `pgvector` storage and similarity search
- [ ] `knowledge_bases` table: name, description, team_id (nullable), created_by
- [ ] KB document storage via 0.11.0 storage backend (same S3/PVC abstraction)
- [ ] KB CRUD endpoints + admin UI
- [ ] `kb_search` tool (built on 0.9.0 tool framework)
- [ ] Context injection in completion flow - [ ] Context injection in completion flow
- [ ] Per-KB toggle in chat settings - [ ] Notes get embedded too (once pipeline exists)
- [ ] Team admins manage team KBs (permission layer from 0.8.0)
- [ ] Per-channel KB toggle
--- ---
## Phase 5: Polish ## 0.14.0 — Compaction
- [ ] Chat search / filter in sidebar Replaces the manual 0.9.x context management with automated background processing.
- [ ] Message editing
- [ ] Chat folders / pinning - [ ] Auto-compaction service: background job that calls an LLM to summarize
- [ ] Bulk chat operations - [ ] Channel-scoped: triggers when channel exceeds context threshold
- [ ] Keyboard shortcuts (Ctrl+K command palette) - [ ] Compaction summaries stored as system messages in the channel
- [ ] PWA manifest + offline shell - [ ] Configurable: per-channel opt-in/out, summary model selection
- [ ] Performance: lazy load chat history, virtual scroll for long chats - [ ] Admin controls for resource limits
--- ---
## Future ## 0.15.0 — Smart Model Routing
Rules-based routing engine — not ML, just policy.
- [ ] Admin defines routing policies:
"cheapest model with required capabilities",
"prefer private providers, fallback to cloud",
"for this team, use X; for that team, use Y"
- [ ] Model capability system already exists — routing is policy on top
- [ ] Fallback chains: primary provider down → next provider with same model
- [ ] Cost-aware: factor pricing into routing decisions
- [ ] Latency-aware: track response times per provider, prefer faster
---
## 0.16.0 — Tasks / Autonomous Agents
The capstone: everything below it combined into autonomous workflows.
- [ ] Scheduler + task runner
- [ ] `task_create` tool
- [ ] Creates `type: 'service'` channels with no human members
- [ ] Depends on: completion handler, tool execution, notes, web search
- [ ] Admin controls for resource limits, execution budgets
---
## 0.17.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
Enterprise auth modes and fine-grained permissions on top of the teams foundation.
- [ ] `AUTH_MODE` env var: `builtin` | `mtls` | `oidc`
- [ ] All three resolve to the same internal user model
- [ ] `auth_source` + `external_id` columns on users table
- [ ] mTLS: header trust, auto-provision from cert DN
- [ ] OIDC: Keycloak/Okta token validation, claim extraction, role mapping
- [ ] WebSocket auth per mode
- [ ] Per-source auto-activate policy:
auto_activate (bool), default_team, default_role
- [ ] Fine-grained permissions: model access, KB write, task create,
admin delegation, token budgets per user/team
- [ ] SSO/SAML (may fold in or follow as 0.17.x)
---
## Future (post-1.0 candidates)
Items that are real but don't yet have a version assignment. Any of these
could pull left based on need.
**Desktop + Mobile**
- Desktop app (Tauri) - Desktop app (Tauri)
- Smart model routing (cost/quality/latency auto-selection) - Full PWA with offline capability
- Workflow builder (visual DAG for chaining models + tools) - Mobile-optimized layouts
- Plugin marketplace
- SSO/SAML **Generation + Media**
- Audit logging - Image generation tools (DALL-E, Stable Diffusion endpoints)
- Video model integration
- Audio/TTS tools
**Data + Portability**
- Bulk export/import (account data, conversations, settings)
- ChatGPT/other tool import
- GDPR-style "download my data"
- Backup/restore CronJob manifests + operational docs
**Platform**
- Rate limiting per user/team/tier (token budgets)
- Provider health monitoring + key rotation
- Multi-tenant SaaS mode - Multi-tenant SaaS mode
- Workflow builder (visual DAG for chaining models + tools)
- Plugin/extension marketplace
- Live collaboration (typing indicators, presence, co-editing)
- Virtual scroll for long conversations
--- ---
## Plugin / Extension Architecture ## Extension / Plugin Architecture
**Moved to dedicated documents:** **Deferred to post-1.0.** The tool execution framework (0.9.0) provides the
- [EXTENSIONS.md](EXTENSIONS.md) — Full extension system spec with three tiers internal hook points. A formal plugin API, manifest format, and marketplace
(Browser JS, Starlark sandbox, Sidecar containers), manifest format, are tracked in the dedicated design documents:
browser tool bridge, surface/mode system, and implementation roadmap.
- [EXTENSIONS.md](EXTENSIONS.md) — Three-tier extension system spec
(Browser JS, Starlark sandbox, Sidecar containers)
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core backend services that extensions - [ARCHITECTURE.md](ARCHITECTURE.md) — Core backend services that extensions
build on: Notes, Knowledge Bases, Tasks, Channels, Embeddings, and build on
Folders/Projects. Includes data models, sequencing, and admin controls.

View File

@@ -1 +1 @@
0.5.4 0.6.0

109
docker-entrypoint-fe.sh Normal file
View File

@@ -0,0 +1,109 @@
#!/bin/sh
# ============================================
# Chat Switchboard - Frontend Entrypoint
# ============================================
# Injects BASE_PATH into index.html and nginx
# config at container startup. Supports path-
# based multi-env deployment on a single domain.
#
# Environment:
# BASE_PATH - URL prefix (e.g. "/dev", "/test", or "")
# ============================================
set -e
BASE_PATH="${BASE_PATH:-}"
# Compute base href (needs trailing slash for <base> tag)
if [ -z "${BASE_PATH}" ]; then
BASE_HREF="/"
else
BASE_HREF="${BASE_PATH}/"
fi
# ── Inject into index.html ──────────────────
sed -i \
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
-e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
/usr/share/nginx/html/index.html
echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/}"
# ── Generate nginx config ───────────────────
# If BASE_PATH is set, serve under that prefix with alias.
# If empty, serve from root (default behavior).
if [ -z "${BASE_PATH}" ]; then
cat > /etc/nginx/conf.d/default.conf <<'NGINX'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
# Health check for k8s probes
location = /healthz {
return 200 'ok';
add_header Content-Type text/plain;
}
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
NGINX
else
cat > /etc/nginx/conf.d/default.conf <<NGINX
server {
listen 80;
server_name _;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
location ${BASE_PATH}/ {
alias /usr/share/nginx/html/;
try_files \$uri \$uri/ ${BASE_PATH}/index.html;
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)\$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Redirect bare path to trailing slash
location = ${BASE_PATH} {
return 301 ${BASE_PATH}/;
}
# Health check for k8s probes (always at root)
location = /healthz {
return 200 'ok';
add_header Content-Type text/plain;
}
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
NGINX
fi
# ── Start nginx ─────────────────────────────
exec nginx -g 'daemon off;'

View File

@@ -2,6 +2,14 @@
# ============================================ # ============================================
# Chat Switchboard - Backend Deployment # Chat Switchboard - Backend Deployment
# ============================================ # ============================================
# Secrets:
# switchboard-db-credentials - POSTGRES_USER, POSTGRES_PASSWORD
# switchboard-admin - username, password, email (optional)
#
# 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.
# ============================================
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -37,6 +45,8 @@ spec:
value: "${ENVIRONMENT}" value: "${ENVIRONMENT}"
- name: PORT - name: PORT
value: "8080" value: "8080"
- name: BASE_PATH
value: "${BASE_PATH}"
- name: POSTGRES_HOST - name: POSTGRES_HOST
value: "${POSTGRES_HOST}" value: "${POSTGRES_HOST}"
- name: POSTGRES_PORT - name: POSTGRES_PORT
@@ -55,6 +65,24 @@ spec:
key: POSTGRES_PASSWORD key: POSTGRES_PASSWORD
- name: DATABASE_URL - name: DATABASE_URL
value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable" value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable"
- name: SWITCHBOARD_ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: switchboard-admin
key: username
optional: true
- name: SWITCHBOARD_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: switchboard-admin
key: password
optional: true
- name: SWITCHBOARD_ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: switchboard-admin
key: email
optional: true
resources: resources:
requests: requests:
memory: "${BE_MEMORY_REQUEST}" memory: "${BE_MEMORY_REQUEST}"
@@ -64,7 +92,7 @@ spec:
cpu: "${BE_CPU_LIMIT}" cpu: "${BE_CPU_LIMIT}"
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /health path: ${BASE_PATH}/health
port: 8080 port: 8080
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
@@ -72,7 +100,7 @@ spec:
failureThreshold: 3 failureThreshold: 3
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /health path: ${BASE_PATH}/health
port: 8080 port: 8080
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 30 periodSeconds: 30

View File

@@ -35,6 +35,9 @@ spec:
ports: ports:
- containerPort: 80 - containerPort: 80
name: http name: http
env:
- name: BASE_PATH
value: "${BASE_PATH}"
resources: resources:
requests: requests:
memory: "${FE_MEMORY_REQUEST}" memory: "${FE_MEMORY_REQUEST}"
@@ -44,7 +47,7 @@ spec:
cpu: "${FE_CPU_LIMIT}" cpu: "${FE_CPU_LIMIT}"
readinessProbe: readinessProbe:
httpGet: httpGet:
path: / path: /healthz
port: 80 port: 80
initialDelaySeconds: 3 initialDelaySeconds: 3
periodSeconds: 10 periodSeconds: 10
@@ -52,7 +55,7 @@ spec:
failureThreshold: 3 failureThreshold: 3
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /healthz
port: 80 port: 80
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 30 periodSeconds: 30

View File

@@ -2,11 +2,18 @@
# ============================================ # ============================================
# Chat Switchboard - Ingress # Chat Switchboard - Ingress
# ============================================ # ============================================
# Routes for a single subdomain: # Path-based routing on a single domain:
# /api/* → backend service (Go API on :8080) # switchboard.DOMAIN/ → production
# /health → backend service (health check) # switchboard.DOMAIN/test/ → test (main branch)
# /ws → backend service (WebSocket, upgraded) # switchboard.DOMAIN/dev/ → dev (PR branches)
# /* → frontend service (nginx SPA on :80) #
# Each environment deploys its own Ingress resource.
# Traefik merges rules for the same host automatically.
# Longer path prefixes take priority (Traefik default).
#
# BASE_PATH is empty for prod, "/test" or "/dev" for others.
# Backend handles BASE_PATH via r.Group(cfg.BasePath).
# Frontend entrypoint injects BASE_PATH into nginx + HTML.
# ============================================ # ============================================
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
@@ -24,13 +31,13 @@ spec:
tls: tls:
- hosts: - hosts:
- ${DEPLOY_HOST} - ${DEPLOY_HOST}
secretName: switchboard${DEPLOY_SUFFIX}-tls secretName: switchboard-tls
rules: rules:
- host: "${DEPLOY_HOST}" - host: "${DEPLOY_HOST}"
http: http:
paths: paths:
# API routes → backend # API routes → backend
- path: /api - path: ${BASE_PATH}/api
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
@@ -38,7 +45,7 @@ spec:
port: port:
number: 8080 number: 8080
# Health check → backend # Health check → backend
- path: /health - path: ${BASE_PATH}/health
pathType: Exact pathType: Exact
backend: backend:
service: service:
@@ -46,15 +53,15 @@ spec:
port: port:
number: 8080 number: 8080
# WebSocket → backend (traefik handles upgrade automatically) # WebSocket → backend (traefik handles upgrade automatically)
- path: /ws - path: ${BASE_PATH}/ws
pathType: Exact pathType: Exact
backend: backend:
service: service:
name: switchboard-be${DEPLOY_SUFFIX} name: switchboard-be${DEPLOY_SUFFIX}
port: port:
number: 8080 number: 8080
# Everything else → frontend # Everything else under this prefix → frontend
- path: / - path: ${BASE_PATH}/
pathType: Prefix pathType: Prefix
backend: backend:
service: service:

View File

@@ -90,13 +90,13 @@ echo " ✓ ${MIGRATION_COUNT} migrations applied"
LATEST=$(psql -tAc "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" 2>/dev/null || echo "none") LATEST=$(psql -tAc "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" 2>/dev/null || echo "none")
echo " ✓ latest: ${LATEST}" echo " ✓ latest: ${LATEST}"
# ── 3. Core tables (001_full_schema) ──────── # ── 3. Core tables (001 schema, renamed in 006)
echo "" echo ""
echo "Core tables:" echo "Core tables:"
check_table "users" check_table "users"
check_table "api_configs" check_table "api_configs"
check_table "chats" check_table "channels"
check_table "chat_messages" check_table "messages"
# Key columns # Key columns
check_column "users" "id" check_column "users" "id"
@@ -105,9 +105,11 @@ check_column "users" "role"
check_column "api_configs" "user_id" check_column "api_configs" "user_id"
check_column "api_configs" "provider" check_column "api_configs" "provider"
check_column "api_configs" "api_key_encrypted" check_column "api_configs" "api_key_encrypted"
check_column "chats" "user_id" check_column "channels" "user_id"
check_column "chat_messages" "chat_id" check_column "channels" "type"
check_column "chat_messages" "role" check_column "messages" "channel_id"
check_column "messages" "role"
check_column "messages" "parent_id"
# ── 4. Refresh tokens (002) ───────────────── # ── 4. Refresh tokens (002) ─────────────────
echo "" echo ""
@@ -140,9 +142,44 @@ check_table "user_model_preferences"
check_column "user_model_preferences" "user_id" check_column "user_model_preferences" "user_id"
check_column "user_model_preferences" "model_config_id" check_column "user_model_preferences" "model_config_id"
# ── 8. Channel unification (006-008) ────────
echo ""
echo "Channel model (006-008):"
check_column "channels" "description"
check_column "messages" "participant_type"
check_column "messages" "participant_id"
check_table "channel_members"
check_column "channel_members" "channel_id"
check_column "channel_members" "user_id"
check_table "channel_models"
check_column "channel_models" "channel_id"
check_column "channel_models" "model_id"
check_table "channel_cursors"
check_column "channel_cursors" "active_leaf_id"
# ── 9. Folders & Projects (009) ─────────────
echo ""
echo "Organization (009):"
check_table "folders"
check_column "folders" "user_id"
check_table "projects"
check_column "projects" "user_id"
check_table "project_channels"
# ── 10. Banners (010) ───────────────────────
echo ""
echo "Banners (010):"
# Banner config is seeded into global_settings; just verify the key exists
BANNER_KEY=$(psql -tAc "SELECT 1 FROM global_settings WHERE key = 'banner';" 2>/dev/null || echo "0")
if [[ "${BANNER_KEY}" == "1" ]]; then
echo " ✓ global_settings: banner"
else
echo " ✗ MISSING global_settings key: banner"
ERRORS=$((ERRORS + 1))
fi
# ═══════════════════════════════════════════ # ═══════════════════════════════════════════
# ADD NEW MIGRATION CHECKS ABOVE THIS LINE # ADD NEW MIGRATION CHECKS ABOVE THIS LINE
# When you add migration 006, add checks here.
# ═══════════════════════════════════════════ # ═══════════════════════════════════════════
# ── Summary ────────────────────────────────── # ── Summary ──────────────────────────────────

View File

@@ -12,6 +12,12 @@ type Config struct {
DatabaseURL string DatabaseURL string
JWTSecret string JWTSecret string
Environment string Environment string
BasePath string // URL path prefix (e.g. "/dev", "/test", or "" for root)
// Admin bootstrap (optional — set via K8s secret)
AdminUsername string
AdminPassword string
AdminEmail string
} }
// Load reads configuration from environment variables. // Load reads configuration from environment variables.
@@ -21,13 +27,34 @@ func Load() *Config {
_ = godotenv.Load() _ = godotenv.Load()
return &Config{ return &Config{
Port: getEnv("PORT", "8080"), Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", ""), DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"), JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
Environment: getEnv("ENVIRONMENT", "development"), Environment: getEnv("ENVIRONMENT", "development"),
BasePath: sanitizeBasePath(getEnv("BASE_PATH", "")),
AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""),
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
} }
} }
// sanitizeBasePath ensures the path starts with / and doesn't end with /.
// Empty string means root (no prefix).
func sanitizeBasePath(p string) string {
if p == "" || p == "/" {
return ""
}
// Ensure leading slash
if p[0] != '/' {
p = "/" + p
}
// Strip trailing slash
for len(p) > 1 && p[len(p)-1] == '/' {
p = p[:len(p)-1]
}
return p
}
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v

View File

@@ -0,0 +1,114 @@
-- ==========================================
-- Migration 006: Unify Chats → Channels
-- ==========================================
-- "Everything is a channel." Merges the separate chats/channels
-- schema into a single unified channel model.
--
-- The old channels/channel_members/channel_messages tables (from 001)
-- were never populated — safe to drop and rebuild on top of chats.
-- ==========================================
-- ── 1. Drop unused legacy channel tables ────
-- These were placeholders from 001; no data, no handlers.
-- Order matters: drop dependents first.
-- Remove FK references in tool_usage_log before dropping
ALTER TABLE tool_usage_log DROP CONSTRAINT IF EXISTS tool_usage_log_channel_id_fkey;
ALTER TABLE tool_usage_log DROP COLUMN IF EXISTS channel_id;
DROP TABLE IF EXISTS channel_messages CASCADE;
DROP TABLE IF EXISTS channel_members CASCADE;
DROP TABLE IF EXISTS channels CASCADE;
-- Drop the old trigger (will re-create after rename)
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
-- ── 2. Rename chats → channels ──────────────
ALTER TABLE chats RENAME TO channels;
ALTER TABLE chat_messages RENAME TO messages;
-- Rename columns to match new schema
ALTER TABLE messages RENAME COLUMN chat_id TO channel_id;
-- Rename indexes
ALTER INDEX IF EXISTS idx_chats_user RENAME TO idx_channels_user;
ALTER INDEX IF EXISTS idx_chats_updated RENAME TO idx_channels_updated;
ALTER INDEX IF EXISTS idx_chats_tags RENAME TO idx_channels_tags;
ALTER INDEX IF EXISTS idx_chat_messages_chat RENAME TO idx_messages_channel;
-- Rename constraints (PK and FK auto-renamed with table on some PG versions,
-- but let's be explicit for the FK)
-- Note: PG auto-renames PKs but not FKs or check constraints
-- Rename the updated_at trigger
DROP TRIGGER IF EXISTS chats_updated_at ON channels;
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- ── 3. Add channel type + description ───────
ALTER TABLE channels ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'direct';
ALTER TABLE channels ADD COLUMN IF NOT EXISTS description TEXT;
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
-- Backfill: all existing rows are 1:1 AI chats
UPDATE channels SET type = 'direct' WHERE type IS NULL;
-- Index on type for filtered queries
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
-- ── 4. Add message tree (parent_id) ─────────
ALTER TABLE messages ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES messages(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
-- Backfill linear parent chains on existing messages.
-- Each message's parent is the previous message in the same channel (by created_at).
WITH ordered AS (
SELECT id, channel_id, created_at,
LAG(id) OVER (PARTITION BY channel_id ORDER BY created_at) AS prev_id
FROM messages
)
UPDATE messages m
SET parent_id = o.prev_id
FROM ordered o
WHERE m.id = o.id AND o.prev_id IS NOT NULL AND m.parent_id IS NULL;
-- ── 5. Add participant columns on messages ──
-- Decouples messages from user-only: AI models are participants too.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_type VARCHAR(10) DEFAULT 'user';
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_id VARCHAR(255);
COMMENT ON COLUMN messages.participant_type IS 'user or model';
COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string';
-- Backfill: user messages get the channel owner's user_id;
-- assistant messages get the model name as participant_id.
UPDATE messages m
SET participant_type = CASE WHEN m.role = 'assistant' THEN 'model' ELSE 'user' END,
participant_id = CASE
WHEN m.role = 'assistant' THEN COALESCE(m.model, 'unknown')
ELSE (SELECT c.user_id::text FROM channels c WHERE c.id = m.channel_id)
END
WHERE m.participant_id IS NULL;
-- ── 6. Update model_routing_log FK ──────────
-- The FK column was named chat_id — rename it
ALTER TABLE model_routing_log RENAME COLUMN chat_id TO channel_id;
ALTER INDEX IF EXISTS idx_routing_log_chat RENAME TO idx_routing_log_channel;
-- message_id FK still valid (messages table was renamed, FK follows)
-- ── 7. Update tool_usage_log ────────────────
-- We already dropped the old channel_id column above.
-- Re-add it pointing to the unified channels table.
-- Also rename the old chat_id column.
ALTER TABLE tool_usage_log RENAME COLUMN chat_id TO channel_id;
-- The FK auto-follows the table rename, but let's be safe:
-- (chat_id FK pointed to chats(id), which is now channels(id) — PG handles this)

View File

@@ -0,0 +1,56 @@
-- ==========================================
-- Migration 007: Channel Members & Models
-- ==========================================
-- Membership and model assignment tables for
-- multi-user and multi-model channels.
-- ==========================================
-- ── Channel Members ─────────────────────────
-- Who is in this channel (human participants).
CREATE TABLE IF NOT EXISTS channel_members (
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) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member', -- owner, admin, member
joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
-- Backfill: existing channels are 1:1, so the channel owner is the sole member.
INSERT INTO channel_members (channel_id, user_id, role)
SELECT id, user_id, 'owner'
FROM channels
WHERE user_id IS NOT NULL
ON CONFLICT (channel_id, user_id) DO NOTHING;
-- ── Channel Models ──────────────────────────
-- Which AI models are assigned to this channel.
-- For direct chats this is one model; for group/channel
-- there can be multiple with @mention routing.
CREATE TABLE IF NOT EXISTS channel_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id VARCHAR(255) NOT NULL, -- e.g. 'claude-sonnet-4'
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
display_name VARCHAR(100), -- optional alias in this channel
system_prompt TEXT, -- per-model system prompt override
settings JSONB DEFAULT '{}'::jsonb, -- temperature, max_tokens overrides
is_default BOOLEAN DEFAULT false, -- auto-complete (no @mention needed)
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, model_id)
);
CREATE INDEX idx_channel_models_channel ON channel_models(channel_id);
-- Backfill: existing channels have a model in the channels.model column.
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
SELECT id, model, api_config_id, true
FROM channels
WHERE model IS NOT NULL AND model != ''
ON CONFLICT (channel_id, model_id) DO NOTHING;

View File

@@ -0,0 +1,29 @@
-- ==========================================
-- Migration 008: Channel Cursors
-- ==========================================
-- Tracks each user's active branch position
-- per channel. Essential for conversation forking.
-- ==========================================
CREATE TABLE IF NOT EXISTS channel_cursors (
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) ON DELETE CASCADE,
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_cursors_channel ON channel_cursors(channel_id);
CREATE INDEX idx_channel_cursors_user ON channel_cursors(user_id);
-- Backfill: set cursor to the last message in each channel for the owner.
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
SELECT c.id, c.user_id, (
SELECT m.id FROM messages m
WHERE m.channel_id = c.id
ORDER BY m.created_at DESC LIMIT 1
)
FROM channels c
WHERE c.user_id IS NOT NULL
ON CONFLICT (channel_id, user_id) DO NOTHING;

View File

@@ -0,0 +1,57 @@
-- ==========================================
-- Migration 009: Folders & Projects
-- ==========================================
-- Organizational structures for channels.
-- Folders are simple containers; projects are
-- tagged collections that can span folders.
-- ==========================================
-- ── Folders ─────────────────────────────────
CREATE TABLE IF NOT EXISTS folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, name, parent_id)
);
CREATE INDEX idx_folders_user ON folders(user_id);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Add folder_id to channels (replaces the old text folder column)
ALTER TABLE channels ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES folders(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id);
-- ── Projects ────────────────────────────────
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
color VARCHAR(7), -- hex color for UI badge
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_projects_user ON projects(user_id);
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Junction: channels can belong to multiple projects
CREATE TABLE IF NOT EXISTS project_channels (
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, channel_id)
);
CREATE INDEX idx_project_channels_channel ON project_channels(channel_id);

View File

@@ -0,0 +1,32 @@
-- ==========================================
-- Migration 010: Environment Banners
-- ==========================================
-- Environment banners for deployment context
-- (dev, staging, production, etc). Stored in
-- global_settings with a dedicated key.
-- ==========================================
-- Seed default banner config (disabled).
-- Schema: { enabled, text, position, bg, fg }
INSERT INTO global_settings (key, value) VALUES
('banner', '{
"enabled": false,
"text": "",
"position": "both",
"bg": "#007a33",
"fg": "#ffffff"
}'::jsonb)
ON CONFLICT (key) DO NOTHING;
-- Banner presets for quick selection in admin UI.
-- Generic environment labels — admins can set custom text.
INSERT INTO global_settings (key, value) VALUES
('banner_presets', '{
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -0,0 +1,18 @@
-- ==========================================
-- Migration 011: Replace banner presets
-- ==========================================
-- Replaces legacy presets with
-- generic environment labels. Existing databases
-- that ran 010 have the old presets; this overwrites.
-- ==========================================
UPDATE global_settings
SET value = '{
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
}'::jsonb
WHERE key = 'banner_presets';

40
server/go.sum Normal file
View File

@@ -0,0 +1,40 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=

View File

@@ -278,6 +278,44 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
} }
// ── Public Settings (for any authenticated user) ──
var publicSettingKeys = map[string]bool{
"banner": true,
"user_providers_enabled": true,
"registration_enabled": true,
"registration_default_state": true,
"banner_presets": true,
}
func (h *AdminHandler) PublicSettings(c *gin.Context) {
rows, err := database.DB.Query(`
SELECT key, value::text FROM global_settings ORDER BY key
`)
if err != nil {
c.JSON(http.StatusOK, gin.H{"settings": []interface{}{}})
return
}
defer rows.Close()
settings := make([]globalSettingResponse, 0)
for rows.Next() {
var key, valueRaw string
if err := rows.Scan(&key, &valueRaw); err != nil {
continue
}
if !publicSettingKeys[key] {
continue
}
s := globalSettingResponse{Key: key}
s.Value = make(map[string]interface{})
_ = json.Unmarshal([]byte(valueRaw), &s.Value)
settings = append(settings, s)
}
c.JSON(http.StatusOK, gin.H{"settings": settings})
}
// ── List Global Settings ──────────────────── // ── List Global Settings ────────────────────
func (h *AdminHandler) ListGlobalSettings(c *gin.Context) { func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
@@ -369,8 +407,8 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
queries := map[string]string{ queries := map[string]string{
"total_users": "SELECT COUNT(*) FROM users", "total_users": "SELECT COUNT(*) FROM users",
"active_users": "SELECT COUNT(*) FROM users WHERE is_active = true", "active_users": "SELECT COUNT(*) FROM users WHERE is_active = true",
"total_chats": "SELECT COUNT(*) FROM chats", "total_channels": "SELECT COUNT(*) FROM channels",
"total_messages": "SELECT COUNT(*) FROM chat_messages", "total_messages": "SELECT COUNT(*) FROM messages",
"api_configs": "SELECT COUNT(*) FROM api_configs", "api_configs": "SELECT COUNT(*) FROM api_configs",
} }
@@ -438,8 +476,8 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
var id string var id string
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id) INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id, is_global)
VALUES ($1, $2, $3, $4, $5, NULL) VALUES ($1, $2, $3, $4, $5, NULL, true)
RETURNING id RETURNING id
`, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id) `, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id)
if err != nil { if err != nil {
@@ -666,6 +704,28 @@ func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "model updated"}) c.JSON(http.StatusOK, gin.H{"message": "model updated"})
} }
// BulkUpdateModels enables or disables all models at once
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
var req struct {
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := database.DB.Exec(
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW()`,
req.IsEnabled,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update models"})
return
}
rows, _ := result.RowsAffected()
c.JSON(http.StatusOK, gin.H{"message": "models updated", "count": rows})
}
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) { func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
modelID := c.Param("id") modelID := c.Param("id")
result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID) result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID)

View File

@@ -89,8 +89,8 @@ func TestCompletionHandlerMissingFields(t *testing.T) {
name string name string
body string body string
}{ }{
{"missing chat_id", `{"content":"hello"}`}, {"missing channel_id", `{"content":"hello"}`},
{"missing content", `{"chat_id":"abc"}`}, {"missing content", `{"channel_id":"abc"}`},
{"empty body", `{}`}, {"empty body", `{}`},
} }

View File

@@ -6,7 +6,9 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@@ -91,8 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
_ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount) _ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
isFirstUser := userCount == 0 isFirstUser := userCount == 0
// If not first user, check if registration is enabled // First-user-becomes-admin only when no env admin is configured
if !isFirstUser { envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != ""
promoteFirst := isFirstUser && !envAdminSet
// If not first user (or env admin handles bootstrap), check registration
if !promoteFirst {
if !IsRegistrationEnabled() { if !IsRegistrationEnabled() {
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"}) c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
return return
@@ -106,19 +112,25 @@ func (h *AuthHandler) Register(c *gin.Context) {
return return
} }
// First user gets admin role // Determine role and active state
role := "user" role := "user"
if isFirstUser { isActive := true
if promoteFirst {
role = "admin" role = "admin"
} else {
// Apply registration default state
if GetRegistrationDefaultState() == "pending" {
isActive = false
}
} }
// Insert user // Insert user
var user userResponse var user userResponse
err = database.DB.QueryRow(` err = database.DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role) INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, display_name, role RETURNING id, username, email, display_name, role
`, req.Username, req.Email, string(hash), role).Scan( `, req.Username, req.Email, string(hash), role, isActive).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role,
) )
if err != nil { if err != nil {
@@ -134,6 +146,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
return return
} }
// If account is pending, don't generate tokens
if !isActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created and pending admin approval",
"pending": true,
})
return
}
// Generate tokens // Generate tokens
resp, err := h.generateTokenPair(user) resp, err := h.generateTokenPair(user)
if err != nil { if err != nil {
@@ -152,8 +173,8 @@ func IsRegistrationEnabled() bool {
} }
var enabled bool var enabled bool
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
SELECT COALESCE((value->>'enabled')::boolean, true) SELECT COALESCE((value->>'value')::boolean, true)
FROM global_settings WHERE key = 'registration' FROM global_settings WHERE key = 'registration_enabled'
`).Scan(&enabled) `).Scan(&enabled)
if err != nil { if err != nil {
return true // Default to open if setting missing return true // Default to open if setting missing
@@ -161,6 +182,75 @@ func IsRegistrationEnabled() bool {
return enabled return enabled
} }
// GetRegistrationDefaultState returns "active" or "pending".
func GetRegistrationDefaultState() string {
if database.DB == nil {
return "active"
}
var state string
err := database.DB.QueryRow(`
SELECT COALESCE(value->>'value', 'active')
FROM global_settings WHERE key = 'registration_default_state'
`).Scan(&state)
if err != nil || state == "" {
return "active"
}
return state
}
// BootstrapAdmin creates or updates the admin user from environment variables.
// This runs on every startup, so changing the K8s secret + restarting resets the password.
// Handles both username and email conflicts (e.g. admin username changed between deploys).
func BootstrapAdmin(cfg *config.Config) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return
}
if database.DB == nil {
return
}
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@localhost"
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
if err != nil {
log.Printf("⚠ Failed to hash admin password: %v", err)
return
}
// Try upsert by username (common case: same username, new password)
_, err = database.DB.Exec(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, 'admin', true)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
email = EXCLUDED.email,
role = 'admin',
is_active = true
`, cfg.AdminUsername, email, string(hash))
if err != nil && strings.Contains(err.Error(), "duplicate key") {
// Email conflict — admin username was changed in config but email
// already belongs to old admin row. Update that row instead.
_, err = database.DB.Exec(`
UPDATE users SET
username = $1,
password_hash = $3,
role = 'admin',
is_active = true
WHERE email = $2
`, cfg.AdminUsername, email, string(hash))
}
if err != nil {
log.Printf("⚠ Admin bootstrap failed: %v", err)
} else {
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
}
}
// ── Login ─────────────────────────────────── // ── Login ───────────────────────────────────
func (h *AuthHandler) Login(c *gin.Context) { func (h *AuthHandler) Login(c *gin.Context) {
@@ -195,7 +285,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
} }
if !isActive { if !isActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"}) c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"})
return return
} }

View File

@@ -14,8 +14,10 @@ import (
// ── Request / Response types ──────────────── // ── Request / Response types ────────────────
type createChatRequest struct { type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"` 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"` Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"` APIConfigID *string `json:"api_config_id,omitempty"`
@@ -23,8 +25,9 @@ type createChatRequest struct {
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
} }
type updateChatRequest struct { type updateChannelRequest struct {
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"` Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"` SystemPrompt *string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"` APIConfigID *string `json:"api_config_id,omitempty"`
@@ -34,10 +37,12 @@ type updateChatRequest struct {
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
} }
type chatResponse struct { type channelResponse struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Title string `json:"title"` Title string `json:"title"`
Type string `json:"type"`
Description *string `json:"description"`
Model *string `json:"model"` Model *string `json:"model"`
APIConfigID *string `json:"api_config_id"` APIConfigID *string `json:"api_config_id"`
SystemPrompt *string `json:"system_prompt"` SystemPrompt *string `json:"system_prompt"`
@@ -58,12 +63,12 @@ type paginatedResponse struct {
TotalPages int `json:"total_pages"` TotalPages int `json:"total_pages"`
} }
// ChatHandler holds dependencies for chat endpoints. // ChannelHandler holds dependencies for channel endpoints.
type ChatHandler struct{} type ChannelHandler struct{}
// NewChatHandler creates a new chat handler. // NewChannelHandler creates a new channel handler.
func NewChatHandler() *ChatHandler { func NewChannelHandler() *ChannelHandler {
return &ChatHandler{} return &ChannelHandler{}
} }
// ── Helpers ───────────────────────────────── // ── Helpers ─────────────────────────────────
@@ -93,46 +98,59 @@ func parsePagination(c *gin.Context) (page, perPage, offset int) {
return return
} }
// ── List Chats ────────────────────────────── // ── List Channels ───────────────────────────
func (h *ChatHandler) ListChats(c *gin.Context) { func (h *ChannelHandler) ListChannels(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
page, perPage, offset := parsePagination(c) page, perPage, offset := parsePagination(c)
// Optional filters // Optional filters
archived := c.DefaultQuery("archived", "false") archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder") folder := c.Query("folder")
channelType := c.DefaultQuery("type", "") // empty = all types
// Count total // Count total
var total int countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
countQuery := `SELECT COUNT(*) FROM chats WHERE user_id = $1 AND is_archived = $2`
countArgs := []interface{}{userID, archived == "true"} countArgs := []interface{}{userID, archived == "true"}
argN := 3
if channelType != "" {
countQuery += ` AND type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType)
argN++
}
if folder != "" { if folder != "" {
countQuery += ` AND folder = $3` countQuery += ` AND folder = $` + strconv.Itoa(argN)
countArgs = append(countArgs, folder) countArgs = append(countArgs, folder)
argN++
} }
var total int
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil { if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count chats"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
return return
} }
// Fetch chats with message count // Fetch channels with message count
query := ` query := `
SELECT c.id, c.user_id, c.title, c.model, c.api_config_id, SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_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,
COALESCE(mc.cnt, 0) AS message_count, COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at c.created_at, c.updated_at
FROM chats c FROM channels c
LEFT JOIN ( LEFT JOIN (
SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.chat_id = c.id ) mc ON mc.channel_id = c.id
WHERE c.user_id = $1 AND c.is_archived = $2` WHERE c.user_id = $1 AND c.is_archived = $2`
args := []interface{}{userID, archived == "true"} args := []interface{}{userID, archived == "true"}
argN := 3 argN = 3
if channelType != "" {
query += ` AND c.type = $` + strconv.Itoa(argN)
args = append(args, channelType)
argN++
}
if folder != "" { if folder != "" {
query += ` AND c.folder = $` + strconv.Itoa(argN) query += ` AND c.folder = $` + strconv.Itoa(argN)
args = append(args, folder) args = append(args, folder)
@@ -145,34 +163,34 @@ func (h *ChatHandler) ListChats(c *gin.Context) {
rows, err := database.DB.Query(query, args...) rows, err := database.DB.Query(query, args...)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list chats"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return return
} }
defer rows.Close() defer rows.Close()
chats := make([]chatResponse, 0) channels := make([]channelResponse, 0)
for rows.Next() { for rows.Next() {
var chat chatResponse var ch channelResponse
var tags []string var tags []string
err := rows.Scan( err := rows.Scan(
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), pq.Array(&tags),
&chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt, &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan chat"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"})
return return
} }
if tags == nil { if tags == nil {
tags = []string{} tags = []string{}
} }
chat.Tags = tags ch.Tags = tags
chats = append(chats, chat) channels = append(channels, ch)
} }
c.JSON(http.StatusOK, paginatedResponse{ c.JSON(http.StatusOK, paginatedResponse{
Data: chats, Data: channels,
Page: page, Page: page,
PerPage: perPage, PerPage: perPage,
Total: total, Total: total,
@@ -180,12 +198,12 @@ func (h *ChatHandler) ListChats(c *gin.Context) {
}) })
} }
// ── Create Chat ───────────────────────────── // ── Create Channel ──────────────────────────
func (h *ChatHandler) CreateChat(c *gin.Context) { func (h *ChannelHandler) CreateChannel(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
var req createChatRequest var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -195,88 +213,110 @@ func (h *ChatHandler) CreateChat(c *gin.Context) {
req.Tags = []string{} req.Tags = []string{}
} }
var chat chatResponse // Default type is "direct" (1:1 AI chat)
channelType := req.Type
if channelType == "" {
channelType = "direct"
}
var ch channelResponse
var tags []string var tags []string
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
INSERT INTO chats (user_id, title, model, system_prompt, api_config_id, folder, tags) INSERT INTO channels (user_id, title, type, description, model, system_prompt, api_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, model, api_config_id, system_prompt, RETURNING id, user_id, title, type, description, model, api_config_id, system_prompt,
is_archived, is_pinned, folder, tags, created_at, updated_at is_archived, is_pinned, folder, tags, created_at, updated_at
`, userID, req.Title, req.Model, req.SystemPrompt, req.APIConfigID, `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags), req.Folder, pq.Array(req.Tags),
).Scan( ).Scan(
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &chat.CreatedAt, &chat.UpdatedAt, pq.Array(&tags), &ch.CreatedAt, &ch.UpdatedAt,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create chat"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return return
} }
if tags == nil { if tags == nil {
tags = []string{} tags = []string{}
} }
chat.Tags = tags ch.Tags = tags
chat.MessageCount = 0 ch.MessageCount = 0
c.JSON(http.StatusCreated, chat) // 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, api_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 Chat ──────────────────────────────── // ── Get Channel ─────────────────────────────
func (h *ChatHandler) GetChat(c *gin.Context) { func (h *ChannelHandler) GetChannel(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
chatID := c.Param("id") channelID := c.Param("id")
var chat chatResponse var ch channelResponse
var tags []string var tags []string
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
SELECT c.id, c.user_id, c.title, c.model, c.api_config_id, SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_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,
COALESCE(mc.cnt, 0) AS message_count, COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at c.created_at, c.updated_at
FROM chats c FROM channels c
LEFT JOIN ( LEFT JOIN (
SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.chat_id = c.id ) mc ON mc.channel_id = c.id
WHERE c.id = $1 AND c.user_id = $2 WHERE c.id = $1 AND c.user_id = $2
`, chatID, userID).Scan( `, channelID, userID).Scan(
&chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), pq.Array(&tags),
&chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt, &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return return
} }
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get chat"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"})
return return
} }
if tags == nil { if tags == nil {
tags = []string{} tags = []string{}
} }
chat.Tags = tags ch.Tags = tags
c.JSON(http.StatusOK, chat) c.JSON(http.StatusOK, ch)
} }
// ── Update Chat ───────────────────────────── // ── Update Channel ──────────────────────────
func (h *ChatHandler) UpdateChat(c *gin.Context) { func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
chatID := c.Param("id") channelID := c.Param("id")
if database.DB == nil { if database.DB == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"})
return return
} }
var req updateChatRequest var req updateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -284,13 +324,13 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
// Verify ownership // Verify ownership
var ownerID string var ownerID string
err := database.DB.QueryRow(`SELECT user_id FROM chats WHERE id = $1`, chatID).Scan(&ownerID) err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return return
} }
if ownerID != userID { if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"}) c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return return
} }
@@ -308,6 +348,9 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
if req.Title != nil { if req.Title != nil {
addClause("title", *req.Title) addClause("title", *req.Title)
} }
if req.Description != nil {
addClause("description", *req.Description)
}
if req.Model != nil { if req.Model != nil {
addClause("model", *req.Model) addClause("model", *req.Model)
} }
@@ -335,7 +378,7 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
return return
} }
query := "UPDATE chats SET " query := "UPDATE channels SET "
for i, clause := range setClauses { for i, clause := range setClauses {
if i > 0 { if i > 0 {
query += ", " query += ", "
@@ -343,38 +386,38 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) {
query += clause query += clause
} }
query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1) query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1)
args = append(args, chatID, userID) args = append(args, channelID, userID)
_, err = database.DB.Exec(query, args...) _, err = database.DB.Exec(query, args...)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update chat"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return return
} }
// Return updated chat // Return updated channel
h.GetChat(c) h.GetChannel(c)
} }
// ── Delete Chat ───────────────────────────── // ── Delete Channel ──────────────────────────
func (h *ChatHandler) DeleteChat(c *gin.Context) { func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
chatID := c.Param("id") channelID := c.Param("id")
result, err := database.DB.Exec( result, err := database.DB.Exec(
`DELETE FROM chats WHERE id = $1 AND user_id = $2`, `DELETE FROM channels WHERE id = $1 AND user_id = $2`,
chatID, userID, channelID, userID,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete chat"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return return
} }
rows, _ := result.RowsAffected() rows, _ := result.RowsAffected()
if rows == 0 { if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "chat deleted"}) c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
} }

View File

@@ -13,56 +13,56 @@ func init() {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
} }
// ── Chat Request Validation ───────────────── // ── Channel Request Validation ─────────────────
func TestCreateChatMissingTitle(t *testing.T) { func TestCreateChannelMissingTitle(t *testing.T) {
h := NewChatHandler() h := NewChannelHandler()
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/chats", c.Request = httptest.NewRequest("POST", "/api/v1/channels",
strings.NewReader(`{}`)) strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
h.CreateChat(c) h.CreateChannel(c)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String()) t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String())
} }
} }
func TestCreateChatTitleTooLong(t *testing.T) { func TestCreateChannelTitleTooLong(t *testing.T) {
h := NewChatHandler() h := NewChannelHandler()
longTitle := strings.Repeat("x", 501) longTitle := strings.Repeat("x", 501)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/chats", c.Request = httptest.NewRequest("POST", "/api/v1/channels",
strings.NewReader(`{"title":"`+longTitle+`"}`)) strings.NewReader(`{"title":"`+longTitle+`"}`))
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
h.CreateChat(c) h.CreateChannel(c)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code) t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code)
} }
} }
func TestUpdateChatEmptyBody(t *testing.T) { func TestUpdateChannelEmptyBody(t *testing.T) {
h := NewChatHandler() h := NewChannelHandler()
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user-id") c.Set("user_id", "test-user-id")
c.Params = gin.Params{{Key: "id", Value: "test-chat-id"}} c.Params = gin.Params{{Key: "id", Value: "test-channel-id"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/chats/test-chat-id", c.Request = httptest.NewRequest("PUT", "/api/v1/channels/test-channel-id",
strings.NewReader(`{}`)) strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
// Without a DB connection, UpdateChat will fail at ownership check. // Without a DB connection, UpdateChannel will fail at ownership check.
// Integration tests with a real DB would validate the "no fields" path. // Integration tests with a real DB would validate the "no fields" path.
// Here we just confirm it doesn't return 400 for valid JSON. // Here we just confirm it doesn't return 400 for valid JSON.
h.UpdateChat(c) h.UpdateChannel(c)
if w.Code == http.StatusBadRequest { if w.Code == http.StatusBadRequest {
t.Error("Empty JSON body should not be a parse error") t.Error("Empty JSON body should not be a parse error")
@@ -76,8 +76,8 @@ func TestCreateMessageMissingRole(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-chat"}} c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages", c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"content":"hello"}`)) strings.NewReader(`{"content":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
@@ -93,8 +93,8 @@ func TestCreateMessageInvalidRole(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-chat"}} c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages", c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"role":"invalid","content":"hello"}`)) strings.NewReader(`{"role":"invalid","content":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
@@ -110,8 +110,8 @@ func TestCreateMessageMissingContent(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-chat"}} c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages", c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"role":"user"}`)) strings.NewReader(`{"role":"user"}`))
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
@@ -135,7 +135,7 @@ func TestRegenerateReturns501(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/chats/x/regenerate", nil) c.Request = httptest.NewRequest("POST", "/api/v1/channels/x/regenerate", nil)
h.Regenerate(c) h.Regenerate(c)
@@ -149,7 +149,7 @@ func TestRegenerateReturns501(t *testing.T) {
func TestParsePaginationDefaults(t *testing.T) { func TestParsePaginationDefaults(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/chats", nil) c.Request = httptest.NewRequest("GET", "/api/v1/channels", nil)
page, perPage, offset := parsePagination(c) page, perPage, offset := parsePagination(c)
@@ -167,7 +167,7 @@ func TestParsePaginationDefaults(t *testing.T) {
func TestParsePaginationCustom(t *testing.T) { func TestParsePaginationCustom(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/chats?page=3&per_page=10", nil) c.Request = httptest.NewRequest("GET", "/api/v1/channels?page=3&per_page=10", nil)
page, perPage, offset := parsePagination(c) page, perPage, offset := parsePagination(c)
@@ -185,7 +185,7 @@ func TestParsePaginationCustom(t *testing.T) {
func TestParsePaginationClampMax(t *testing.T) { func TestParsePaginationClampMax(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/chats?per_page=500", nil) c.Request = httptest.NewRequest("GET", "/api/v1/channels?per_page=500", nil)
_, perPage, _ := parsePagination(c) _, perPage, _ := parsePagination(c)

View File

@@ -17,7 +17,8 @@ import (
// ── Request Types ─────────────────────────── // ── Request Types ───────────────────────────
type completionRequest struct { type completionRequest struct {
ChatID string `json:"chat_id" binding:"required"` ChannelID string `json:"channel_id"` // preferred; validated manually below
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
Content string `json:"content" binding:"required"` Content string `json:"content" binding:"required"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
APIConfigID string `json:"api_config_id,omitempty"` APIConfigID string `json:"api_config_id,omitempty"`
@@ -41,7 +42,7 @@ func NewCompletionHandler() *CompletionHandler {
// Flow: // Flow:
// 1. Validate request, verify chat ownership // 1. Validate request, verify chat ownership
// 2. Resolve api_config (from request, chat, or user default) // 2. Resolve api_config (from request, chat, or user default)
// 3. Load conversation history from chat_messages // 3. Load conversation history from messages
// 4. Persist user message // 4. Persist user message
// 5. Call provider (stream or non-stream) // 5. Call provider (stream or non-stream)
// 6. Stream SSE to client / return JSON // 6. Stream SSE to client / return JSON
@@ -54,15 +55,25 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
return return
} }
// Support chat_id as alias during frontend transition
channelID := req.ChannelID
if channelID == "" {
channelID = req.ChatID
}
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
return
}
userID := getUserID(c) userID := getUserID(c)
// Verify chat ownership // Verify channel ownership
if !userOwnsChat(c, req.ChatID, userID) { if !userOwnsChannel(c, channelID, userID) {
return return
} }
// Resolve provider config // Resolve provider config
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, req) providerCfg, providerID, model, configID, err := h.resolveConfig(userID, channelID, req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -75,7 +86,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
} }
// Load conversation history // Load conversation history
messages, err := h.loadConversation(req.ChatID) messages, err := h.loadConversation(channelID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
return return
@@ -88,7 +99,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}) })
// Persist user message // Persist user message
if err := h.persistMessage(req.ChatID, "user", req.Content, "", 0, 0); err != nil { if err := h.persistMessage(channelID, "user", req.Content, "", 0, 0); err != nil {
log.Printf("Failed to persist user message: %v", err) log.Printf("Failed to persist user message: %v", err)
} }
@@ -122,9 +133,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
} }
if stream { if stream {
h.streamCompletion(c, provider, providerCfg, provReq, req.ChatID, model) h.streamCompletion(c, provider, providerCfg, provReq, channelID, model)
} else { } else {
h.syncCompletion(c, provider, providerCfg, provReq, req.ChatID, model) h.syncCompletion(c, provider, providerCfg, provReq, channelID, model)
} }
} }
@@ -135,7 +146,7 @@ func (h *CompletionHandler) streamCompletion(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req providers.CompletionRequest, req providers.CompletionRequest,
chatID, model string, channelID, model string,
) { ) {
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req) ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
if err != nil { if err != nil {
@@ -190,7 +201,7 @@ func (h *CompletionHandler) streamCompletion(
// Persist assistant response // Persist assistant response
if fullContent != "" { if fullContent != "" {
if err := h.persistMessage(chatID, "assistant", fullContent, model, 0, 0); err != nil { if err := h.persistMessage(channelID, "assistant", fullContent, model, 0, 0); err != nil {
log.Printf("Failed to persist assistant message: %v", err) log.Printf("Failed to persist assistant message: %v", err)
} }
} }
@@ -203,7 +214,7 @@ func (h *CompletionHandler) syncCompletion(
provider providers.Provider, provider providers.Provider,
cfg providers.ProviderConfig, cfg providers.ProviderConfig,
req providers.CompletionRequest, req providers.CompletionRequest,
chatID, model string, channelID, model string,
) { ) {
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req) resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
if err != nil { if err != nil {
@@ -212,7 +223,7 @@ func (h *CompletionHandler) syncCompletion(
} }
// Persist assistant response // Persist assistant response
if err := h.persistMessage(chatID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil { if err := h.persistMessage(channelID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil {
log.Printf("Failed to persist assistant message: %v", err) log.Printf("Failed to persist assistant message: %v", err)
} }
@@ -271,7 +282,7 @@ func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) prov
// ── Config Resolution ─────────────────────── // ── Config Resolution ───────────────────────
// Priority: request.api_config_id → chat.api_config_id → user's first active config // Priority: request.api_config_id → chat.api_config_id → user's first active config
func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) { func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
var configID string var configID string
// 1. Explicit config from request // 1. Explicit config from request
@@ -279,14 +290,14 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
configID = req.APIConfigID configID = req.APIConfigID
} }
// 2. Config from chat // 2. Config from channel
if configID == "" { if configID == "" {
var chatConfigID *string var channelConfigID *string
err := database.DB.QueryRow( err := database.DB.QueryRow(
`SELECT api_config_id FROM chats WHERE id = $1`, req.ChatID, `SELECT api_config_id FROM channels WHERE id = $1`, channelID,
).Scan(&chatConfigID) ).Scan(&channelConfigID)
if err == nil && chatConfigID != nil { if err == nil && channelConfigID != nil {
configID = *chatConfigID configID = *channelConfigID
} }
} }
@@ -294,7 +305,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
if configID == "" { if configID == "" {
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
SELECT id FROM api_configs SELECT id FROM api_configs
WHERE (user_id = $1 OR is_global = true) AND is_active = true WHERE (user_id = $1 OR is_global = true OR user_id IS NULL) AND is_active = true
ORDER BY user_id NULLS LAST, created_at ASC ORDER BY user_id NULLS LAST, created_at ASC
LIMIT 1 LIMIT 1
`, userID).Scan(&configID) `, userID).Scan(&configID)
@@ -310,7 +321,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings
FROM api_configs FROM api_configs
WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true WHERE id = $1 AND (user_id = $2 OR is_global = true OR user_id IS NULL) AND is_active = true
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON) `, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -356,11 +367,11 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
// ── Conversation Loader ───────────────────── // ── Conversation Loader ─────────────────────
func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message, error) { func (h *CompletionHandler) loadConversation(channelID string) ([]providers.Message, error) {
// Load system prompt from chat // Load system prompt from channel
var systemPrompt *string var systemPrompt *string
_ = database.DB.QueryRow( _ = database.DB.QueryRow(
`SELECT system_prompt FROM chats WHERE id = $1`, chatID, `SELECT system_prompt FROM channels WHERE id = $1`, channelID,
).Scan(&systemPrompt) ).Scan(&systemPrompt)
messages := make([]providers.Message, 0) messages := make([]providers.Message, 0)
@@ -374,10 +385,10 @@ func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message
// Load message history (oldest first) // Load message history (oldest first)
rows, err := database.DB.Query(` rows, err := database.DB.Query(`
SELECT role, content FROM chat_messages SELECT role, content FROM messages
WHERE chat_id = $1 WHERE channel_id = $1
ORDER BY created_at ASC ORDER BY created_at ASC
`, chatID) `, channelID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -396,22 +407,42 @@ func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message
// ── Message Persistence ───────────────────── // ── Message Persistence ─────────────────────
func (h *CompletionHandler) persistMessage(chatID, role, content, model string, inputTokens, outputTokens int) error { func (h *CompletionHandler) persistMessage(channelID, role, content, model string, inputTokens, outputTokens int) error {
var tokensUsed *int var tokensUsed *int
if inputTokens > 0 || outputTokens > 0 { if inputTokens > 0 || outputTokens > 0 {
total := inputTokens + outputTokens total := inputTokens + outputTokens
tokensUsed = &total tokensUsed = &total
} }
// Find current leaf for parent_id
var parentID *string
_ = database.DB.QueryRow(
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
channelID,
).Scan(&parentID)
participantType := "user"
participantID := channelID // will be overridden below
if role == "assistant" {
participantType = "model"
participantID = model
} else {
// Get channel owner as participant_id for user messages
var ownerID string
if err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID); err == nil {
participantID = ownerID
}
}
_, err := database.DB.Exec(` _, err := database.DB.Exec(`
INSERT INTO chat_messages (chat_id, role, content, model, tokens_used) INSERT INTO messages (channel_id, role, content, model, tokens_used, parent_id, participant_type, participant_id)
VALUES ($1, $2, $3, NULLIF($4, ''), $5) VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8)
`, chatID, role, content, model, tokensUsed) `, channelID, role, content, model, tokensUsed, parentID, participantType, participantID)
if err != nil { if err != nil {
return err return err
} }
// Touch chat updated_at // Touch channel updated_at
_, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID) _, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
return nil return nil
} }

View File

@@ -19,13 +19,14 @@ type createMessageRequest struct {
} }
type messageResponse struct { type messageResponse struct {
ID string `json:"id"` ID string `json:"id"`
ChatID string `json:"chat_id"` ChannelID string `json:"channel_id"`
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Model *string `json:"model"` Model *string `json:"model"`
TokensUsed *int `json:"tokens_used"` TokensUsed *int `json:"tokens_used"`
CreatedAt string `json:"created_at"` ParentID *string `json:"parent_id,omitempty"`
CreatedAt string `json:"created_at"`
} }
// MessageHandler holds dependencies for message endpoints. // MessageHandler holds dependencies for message endpoints.
@@ -42,18 +43,18 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
page, perPage, offset := parsePagination(c) page, perPage, offset := parsePagination(c)
userID := getUserID(c) userID := getUserID(c)
chatID := c.Param("id") channelID := c.Param("id")
// Verify ownership // Verify ownership
if !userOwnsChat(c, chatID, userID) { if !userOwnsChannel(c, channelID, userID) {
return return
} }
// Count total messages // Count total messages
var total int var total int
err := database.DB.QueryRow( err := database.DB.QueryRow(
`SELECT COUNT(*) FROM chat_messages WHERE chat_id = $1`, `SELECT COUNT(*) FROM messages WHERE channel_id = $1`,
chatID, channelID,
).Scan(&total) ).Scan(&total)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"})
@@ -62,12 +63,12 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
// Fetch messages (oldest first for conversation display) // Fetch messages (oldest first for conversation display)
rows, err := database.DB.Query(` rows, err := database.DB.Query(`
SELECT id, chat_id, role, content, model, tokens_used, created_at SELECT id, channel_id, role, content, model, tokens_used, parent_id, created_at
FROM chat_messages FROM messages
WHERE chat_id = $1 WHERE channel_id = $1
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
`, chatID, perPage, offset) `, channelID, perPage, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
return return
@@ -78,8 +79,8 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
for rows.Next() { for rows.Next() {
var msg messageResponse var msg messageResponse
err := rows.Scan( err := rows.Scan(
&msg.ID, &msg.ChatID, &msg.Role, &msg.Content, &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.CreatedAt, &msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
@@ -107,29 +108,49 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
} }
userID := getUserID(c) userID := getUserID(c)
chatID := c.Param("id") channelID := c.Param("id")
// Verify ownership // Verify ownership
if !userOwnsChat(c, chatID, userID) { if !userOwnsChannel(c, channelID, userID) {
return return
} }
// Find the current leaf message to set as parent
var parentID *string
_ = database.DB.QueryRow(
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
channelID,
).Scan(&parentID)
var msg messageResponse var msg messageResponse
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
INSERT INTO chat_messages (chat_id, role, content, model) INSERT INTO messages (channel_id, role, content, model, parent_id, participant_type, participant_id)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, chat_id, role, content, model, tokens_used, created_at RETURNING id, channel_id, role, content, model, tokens_used, parent_id, created_at
`, chatID, req.Role, req.Content, req.Model).Scan( `, channelID, req.Role, req.Content, req.Model, parentID,
&msg.ID, &msg.ChatID, &msg.Role, &msg.Content, func() string {
&msg.Model, &msg.TokensUsed, &msg.CreatedAt, if req.Role == "assistant" {
return "model"
}
return "user"
}(),
func() string {
if req.Role == "assistant" && req.Model != "" {
return req.Model
}
return userID
}(),
).Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return return
} }
// Touch the chat's updated_at so it sorts to top of list // Touch the channel's updated_at so it sorts to top of list
_, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID) _, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
c.JSON(http.StatusCreated, msg) c.JSON(http.StatusCreated, msg)
} }
@@ -152,22 +173,22 @@ func (h *MessageHandler) Stream(c *gin.Context) {
// ── Ownership Check ───────────────────────── // ── Ownership Check ─────────────────────────
func userOwnsChat(c *gin.Context, chatID, userID string) bool { func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
var ownerID string var ownerID string
err := database.DB.QueryRow( err := database.DB.QueryRow(
`SELECT user_id FROM chats WHERE id = $1`, chatID, `SELECT user_id FROM channels WHERE id = $1`, channelID,
).Scan(&ownerID) ).Scan(&ownerID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return false return false
} }
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify chat ownership"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
return false return false
} }
if ownerID != userID { if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"}) c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false return false
} }
return true return true

View File

@@ -27,18 +27,24 @@ func main() {
if err := database.Migrate(); err != nil { if err := database.Migrate(); err != nil {
log.Fatalf("❌ Schema migration failed: %v", err) log.Fatalf("❌ Schema migration failed: %v", err)
} }
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg)
} }
defer database.Close() defer database.Close()
r := gin.Default() r := gin.Default()
r.Use(middleware.CORS()) r.Use(middleware.CORS())
// ── Base path group ──────────────────────
// All routes live under cfg.BasePath (e.g. "/dev", "/test", or "")
base := r.Group(cfg.BasePath)
// ── EventBus + WebSocket Hub ───────────── // ── EventBus + WebSocket Hub ─────────────
bus := events.NewBus() bus := events.NewBus()
hub := events.NewHub(bus) hub := events.NewHub(bus)
// Health check (k8s probes hit this directly) // Health check (k8s probes hit this directly)
r.GET("/health", func(c *gin.Context) { base.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": "ok", "status": "ok",
"version": Version, "version": Version,
@@ -48,13 +54,13 @@ func main() {
}) })
// WebSocket endpoint — auth via ?token= query param // WebSocket endpoint — auth via ?token= query param
r.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ────────────── // ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg) auth := handlers.NewAuthHandler(cfg)
authLimiter := middleware.NewRateLimiter(1, 5) authLimiter := middleware.NewRateLimiter(1, 5)
api := r.Group("/api/v1") api := base.Group("/api/v1")
{ {
// Health (routable through ingress) // Health (routable through ingress)
api.GET("/health", func(c *gin.Context) { api.GET("/health", func(c *gin.Context) {
@@ -85,23 +91,23 @@ func main() {
protected := api.Group("") protected := api.Group("")
protected.Use(middleware.Auth(cfg)) protected.Use(middleware.Auth(cfg))
{ {
// Chats // Channels (unified: replaces /chats)
chats := handlers.NewChatHandler() channels := handlers.NewChannelHandler()
protected.GET("/chats", chats.ListChats) protected.GET("/channels", channels.ListChannels)
protected.POST("/chats", chats.CreateChat) protected.POST("/channels", channels.CreateChannel)
protected.GET("/chats/:id", chats.GetChat) protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/chats/:id", chats.UpdateChat) protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/chats/:id", chats.DeleteChat) protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages // Messages
msgs := handlers.NewMessageHandler() msgs := handlers.NewMessageHandler()
protected.GET("/chats/:id/messages", msgs.ListMessages) protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/chats/:id/messages", msgs.CreateMessage) protected.POST("/channels/:id/messages", msgs.CreateMessage)
// ── Chat Engine (#8) ──────────────── // ── Chat Engine (#8) ────────────────
comp := handlers.NewCompletionHandler() comp := handlers.NewCompletionHandler()
protected.POST("/chat/completions", comp.Complete) protected.POST("/chat/completions", comp.Complete)
protected.POST("/chats/:id/regenerate", msgs.Regenerate) // TODO: wire to completion handler protected.POST("/channels/:id/regenerate", msgs.Regenerate) // TODO: wire to completion handler
// API Configs // API Configs
apiCfg := handlers.NewAPIConfigHandler() apiCfg := handlers.NewAPIConfigHandler()
@@ -123,6 +129,10 @@ func main() {
protected.POST("/profile/password", settings.ChangePassword) protected.POST("/profile/password", settings.ChangePassword)
protected.GET("/settings", settings.GetSettings) protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings) protected.PUT("/settings", settings.UpdateSettings)
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler()
protected.GET("/settings/public", adm.PublicSettings)
} }
// ── Admin routes ──────────────────────── // ── Admin routes ────────────────────────
@@ -156,15 +166,21 @@ func main() {
// Model Configs // Model Configs
admin.GET("/models", adm.ListModelConfigs) admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels) admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/bulk", adm.BulkUpdateModels)
admin.PUT("/models/:id", adm.UpdateModelConfig) admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig) admin.DELETE("/models/:id", adm.DeleteModelConfig)
} }
} }
bp := cfg.BasePath
if bp == "" {
bp = "/"
}
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) 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(" Schema: %s", database.SchemaVersion())
log.Printf(" Providers: %v", providers.List()) log.Printf(" Providers: %v", providers.List())
log.Printf(" EventBus: ready, WebSocket on /ws") log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
if err := r.Run(":" + cfg.Port); err != nil { if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err) log.Fatalf("Failed to start server: %v", err)
} }

View File

@@ -53,7 +53,7 @@ func TestCORSHeaders(t *testing.T) {
func TestAllRoutesRegistered(t *testing.T) { func TestAllRoutesRegistered(t *testing.T) {
cfg := &config.Config{JWTSecret: "test"} cfg := &config.Config{JWTSecret: "test"}
auth := handlers.NewAuthHandler(cfg) auth := handlers.NewAuthHandler(cfg)
chats := handlers.NewChatHandler() channels := handlers.NewChannelHandler()
msgs := handlers.NewMessageHandler() msgs := handlers.NewMessageHandler()
comp := handlers.NewCompletionHandler() comp := handlers.NewCompletionHandler()
apiCfg := handlers.NewAPIConfigHandler() apiCfg := handlers.NewAPIConfigHandler()
@@ -73,19 +73,19 @@ func TestAllRoutesRegistered(t *testing.T) {
protected := api.Group("") protected := api.Group("")
{ {
// Chats // Channels
protected.GET("/chats", chats.ListChats) protected.GET("/channels", channels.ListChannels)
protected.POST("/chats", chats.CreateChat) protected.POST("/channels", channels.CreateChannel)
protected.GET("/chats/:id", chats.GetChat) protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/chats/:id", chats.UpdateChat) protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/chats/:id", chats.DeleteChat) protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages // Messages
protected.GET("/chats/:id/messages", msgs.ListMessages) protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/chats/:id/messages", msgs.CreateMessage) protected.POST("/channels/:id/messages", msgs.CreateMessage)
protected.POST("/chats/:id/regenerate", msgs.Regenerate) protected.POST("/channels/:id/regenerate", msgs.Regenerate)
// Chat Engine // Completion Engine
protected.POST("/chat/completions", comp.Complete) protected.POST("/chat/completions", comp.Complete)
// API Configs // API Configs
@@ -140,17 +140,17 @@ func TestAllRoutesRegistered(t *testing.T) {
"POST /api/v1/auth/login", "POST /api/v1/auth/login",
"POST /api/v1/auth/refresh", "POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout", "POST /api/v1/auth/logout",
// Chats // Channels
"GET /api/v1/chats", "GET /api/v1/channels",
"POST /api/v1/chats", "POST /api/v1/channels",
"GET /api/v1/chats/:id", "GET /api/v1/channels/:id",
"PUT /api/v1/chats/:id", "PUT /api/v1/channels/:id",
"DELETE /api/v1/chats/:id", "DELETE /api/v1/channels/:id",
// Messages // Messages
"GET /api/v1/chats/:id/messages", "GET /api/v1/channels/:id/messages",
"POST /api/v1/chats/:id/messages", "POST /api/v1/channels/:id/messages",
"POST /api/v1/chats/:id/regenerate", "POST /api/v1/channels/:id/regenerate",
// Chat Engine // Completion Engine
"POST /api/v1/chat/completions", "POST /api/v1/chat/completions",
// API Configs // API Configs
"GET /api/v1/api-configs", "GET /api/v1/api-configs",

View File

@@ -14,12 +14,12 @@ type BaseModel struct {
// User represents a user in the system // User represents a user in the system
type User struct { type User struct {
BaseModel BaseModel
Email string `json:"email" db:"email"` Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"` PasswordHash string `json:"-" db:"password_hash"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Role string `json:"role" db:"role"` Role string `json:"role" db:"role"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
IsActive bool `json:"is_active" db:"is_active"` IsActive bool `json:"is_active" db:"is_active"`
} }
// UserRole constants // UserRole constants
@@ -28,121 +28,147 @@ const (
UserRoleAdmin = "admin" UserRoleAdmin = "admin"
) )
// Chat represents a chat conversation // ── Channel Types ───────────────────────────
type Chat struct {
const (
ChannelTypeDirect = "direct" // 1:1 AI chat (legacy "chat")
ChannelTypeGroup = "group" // multi-model conversation
ChannelTypeChannel = "channel" // named, persistent, membered
)
// Channel represents a conversation channel (unified: chats + channels).
type Channel struct {
BaseModel BaseModel
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"` Title string `json:"title" db:"title"`
Model string `json:"model" db:"model"` Description string `json:"description,omitempty" db:"description"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` Type string `json:"type" db:"type"`
IsArchived bool `json:"is_archived" db:"is_archived"` Model string `json:"model,omitempty" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
APIConfigID *string `json:"api_config_id,omitempty" db:"api_config_id"`
IsArchived bool `json:"is_archived" db:"is_archived"`
IsPinned bool `json:"is_pinned" db:"is_pinned"`
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
} }
// Message represents a chat message // Message represents a message in a channel
type Message struct { type Message struct {
BaseModel BaseModel
ChatID string `json:"chat_id" db:"chat_id"` ChannelID string `json:"channel_id" db:"channel_id"`
Role string `json:"role" db:"role"` // "user", "assistant", "system" Role string `json:"role" db:"role"`
Content string `json:"content" db:"content"` Content string `json:"content" db:"content"`
Tokens int `json:"tokens,omitempty" db:"tokens"` Tokens int `json:"tokens,omitempty" db:"tokens"`
Model string `json:"model,omitempty" db:"model"` Model string `json:"model,omitempty" db:"model"`
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"` FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
} }
// MessageRole constants
const ( const (
MessageRoleUser = "user" MessageRoleUser = "user"
MessageRoleAssistant = "assistant" MessageRoleAssistant = "assistant"
MessageRoleSystem = "system" MessageRoleSystem = "system"
) )
// APIConfig represents a configured API provider // ── Channel Members & Models ────────────────
type APIConfig struct {
type ChannelMember struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"`
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
LastReadAt time.Time `json:"last_read_at" db:"last_read_at"`
}
type ChannelModel struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
ModelID string `json:"model_id" db:"model_id"`
APIConfigID string `json:"api_config_id,omitempty" db:"api_config_id"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
IsDefault bool `json:"is_default" db:"is_default"`
}
type ChannelCursor struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
ActiveLeafID *string `json:"active_leaf_id,omitempty" db:"active_leaf_id"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ── Organization ────────────────────────────
type Folder struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
}
type Project struct {
BaseModel BaseModel
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"` // "openai", "anthropic", "google", etc.
APIKey string `json:"-" db:"api_key"`
BaseURL string `json:"base_url,omitempty" db:"base_url"`
Model string `json:"model" db:"model"`
IsDefault bool `json:"is_default" db:"is_default"`
}
// Channel represents a chat channel
type Channel struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"` Description string `json:"description,omitempty" db:"description"`
IsPrivate bool `json:"is_private" db:"is_private"` Color string `json:"color,omitempty" db:"color"`
OwnerID string `json:"owner_id" db:"owner_id"`
} }
// ChannelMember represents a member of a channel // ── API Config ──────────────────────────────
type ChannelMember struct {
type APIConfig struct {
BaseModel BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"` // "member", "moderator", "admin" Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"`
APIKey string `json:"-" db:"api_key"`
BaseURL string `json:"base_url,omitempty" db:"base_url"`
Model string `json:"model" db:"model"`
IsDefault bool `json:"is_default" db:"is_default"`
} }
// ChannelMessage represents a message in a channel // ── Notes (future Phase 2) ──────────────────
type ChannelMessage struct {
BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
Content string `json:"content" db:"content"`
ParentID string `json:"parent_id,omitempty" db:"parent_id"`
}
// Note represents a user note
type Note struct { type Note struct {
BaseModel BaseModel
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"` Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"` Content string `json:"content" db:"content"`
Tags []string `json:"tags,omitempty" db:"tags"` Tags []string `json:"tags,omitempty" db:"tags"`
FolderID string `json:"folder_id,omitempty" db:"folder_id"` FolderID string `json:"folder_id,omitempty" db:"folder_id"`
IsShared bool `json:"is_shared" db:"is_shared"` IsShared bool `json:"is_shared" db:"is_shared"`
} }
// KnowledgeBase represents a knowledge base collection
type KnowledgeBase struct { type KnowledgeBase struct {
BaseModel BaseModel
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Source string `json:"source" db:"source"` // "url", "file", "text" Source string `json:"source" db:"source"`
Settings string `json:"settings,omitempty" db:"settings"` // JSON settings
}
// Workflow represents a workflow definition
type Workflow struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Steps string `json:"steps" db:"steps"` // JSON array of steps
Settings string `json:"settings,omitempty" db:"settings"` Settings string `json:"settings,omitempty" db:"settings"`
IsPublic bool `json:"is_public" db:"is_public"`
} }
// Settings represents user settings // ── Settings ────────────────────────────────
type Settings struct { type Settings struct {
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
Theme string `json:"theme" db:"theme"` Theme string `json:"theme" db:"theme"`
Language string `json:"language" db:"language"` Language string `json:"language" db:"language"`
Model string `json:"model" db:"model"` Model string `json:"model" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
MaxTokens int `json:"max_tokens" db:"max_tokens"` MaxTokens int `json:"max_tokens" db:"max_tokens"`
Temperature float64 `json:"temperature" db:"temperature"` Temperature float64 `json:"temperature" db:"temperature"`
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"` DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
} }
// APIToken represents an API token for external access
type APIToken struct { type APIToken struct {
BaseModel BaseModel
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Token string `json:"-" db:"token"` Token string `json:"-" db:"token"`
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"` ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
IsActive bool `json:"is_active" db:"is_active"` IsActive bool `json:"is_active" db:"is_active"`
} }

View File

@@ -1,5 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ========================================== /* ==========================================
Chat Switchboard v0.5.2 Chat Switchboard v0.6.1
Clean utility layout · Open WebUI inspired Clean utility layout · Open WebUI inspired
========================================== */ ========================================== */
@@ -19,25 +21,56 @@
--danger: #ef4444; --danger: #ef4444;
--success: #22c55e; --success: #22c55e;
--warning: #eab308; --warning: #eab308;
--purple: #a78bfa;
--purple-dim: rgba(167,139,250,0.10);
--radius: 8px; --radius: 8px;
--radius-lg: 12px; --radius-lg: 12px;
--sidebar-w: 260px; --sidebar-w: 260px;
--sidebar-rail: 52px; --sidebar-rail: 52px;
--font: 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font: 'DM Sans', 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace; --mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
--transition: 180ms ease; --transition: 180ms ease;
/* Banner system — set by JS from /api/v1/admin/settings/banner */
--banner-top-height: 0px;
--banner-bottom-height: 0px;
--banner-bg: transparent;
--banner-fg: transparent;
} }
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; font-size: 14px; } body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; font-size: 14px; }
/* Global scrollbar styling */
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
::selection { background: var(--accent-dim); } ::selection { background: var(--accent-dim); }
/* ── App Shell ───────────────────────────── */ /* ── App Shell ───────────────────────────── */
.app { display: flex; height: 100vh; } .app { display: flex; height: 100vh; flex-direction: column; }
.app-body { display: flex; flex: 1; min-height: 0; }
/* ── Environment Banners ──────────────────── */
.banner {
display: none;
width: 100%; text-align: center;
font-size: 12px; font-weight: 700; letter-spacing: 1px;
text-transform: uppercase;
padding: 2px 0; line-height: 1.4;
background: var(--banner-bg); color: var(--banner-fg);
flex-shrink: 0; z-index: 9999;
}
.banner.active { display: block; }
.banner-top { order: -1; }
.banner-bottom { order: 999; }
/* ── Sidebar ─────────────────────────────── */ /* ── Sidebar ─────────────────────────────── */
@@ -85,13 +118,105 @@ a:hover { text-decoration: underline; }
.sidebar.collapsed .sb-label { opacity: 0; width: 0; } .sidebar.collapsed .sb-label { opacity: 0; width: 0; }
.sidebar.collapsed .brand-text { opacity: 0; width: 0; } .sidebar.collapsed .brand-text { opacity: 0; width: 0; }
/* Chat list */ /* Collapsed centering */
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
.sidebar.collapsed .user-flyout {
position: fixed;
left: var(--sidebar-rail);
bottom: 8px;
right: auto;
width: 200px;
margin-bottom: 0;
}
.sidebar.collapsed .avatar-bug { display: none; }
/* Split button (New Chat + dropdown) */
.split-btn { display: flex; width: 100%; gap: 0; }
.split-btn-main {
flex: 1; display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--radius) 0 0 var(--radius);
background: none; border: none; color: var(--text-2);
cursor: pointer; font-size: 13px; white-space: nowrap;
transition: background var(--transition), color var(--transition);
}
.split-btn-main:hover { background: var(--bg-hover); color: var(--text); }
.split-btn-main svg { flex-shrink: 0; }
.split-btn-drop {
display: flex; align-items: center; padding: 8px 6px;
border-radius: 0 var(--radius) var(--radius) 0;
background: none; border: none; border-left: 1px solid var(--border);
color: var(--text-3); cursor: pointer;
transition: background var(--transition), color var(--transition);
}
.split-btn-drop:hover { background: var(--bg-hover); color: var(--text); }
.sidebar.collapsed .split-btn-drop { display: none; }
/* Dropdown menu */
.split-dropdown {
display: none; position: absolute; left: 6px; right: 6px;
top: 100%; z-index: 200;
background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius); padding: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.split-dropdown.open { display: block; }
.split-dropdown-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 4px; width: 100%;
background: none; border: none; color: var(--text-2);
cursor: pointer; font-size: 12px; white-space: nowrap;
transition: background var(--transition), color var(--transition);
}
.split-dropdown-item:hover { background: var(--bg-hover); color: var(--text); }
.split-dropdown-item.disabled { opacity: 0.4; pointer-events: none; }
.split-dropdown-item .dd-hint {
margin-left: auto; font-size: 10px; color: var(--text-3);
}
/* Chat/Channel list */
.sidebar-chats { .sidebar-chats {
flex: 1; overflow-y: auto; overflow-x: hidden; flex: 1; overflow-y: auto; overflow-x: hidden;
padding: 8px 6px; padding: 8px 6px;
} }
.sidebar-chats::-webkit-scrollbar { width: 4px; } .sidebar-chats::-webkit-scrollbar { width: 4px; }
.sidebar-chats::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* Channels section header */
.sidebar-section-header {
display: flex; align-items: center; gap: 6px;
padding: 8px 10px 4px; cursor: pointer;
user-select: none;
}
.sidebar-section-header .section-arrow {
font-size: 10px; color: var(--text-3); transition: transform var(--transition);
}
.sidebar-section-header.collapsed .section-arrow { transform: rotate(-90deg); }
.sidebar-section-header .section-label {
font-size: 11px; font-weight: 600; color: var(--text-3);
text-transform: uppercase; letter-spacing: 0.5px;
}
.sidebar-section-header .section-count {
font-size: 10px; color: var(--text-3); margin-left: auto;
}
.sidebar.collapsed .sidebar-section-header { display: none; }
.channel-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: var(--radius);
cursor: pointer; font-size: 13px; color: var(--text-2);
transition: background var(--transition); position: relative;
overflow: hidden;
}
.channel-item:hover { background: var(--bg-hover); color: var(--text); }
.channel-item.active { background: var(--bg-raised); color: var(--text); }
.channel-item .channel-hash { color: var(--text-3); font-weight: 600; flex-shrink: 0; }
.channel-item .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar.collapsed .channel-item .channel-name { display: none; }
.chat-group-label { .chat-group-label {
font-size: 11px; font-weight: 600; color: var(--text-3); font-size: 11px; font-weight: 600; color: var(--text-3);
@@ -231,8 +356,6 @@ a:hover { text-decoration: underline; }
/* Messages */ /* Messages */
.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; } .messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
.messages::-webkit-scrollbar { width: 6px; }
.messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.message { padding: 1.25rem 0; } .message { padding: 1.25rem 0; }
.message:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.03); } .message:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.03); }
@@ -413,35 +536,144 @@ button { font-family: var(--font); cursor: pointer; }
/* ── Auth Splash ─────────────────────────── */ /* ── Auth Splash ─────────────────────────── */
.splash { .splash {
display: flex; align-items: center; justify-content: center; display: flex;
height: 100vh; background: var(--bg); min-height: 100vh;
min-height: 100dvh;
} }
.splash-card {
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 2rem;
width: 380px; max-width: 90vw;
}
.splash-brand { text-align: center; margin-bottom: 1.5rem; }
.splash-logo { font-size: 3rem; margin-bottom: 0.5rem; }
.splash-brand h1 { font-size: 1.2rem; font-weight: 600; }
.splash-brand p { color: var(--text-2); font-size: 0.85rem; }
.splash-error { color: var(--danger); font-size: 12px; text-align: center; margin-top: 0.5rem; }
.auth-tabs { display: flex; margin-bottom: 1rem; border-bottom: 1px solid var(--border); } /* Hero Panel (left) */
.splash-hero {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 4rem 3.5rem;
position: relative;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 30% 40%, rgba(108,159,255,0.06), transparent),
radial-gradient(ellipse 60% 50% at 70% 70%, rgba(167,139,250,0.05), transparent),
var(--bg);
}
.splash-hero::before {
content: '';
position: absolute;
inset: -50%;
background-image:
linear-gradient(rgba(108,159,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(108,159,255,0.04) 1px, transparent 1px);
background-size: 48px 48px;
animation: gridDrift 30s linear infinite;
pointer-events: none;
}
@keyframes gridDrift { to { transform: translate(48px, 48px); } }
.splash-hero::after {
content: '';
position: absolute;
width: 320px; height: 320px;
bottom: -80px; right: -60px;
background: radial-gradient(circle, rgba(167,139,250,0.08), transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.hero-content { position: relative; z-index: 1; max-width: 520px; }
.hero-logo-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
.hero-logo-mark { width: 48px; height: 48px; flex-shrink: 0; }
.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
.hero-wordmark span { color: var(--accent); }
.hero-headline {
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
letter-spacing: -0.03em; margin-bottom: 1rem;
background: linear-gradient(135deg, var(--text) 0%, var(--text-2) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.hero-sub { font-size: 1.05rem; color: var(--text-2); line-height: 1.6; margin-bottom: 2.5rem; max-width: 440px; }
.hero-features { display: flex; flex-wrap: wrap; gap: 10px; }
.hero-pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 7px 14px; background: var(--bg-surface);
border: 1px solid var(--border); border-radius: 100px;
font-size: 0.8rem; font-weight: 500; color: var(--text-2);
transition: all var(--transition);
}
.hero-pill:hover { border-color: var(--border-light); color: var(--text); }
.hero-pill .pill-icon { font-size: 0.95rem; line-height: 1; }
.hero-pill.accent { border-color: rgba(108,159,255,0.2); color: var(--accent); }
.hero-pill.purple { border-color: rgba(167,139,250,0.2); color: var(--purple); }
.hero-version { margin-top: 3rem; font-size: 0.72rem; font-family: var(--mono); color: var(--text-3); letter-spacing: 0.04em; }
/* Auth Panel (right) */
.splash-auth {
width: 440px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 2rem; background: var(--bg-surface);
border-left: 1px solid var(--border);
}
.auth-card { width: 100%; max-width: 340px; }
.auth-card-header { margin-bottom: 2rem; }
.auth-card-header h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 4px; }
.auth-card-header p { font-size: 0.85rem; color: var(--text-3); }
.auth-tabs { display: flex; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); }
.auth-tab { .auth-tab {
flex: 1; background: none; border: none; color: var(--text-3); flex: 1; background: none; border: none; color: var(--text-3);
padding: 8px; cursor: pointer; font-size: 13px; padding: 10px 0; cursor: pointer; font-family: var(--font);
font-size: 0.85rem; font-weight: 500;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: color var(--transition); transition: color var(--transition);
} }
.auth-tab:hover { color: var(--text-2); }
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.auth-error { color: var(--danger); font-size: 12px; min-height: 1.2em; margin: 0.5rem 0; } .auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; }
.auth-actions { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; } .splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; }
.auth-actions { margin-top: 1.5rem; }
.auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; }
.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; }
/* Splash entrance animation */
.hero-logo-row, .hero-headline, .hero-sub, .hero-features, .hero-version,
.auth-card-header, .splash .auth-tabs, #authLoginForm, .splash .auth-actions {
opacity: 0; transform: translateY(12px);
animation: fadeUp 0.5s ease forwards;
}
.hero-logo-row { animation-delay: 0.05s; }
.hero-headline { animation-delay: 0.12s; }
.hero-sub { animation-delay: 0.19s; }
.hero-features { animation-delay: 0.26s; }
.hero-version { animation-delay: 0.33s; }
.auth-card-header { animation-delay: 0.15s; }
.splash .auth-tabs { animation-delay: 0.22s; }
#authLoginForm { animation-delay: 0.29s; }
.splash .auth-actions { animation-delay: 0.36s; }
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
/* Splash responsive */
@media (max-width: 860px) {
.splash { flex-direction: column; }
.splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; }
.hero-headline { font-size: 1.8rem; }
.hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; }
.hero-version { margin-top: 1.5rem; }
.splash-auth { width: 100%; border-left: none; border-top: 1px solid var(--border); padding: 2rem 1.75rem; }
.auth-card { max-width: 400px; }
}
@media (max-width: 480px) {
.splash-hero { padding: 2rem 1.25rem 1.5rem; }
.hero-logo-row { gap: 10px; margin-bottom: 1rem; }
.hero-wordmark { font-size: 1.3rem; }
.hero-headline { font-size: 1.5rem; }
.hero-sub { font-size: 0.88rem; }
.hero-features { gap: 6px; }
.hero-pill { padding: 5px 10px; font-size: 0.72rem; }
.splash-auth { padding: 1.5rem 1.25rem; }
}
/* ── Forms ────────────────────────────────── */ /* ── Forms ────────────────────────────────── */
.form-group { margin-bottom: 0.75rem; } .form-group { margin-bottom: 0.75rem; }
.form-group label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; } .form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; }
.form-group input, .form-group select, .form-group textarea { .form-group input, .form-group select, .form-group textarea {
width: 100%; background: var(--bg-raised); border: 1px solid var(--border); width: 100%; background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text); padding: 8px 12px; border-radius: var(--radius); color: var(--text); padding: 8px 12px; border-radius: var(--radius);
@@ -454,7 +686,7 @@ button { font-family: var(--font); cursor: pointer; }
.form-row { display: flex; gap: 0.75rem; } .form-row { display: flex; gap: 0.75rem; }
.form-row .form-group { flex: 1; } .form-row .form-group { flex: 1; }
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; } .form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); } .checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
/* ── Modal ────────────────────────────────── */ /* ── Modal ────────────────────────────────── */
@@ -478,7 +710,7 @@ button { font-family: var(--font); cursor: pointer; }
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border); padding: 16px 20px; border-bottom: 1px solid var(--border);
} }
.modal-header h2 { font-size: 15px; font-weight: 600; } .modal-header h2 { font-size: 16px; font-weight: 600; }
.modal-close { .modal-close {
background: none; border: none; color: var(--text-3); font-size: 18px; background: none; border: none; color: var(--text-3); font-size: 18px;
cursor: pointer; padding: 2px 6px; border-radius: 4px; cursor: pointer; padding: 2px 6px; border-radius: 4px;
@@ -493,7 +725,25 @@ button { font-family: var(--font); cursor: pointer; }
.settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); } .settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
.settings-section:last-child { border-bottom: none; margin-bottom: 0; } .settings-section:last-child { border-bottom: none; margin-bottom: 0; }
.settings-section h3 { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; } .settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
/* Settings tabs */
.settings-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); gap: 0; }
.settings-tabs .settings-tab {
padding: 10px 14px; background: none; border: none; border-radius: 0;
color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font);
border-bottom: 2px solid transparent; transition: color var(--transition);
outline: none; -webkit-appearance: none; appearance: none;
}
.settings-tabs .settings-tab:hover { color: var(--text-2); }
.settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.settings-notice {
background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.2);
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
color: var(--warning); display: flex; align-items: center; gap: 8px;
margin-top: 1rem;
}
/* Providers */ /* Providers */
.provider-row { .provider-row {
@@ -508,22 +758,95 @@ button { font-family: var(--font); cursor: pointer; }
/* Admin tabs */ /* Admin tabs */
.admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); } .admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); }
.admin-tab { .admin-tab {
padding: 8px 12px; background: none; border: none; color: var(--text-3); padding: 10px 14px; background: none; border: none; color: var(--text-3);
cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
} }
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.admin-user-row { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; } .admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; }
.admin-user-email { font-size: 11px; color: var(--text-3); } .admin-hint { font-size: 12px; color: var(--text-3); }
.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; } .admin-inline-form {
.admin-model-row .model-caps-inline { display: flex; gap: 3px; margin-left: auto; } background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
.admin-model-row .provider-meta { min-width: 80px; text-align: right; } padding: 1rem; margin-bottom: 1rem;
}
.admin-user-row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px;
min-height: 52px;
}
.admin-user-info { flex: 1; }
.admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; }
.admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; }
.admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); }
.admin-user-actions .btn-danger { color: var(--danger); }
.admin-user-actions .btn-danger:hover { border-color: var(--danger); }
.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; }
.admin-model-row .model-name { flex: 1; font-weight: 500; }
.admin-model-row .model-caps-inline { display: flex; gap: 3px; }
.admin-model-row .provider-meta { min-width: 80px; text-align: right; color: var(--text-3); font-size: 12px; }
.admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; }
.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); }
.admin-model-toggle.enabled { border-color: var(--success); color: var(--success); }
.admin-provider-row {
display: flex; align-items: center; gap: 12px; padding: 10px 0;
border-bottom: 1px solid var(--border); font-size: 14px;
}
.admin-provider-row .provider-name { font-weight: 500; flex: 1; }
.admin-provider-row .provider-endpoint { font-size: 12px; color: var(--text-3); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
.admin-provider-row .btn-delete:hover { color: var(--danger); }
/* Stats cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
.stat-card {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px; text-align: center;
}
.stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; }
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
/* Banner preview */
.banner-preview {
margin-top: 0.75rem; padding: 6px 16px; text-align: center;
font-size: 12px; font-weight: 700; letter-spacing: 0.06em;
border-radius: var(--radius);
}
.color-input-wrap { display: flex; align-items: center; gap: 8px; }
.color-input-wrap input[type="color"] {
width: 36px; height: 32px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg);
cursor: pointer; padding: 2px;
}
.color-hex {
width: 80px; background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text); padding: 6px 10px; border-radius: var(--radius);
font-family: var(--mono); font-size: 12px;
}
.section-hint { font-size: 13px; color: var(--text-3); margin-bottom: 0.75rem; }
/* User model list */
.model-list-grid { display: flex; flex-direction: column; gap: 2px; }
.model-list-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius); font-size: 13px; cursor: default;
}
.model-list-item:hover { background: var(--bg-hover); }
.model-list-item .model-name { flex: 1; font-weight: 500; }
.model-list-item .model-provider { font-size: 12px; color: var(--text-3); }
.model-list-item .model-caps-inline { display: flex; gap: 3px; }
.badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } .badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; } .badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } .badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.loading { color: var(--text-3); font-size: 12px; padding: 0.5rem; } .badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.error-hint { color: var(--danger); font-size: 12px; padding: 0.5rem; } .admin-user-row.user-inactive { opacity: 0.7; }
.empty-hint { color: var(--text-3); font-size: 12px; text-align: center; padding: 1rem; } .loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }
/* ── Toast ────────────────────────────────── */ /* ── Toast ────────────────────────────────── */

View File

@@ -3,17 +3,23 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="%%BASE_HREF%%">
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
<title>Chat Switchboard</title> <title>Chat Switchboard</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg"> <link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png"> <link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png"> <link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<link rel="stylesheet" href="css/styles.css?v=0.5.4"> <link rel="stylesheet" href="css/styles.css?v=0.6.2">
</head> </head>
<body> <body>
<!-- ── App Shell ───────────────────────────── --> <!-- ── App Shell ───────────────────────────── -->
<div id="appContainer" class="app" style="display:none"> <div id="appContainer" class="app" style="display:none">
<!-- Environment Banner (top) -->
<div class="banner banner-top" id="bannerTop"></div>
<div class="app-body">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-top"> <div class="sidebar-top">
@@ -22,10 +28,29 @@
<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> <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">Chat Switchboard</span> <span class="sb-label brand-text">Chat Switchboard</span>
</button> </button>
<button class="sb-btn" id="newChatBtn" title="New Chat"> <div class="split-btn" style="position:relative">
<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> <button class="split-btn-main" id="newChatBtn" title="New Chat">
<span class="sb-label">New Chat</span> <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>
</button> <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>
<div class="sidebar-chats" id="chatHistory"></div> <div class="sidebar-chats" id="chatHistory"></div>
@@ -95,34 +120,68 @@
</div> </div>
</div> </div>
</main> </main>
</div><!-- .app-body -->
<!-- Environment Banner (bottom) -->
<div class="banner banner-bottom" id="bannerBottom"></div>
</div> </div>
<!-- ── Auth Splash ─────────────────────────── --> <!-- ── Auth Splash ─────────────────────────── -->
<div class="splash" id="splashGate"> <div class="splash" id="splashGate">
<div class="splash-card"> <div class="splash-hero">
<div class="splash-brand"> <div class="hero-content">
<div class="splash-logo">🔀</div> <div class="hero-logo-row">
<h1>Chat Switchboard</h1> <svg class="hero-logo-mark" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<p>Multi-Model AI Chat</p> <rect width="64" height="64" rx="12" fill="#1a1a2e"/>
<path d="M12 48 L36 16" stroke="#6c9fff" stroke-width="5.5" stroke-linecap="round" fill="none"/>
<polygon points="42,12 34,14 38,22" fill="#6c9fff"/>
<path d="M12 16 L24 30" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
<path d="M30 38 L36 48" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
<polygon points="42,52 34,50 38,42" fill="#a78bfa"/>
</svg>
<div class="hero-wordmark">Chat <span>Switchboard</span></div>
</div>
<h1 class="hero-headline">One interface.<br>Every AI model.</h1>
<p class="hero-sub">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">
<div class="hero-pill accent"><span class="pill-icon"></span> Streaming responses</div>
<div class="hero-pill purple"><span class="pill-icon">🔀</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">v0.6.2</div>
</div> </div>
<div class="auth-tabs"> </div>
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button> <div class="splash-auth">
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button> <div class="auth-card">
</div> <div class="auth-card-header">
<div id="authLoginForm"> <h2>Welcome back</h2>
<div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username"></div> <p>Sign in to continue to your workspace</p>
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" onkeydown="if(event.key==='Enter')handleLogin()"></div> </div>
</div> <div class="auth-tabs">
<div id="authRegisterForm" style="display:none"> <button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
<div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username"></div> <button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email"></div> </div>
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" onkeydown="if(event.key==='Enter')handleRegister()"></div> <div id="authLoginForm">
</div> <div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username" placeholder="you@example.com"></div>
<div class="auth-error" id="authError"></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 class="splash-error" id="splashError"></div> </div>
<div class="auth-actions"> <div id="authRegisterForm" style="display:none">
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button> <div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username" placeholder="Choose a username"></div>
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button> <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>Self-hosted AI chat &middot; Your keys, your data</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -131,44 +190,62 @@
<div class="modal-overlay" id="settingsModal"> <div class="modal-overlay" id="settingsModal">
<div class="modal"> <div class="modal">
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn"></button></div> <div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn"></button></div>
<div class="settings-tabs">
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
</div>
<div class="modal-body"> <div class="modal-body">
<section class="settings-section"> <!-- General Tab -->
<h3>Profile</h3> <div class="settings-tab-content" id="settingsGeneralTab">
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div> <section class="settings-section">
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div> <h3>Profile</h3>
<button class="btn-small" id="profileChangePwBtn">Change Password</button> <div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
<div id="profileChangePwForm" style="display:none"> <div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
<div class="form-group"><label>Current Password</label><input type="password" id="profileCurrentPw"></div> <button class="btn-small" id="profileChangePwBtn">Change Password</button>
<div class="form-group"><label>New Password</label><input type="password" id="profileNewPw"></div> <div id="profileChangePwForm" style="display:none">
<button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button> <div class="form-group"><label>Current Password</label><input type="password" id="profileCurrentPw"></div>
</div> <div class="form-group"><label>New Password</label><input type="password" id="profileNewPw"></div>
</section> <button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
<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>
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></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> Show thinking blocks</label>
</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 class="form-group"><label>Name</label><input type="text" id="providerName" placeholder="My OpenAI Key"></div>
<div class="form-group"><label>Provider</label><select id="providerType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
<div class="form-group"><label>Endpoint</label><input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-group"><label>API Key</label><input type="password" id="providerApiKey" placeholder="sk-..."></div>
<div class="form-group"><label>Default Model</label><input type="text" id="providerDefaultModel" placeholder="gpt-4o"></div>
<div class="form-row"><button class="btn-small btn-primary" id="providerCreateBtn">Save</button><button class="btn-small" id="providerCancelBtn">Cancel</button></div>
</div>
</section>
<div id="userProvidersDisabled" class="settings-notice" style="display:none">
<span>⚠️</span> User-configured providers have been disabled by the administrator.
</div> </div>
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label> </div>
</section> <!-- Models Tab -->
<section class="settings-section"> <div class="settings-tab-content" id="settingsModelsTab" style="display:none">
<h3>API Providers</h3> <p class="section-hint">Models available from your providers and global configs.</p>
<div id="providerList"></div> <div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button> </div>
<div id="providerAddForm" style="display:none">
<div class="form-group"><label>Name</label><input type="text" id="providerName" placeholder="My OpenAI Key"></div>
<div class="form-group"><label>Provider</label><select id="providerType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
<div class="form-group"><label>Endpoint</label><input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-group"><label>API Key</label><input type="password" id="providerApiKey" placeholder="sk-..."></div>
<div class="form-group"><label>Default Model</label><input type="text" id="providerDefaultModel" placeholder="gpt-4o"></div>
<div class="form-row"><button class="btn-small btn-primary" id="providerCreateBtn">Save</button><button class="btn-small" id="providerCancelBtn">Cancel</button></div>
</div>
</section>
</div> </div>
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div> <div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
</div> </div>
@@ -186,12 +263,89 @@
<button class="admin-tab" data-tab="stats">Stats</button> <button class="admin-tab" data-tab="stats">Stats</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="admin-tab-content" id="adminUsersTab"><div id="adminUserList"></div></div> <div class="admin-tab-content" id="adminUsersTab">
<div class="admin-tab-content" id="adminProvidersTab" style="display:none"><div id="adminProviderList"></div></div> <div class="admin-toolbar">
<div class="admin-tab-content" id="adminModelsTab" style="display:none"><div id="adminModelList"></div></div> <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 class="form-row">
<div class="form-group"><label>Name</label><input type="text" id="adminProvName" placeholder="OpenAI Production"></div>
<div class="form-group"><label>Provider</label><select id="adminProvType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
</div>
<div class="form-group"><label>Endpoint</label><input type="text" id="adminProvEndpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-row">
<div class="form-group"><label>API Key</label><input type="password" id="adminProvKey" placeholder="sk-..."></div>
<div class="form-group"><label>Default Model</label><input type="text" id="adminProvModel" placeholder="gpt-4o"></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateProvBtn">Save</button><button class="btn-small" id="adminCancelProvBtn">Cancel</button></div>
</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="bulkToggleModels(true)">Enable All</button>
<button class="btn-small" onclick="bulkToggleModels(false)">Disable All</button>
<span class="admin-hint" id="adminModelsHint"></span>
</div>
<div id="adminModelList"></div>
</div>
<div class="admin-tab-content" id="adminSettingsTab" style="display:none"> <div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow registration</label> <section class="settings-section">
<button class="btn-primary btn-small" id="adminSaveSettings">Save</button> <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>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>
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div> </div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div> <div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
</div> </div>
@@ -235,10 +389,10 @@
<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/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="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=0.5.4"></script> <script src="js/debug.js?v=0.6.2"></script>
<script src="js/events.js?v=0.5.4"></script> <script src="js/events.js?v=0.6.2"></script>
<script src="js/api.js?v=0.5.4"></script> <script src="js/api.js?v=0.6.2"></script>
<script src="js/ui.js?v=0.5.4"></script> <script src="js/ui.js?v=0.6.2"></script>
<script src="js/app.js?v=0.5.4"></script> <script src="js/app.js?v=0.6.2"></script>
</body> </body>
</html> </html>

View File

@@ -1,10 +1,17 @@
// ========================================== // ==========================================
// Chat Switchboard API Client (v0.5.0) // Chat Switchboard API Client (v0.6.2)
// ========================================== // ==========================================
// Backend-only mode. Handles auth tokens and // Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback. // 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 = { const API = {
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
@@ -15,7 +22,7 @@ const API = {
loadTokens() { loadTokens() {
try { try {
const saved = JSON.parse(localStorage.getItem('sb_auth') || 'null'); const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
if (saved) { if (saved) {
this.accessToken = saved.accessToken || null; this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null; this.refreshToken = saved.refreshToken || null;
@@ -25,7 +32,7 @@ const API = {
}, },
saveTokens() { saveTokens() {
localStorage.setItem('sb_auth', JSON.stringify({ localStorage.setItem(_storageKey, JSON.stringify({
accessToken: this.accessToken, accessToken: this.accessToken,
refreshToken: this.refreshToken, refreshToken: this.refreshToken,
user: this.user user: this.user
@@ -36,7 +43,7 @@ const API = {
this.accessToken = null; this.accessToken = null;
this.refreshToken = null; this.refreshToken = null;
this.user = null; this.user = null;
localStorage.removeItem('sb_auth'); localStorage.removeItem(_storageKey);
}, },
get isAuthed() { return !!this.accessToken; }, get isAuthed() { return !!this.accessToken; },
@@ -45,7 +52,7 @@ const API = {
// ── Auth ───────────────────────────────── // ── Auth ─────────────────────────────────
async health() { async health() {
const resp = await fetch('/api/v1/health', { signal: AbortSignal.timeout(8000) }); const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`); if (!resp.ok) throw new Error(`Health: ${resp.status}`);
return resp.json(); return resp.json();
}, },
@@ -58,7 +65,7 @@ const API = {
async register(username, email, password) { async register(username, email, password) {
const data = await this._post('/api/v1/auth/register', { username, email, password }, true); const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
this._setAuth(data); if (!data.pending) this._setAuth(data);
return data; return data;
}, },
@@ -92,36 +99,39 @@ const API = {
this.saveTokens(); this.saveTokens();
}, },
// ── Chats ──────────────────────────────── // ── Channels ──────────────────────────────
listChats(page = 1, perPage = 100) { listChannels(page = 1, perPage = 100, type = '') {
return this._get(`/api/v1/chats?page=${page}&per_page=${perPage}`); let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
if (type) url += `&type=${type}`;
return this._get(url);
}, },
createChat(title, model, systemPrompt) { createChannel(title, model, systemPrompt, type = 'direct') {
const body = { title }; const body = { title, type };
if (model) body.model = model; if (model) body.model = model;
if (systemPrompt) body.system_prompt = systemPrompt; if (systemPrompt) body.system_prompt = systemPrompt;
return this._post('/api/v1/chats', body); return this._post('/api/v1/channels', body);
}, },
getChat(id) { return this._get(`/api/v1/chats/${id}`); }, getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
updateChat(id, updates) { return this._put(`/api/v1/chats/${id}`, updates); }, updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
deleteChat(id) { return this._del(`/api/v1/chats/${id}`); }, deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
// ── Messages ───────────────────────────── // ── Messages ─────────────────────────────
listMessages(chatId, page = 1, perPage = 200) { listMessages(channelId, page = 1, perPage = 200) {
return this._get(`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`); return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
}, },
// ── Completions ────────────────────────── // ── Completions ──────────────────────────
async streamCompletion(chatId, content, model, signal) { async streamCompletion(channelId, content, model, signal, apiConfigId) {
const body = { chat_id: chatId, content, stream: true }; const body = { channel_id: channelId, content, stream: true };
if (model) body.model = model; if (model) body.model = model;
if (apiConfigId) body.api_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override) // Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens; if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
let resp = await fetch('/api/v1/chat/completions', { let resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: this._authHeaders(), headers: this._authHeaders(),
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -130,7 +140,7 @@ const API = {
if (resp.status === 401 && this.refreshToken) { if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) { if (await this.refresh()) {
resp = await fetch('/api/v1/chat/completions', { resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: this._authHeaders(), headers: this._authHeaders(),
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -182,6 +192,7 @@ const API = {
adminToggleActive(id, active) { return this._put(`/api/v1/admin/users/${id}/active`, { is_active: active }); }, adminToggleActive(id, active) { return this._put(`/api/v1/admin/users/${id}/active`, { is_active: active }); },
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); }, adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
adminGetSettings() { return this._get('/api/v1/admin/settings'); }, 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); }, adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
adminGetStats() { return this._get('/api/v1/admin/stats'); }, adminGetStats() { return this._get('/api/v1/admin/stats'); },
@@ -196,8 +207,12 @@ const API = {
adminListModels() { return this._get('/api/v1/admin/models'); }, adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); }, adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); }, adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminBulkUpdateModels(isEnabled) { return this._put('/api/v1/admin/models/bulk', { is_enabled: isEnabled }); },
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); }, adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── User Models ──────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
// ── HTTP Internals ─────────────────────── // ── HTTP Internals ───────────────────────
_authHeaders() { _authHeaders() {
@@ -230,7 +245,7 @@ const API = {
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`; if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
if (body) opts.body = JSON.stringify(body); if (body) opts.body = JSON.stringify(body);
const resp = await fetch(path, opts); const resp = await fetch(BASE + path, opts);
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`); const err = new Error(data.error || `HTTP ${resp.status}`);

View File

@@ -1,5 +1,5 @@
// ========================================== // ==========================================
// Chat Switchboard Application (v0.5.4) // Chat Switchboard Application (v0.6.1)
// ========================================== // ==========================================
const App = { const App = {
@@ -8,6 +8,7 @@ const App = {
models: [], models: [],
isGenerating: false, isGenerating: false,
abortController: null, abortController: null,
serverSettings: {},
settings: { settings: {
model: '', model: '',
@@ -59,6 +60,7 @@ async function startApp() {
await loadSettings(); await loadSettings();
await loadChats(); await loadChats();
await fetchModels(); await fetchModels();
await initBanners();
UI.renderChatList(); UI.renderChatList();
UI.updateModelSelector(); UI.updateModelSelector();
UI.updateUser(); UI.updateUser();
@@ -67,7 +69,7 @@ async function startApp() {
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet) // Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
try { try {
Events.connect('/ws'); Events.connect((window.__BASE__ || '') + '/ws');
} catch (e) { } catch (e) {
console.warn('EventBus WebSocket not available:', e.message); console.warn('EventBus WebSocket not available:', e.message);
} }
@@ -206,10 +208,11 @@ async function fetchModels() {
async function loadChats() { async function loadChats() {
try { try {
const resp = await API.listChats(); const resp = await API.listChannels(1, 100, 'direct');
App.chats = (resp.data || []).map(c => ({ App.chats = (resp.data || []).map(c => ({
id: c.id, id: c.id,
title: c.title, title: c.title,
type: c.type || 'direct',
model: c.model || '', model: c.model || '',
messageCount: c.message_count || 0, messageCount: c.message_count || 0,
messages: [], messages: [],
@@ -255,7 +258,7 @@ async function newChat() {
async function deleteChat(chatId) { async function deleteChat(chatId) {
if (!confirm('Delete this chat?')) return; if (!confirm('Delete this chat?')) return;
try { try {
await API.deleteChat(chatId); await API.deleteChannel(chatId);
App.chats = App.chats.filter(c => c.id !== chatId); App.chats = App.chats.filter(c => c.id !== chatId);
if (App.currentChatId === chatId) newChat(); if (App.currentChatId === chatId) newChat();
UI.renderChatList(); UI.renderChatList();
@@ -277,8 +280,8 @@ async function sendMessage() {
if (!App.currentChatId) { if (!App.currentChatId) {
try { try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : ''); const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChat(title, model, App.settings.systemPrompt); const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = { id: resp.id, title: resp.title, model, messages: [], messageCount: 0, updatedAt: resp.updated_at }; const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
App.chats.unshift(chat); App.chats.unshift(chat);
App.currentChatId = chat.id; App.currentChatId = chat.id;
UI.renderChatList(); UI.renderChatList();
@@ -295,8 +298,9 @@ async function sendMessage() {
App.abortController = new AbortController(); App.abortController = new AbortController();
UI.setGenerating(true); UI.setGenerating(true);
const modelInfo = App.models.find(m => m.id === model);
try { try {
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal); const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId);
const assistantContent = await UI.streamResponse(resp, chat.messages); const assistantContent = await UI.streamResponse(resp, chat.messages);
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() }); chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
chat.messageCount = chat.messages.length; chat.messageCount = chat.messages.length;
@@ -340,12 +344,13 @@ async function regenerate() {
if (lastUser.role !== 'user') return; if (lastUser.role !== 'user') return;
const model = document.getElementById('modelSelect').value || App.settings.model; const model = document.getElementById('modelSelect').value || App.settings.model;
const modelInfo = App.models.find(m => m.id === model);
App.isGenerating = true; App.isGenerating = true;
App.abortController = new AbortController(); App.abortController = new AbortController();
UI.setGenerating(true); UI.setGenerating(true);
try { try {
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal); const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId);
const content = await UI.streamResponse(resp, chat.messages); const content = await UI.streamResponse(resp, chat.messages);
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() }); chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
UI.renderMessages(chat.messages); UI.renderMessages(chat.messages);
@@ -396,7 +401,18 @@ async function handleRegister() {
if (!username || !email || !password) return setAuthError('Fill in all fields'); if (!username || !email || !password) return setAuthError('Fill in all fields');
if (password.length < 8) return setAuthError('Password must be at least 8 characters'); if (password.length < 8) return setAuthError('Password must be at least 8 characters');
setAuthLoading(true); setAuthLoading(true);
try { await API.register(username, email, password); await startApp(); } try {
const resp = await API.register(username, email, password);
if (resp.pending) {
setAuthError('');
const errEl = document.getElementById('authError');
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
errEl.style.color = 'var(--accent)';
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
} else {
await startApp();
}
}
catch (e) { setAuthError(e.message); } catch (e) { setAuthError(e.message); }
finally { setAuthLoading(false); } finally { setAuthLoading(false); }
} }
@@ -418,6 +434,13 @@ function switchAuthTab(tab) {
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none'; document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none'; document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none'; document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
const hdr = document.querySelector('.auth-card-header');
if (hdr) {
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
hdr.querySelector('p').textContent = tab === 'login'
? 'Sign in to continue to your workspace'
: 'Get started with Chat Switchboard';
}
setAuthError(''); setAuthError('');
} }
@@ -440,6 +463,17 @@ function initListeners() {
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar); document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', newChat); 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 // User flyout
document.getElementById('userMenuBtn').addEventListener('click', (e) => { document.getElementById('userMenuBtn').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -451,8 +485,8 @@ function initListeners() {
// Flyout items // Flyout items
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); }); document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
document.getElementById('menuAdmin').addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); }); document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
document.getElementById('menuDebug').addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); }); document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); }); document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Chat actions // Chat actions
@@ -465,7 +499,7 @@ function initListeners() {
if (this.value) { App.settings.model = this.value; saveSettings(); } if (this.value) { App.settings.model = this.value; saveSettings(); }
UI.updateCapabilityBadges(); UI.updateCapabilityBadges();
}); });
document.getElementById('fetchModelsBtn').addEventListener('click', async () => { document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModels(); await fetchModels();
UI.toast(`Loaded ${App.models.length} models`, 'success'); UI.toast(`Loaded ${App.models.length} models`, 'success');
}); });
@@ -473,6 +507,9 @@ function initListeners() {
// Settings modal // Settings modal
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings); document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings); document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
});
document.getElementById('settingsModel').addEventListener('change', function() { document.getElementById('settingsModel').addEventListener('change', function() {
const model = App.models.find(m => m.id === this.value); const model = App.models.find(m => m.id === this.value);
const caps = model?.capabilities || lookupKnownCaps(this.value) || {}; const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
@@ -503,12 +540,55 @@ function initListeners() {
} }
}); });
// Admin modal // Admin modal (elements may not exist for non-admin users)
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin); document.getElementById('adminCloseBtn')?.addEventListener('click', UI.closeAdmin);
document.querySelectorAll('.admin-tab').forEach(tab => { document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab)); tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
}); });
document.getElementById('adminSaveSettings').addEventListener('click', handleSaveAdminSettings); document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
// Admin — users
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
const f = document.getElementById('adminAddUserForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
// Admin — providers
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
const f = document.getElementById('adminAddProviderForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => { document.getElementById('adminAddProviderForm').style.display = 'none'; });
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
// Admin — models
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
// Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
});
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
const preset = UI._bannerPresets[e.target.value];
if (preset) {
document.getElementById('adminBannerText').value = preset.text || '';
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
document.getElementById('adminBannerFg').value = preset.fg || '#ffffff';
document.getElementById('adminBannerFgHex').value = preset.fg || '#ffffff';
UI.updateBannerPreview();
}
});
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
});
// Sync color pickers with hex inputs
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
// Input // Input
const input = document.getElementById('messageInput'); const input = document.getElementById('messageInput');
@@ -590,11 +670,168 @@ async function deleteProvider(id, name) {
async function handleSaveAdminSettings() { async function handleSaveAdminSettings() {
try { try {
// Registration
const reg = document.getElementById('adminRegToggle').checked; const reg = document.getElementById('adminRegToggle').checked;
await API.adminUpdateSetting('registration_enabled', { value: reg }); await API.adminUpdateSetting('registration_enabled', { value: reg });
UI.toast('Admin settings saved', 'success');
// Registration default state
const regState = document.getElementById('adminRegDefaultState').value;
await API.adminUpdateSetting('registration_default_state', { value: regState });
// User providers
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
await API.adminUpdateSetting('user_providers_enabled', { value: userProviders });
// Banner
const banner = {
enabled: document.getElementById('adminBannerEnabled').checked,
text: document.getElementById('adminBannerText').value,
position: document.getElementById('adminBannerPosition').value,
bg: document.getElementById('adminBannerBg').value,
fg: document.getElementById('adminBannerFg').value,
};
await API.adminUpdateSetting('banner', { value: banner });
UI.toast('Settings saved', 'success');
// Live-apply banner
initBanners();
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
} }
// ── Admin Actions ────────────────────────────
function _adminScroll() { return document.querySelector('#adminModal .modal-body'); }
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
async function toggleUserActive(id, active) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function toggleUserRole(id, role) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
catch (e) { UI.toast(e.message, 'error'); }
}
async function createAdminUser() {
try {
const u = document.getElementById('adminNewUsername').value.trim();
const e = document.getElementById('adminNewEmail').value.trim();
const p = document.getElementById('adminNewPassword').value;
const r = document.getElementById('adminNewRole').value;
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
await API.adminCreateUser(u, e, p, r);
document.getElementById('adminAddUserForm').style.display = 'none';
UI.toast('User created', 'success');
await UI.loadAdminUsers();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteGlobalProvider(id) {
if (!confirm('Delete this global provider?')) return;
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
catch (e) { UI.toast(e.message, 'error'); }
}
async function createGlobalProvider() {
try {
const name = document.getElementById('adminProvName').value.trim();
const prov = document.getElementById('adminProvType').value;
const ep = document.getElementById('adminProvEndpoint').value.trim();
const key = document.getElementById('adminProvKey').value;
const model = document.getElementById('adminProvModel').value.trim();
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(name, prov, ep, key, model);
document.getElementById('adminAddProviderForm').style.display = 'none';
UI.toast('Provider added', 'success');
await UI.loadAdminProviders();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function fetchAdminModels() {
const hint = document.getElementById('adminModelsHint');
hint.textContent = 'Fetching...';
try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); }
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
async function toggleModel(id, enabled) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdateModel(id, { is_enabled: enabled });
await UI.loadAdminModels();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function bulkToggleModels(enabled) {
const hint = document.getElementById('adminModelsHint');
hint.textContent = enabled ? 'Enabling all...' : 'Disabling all...';
try {
const resp = await API.adminBulkUpdateModels(enabled);
hint.textContent = `${resp.count || 'All'} models ${enabled ? 'enabled' : 'disabled'}`;
setTimeout(() => { hint.textContent = ''; }, 3000);
await UI.loadAdminModels();
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── Banners ──────────────────────────────────
async function initBanners() {
try {
const data = await API.getPublicSettings?.() || [];
const settings = data.settings || data || [];
const arr = Array.isArray(settings) ? settings : [];
// Store globally for user-facing checks — unwrap {value: X} wrapper
App.serverSettings = {};
arr.forEach(s => {
const v = s.value;
App.serverSettings[s.key] = (v && typeof v === 'object' && 'value' in v) ? v.value : v;
});
const root = document.documentElement;
const banner = App.serverSettings.banner;
// Clear previous banner state
['bannerTop', 'bannerBottom'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.classList.remove('active'); el.textContent = ''; }
});
root.style.setProperty('--banner-top-height', '0px');
root.style.setProperty('--banner-bottom-height', '0px');
if (!banner || !banner.enabled) return;
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
const text = banner.text || '';
const pos = banner.position || 'both';
if (pos === 'top' || pos === 'both') {
const el = document.getElementById('bannerTop');
el.textContent = text;
el.classList.add('active');
root.style.setProperty('--banner-top-height', '22px');
}
if (pos === 'bottom' || pos === 'both') {
const el = document.getElementById('bannerBottom');
el.textContent = text;
el.classList.add('active');
root.style.setProperty('--banner-bottom-height', '22px');
}
} catch (e) {
// Banners are optional — non-admin users may not have access to settings
console.debug('Banner init skipped:', e.message);
}
}
// ── Boot ───────────────────────────────────── // ── Boot ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);

View File

@@ -191,7 +191,7 @@ const Events = {
} }
// Add auth token as query param (WebSocket doesn't support headers) // Add auth token as query param (WebSocket doesn't support headers)
const tokens = JSON.parse(localStorage.getItem('sb_auth') || '{}'); const tokens = JSON.parse(localStorage.getItem(typeof _storageKey !== 'undefined' ? _storageKey : 'sb_auth') || '{}');
if (tokens.access) { if (tokens.access) {
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`; url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
} else { } else {

View File

@@ -1,5 +1,5 @@
// ========================================== // ==========================================
// Chat Switchboard UI (v0.5.4) // Chat Switchboard UI (v0.6.1)
// ========================================== // ==========================================
const UI = { const UI = {
@@ -337,11 +337,33 @@ const UI = {
} }
UI.loadProfileIntoSettings(); UI.loadProfileIntoSettings();
UI.loadProviderList(); UI.switchSettingsTab('general');
document.getElementById('settingsModal').classList.add('active'); document.getElementById('settingsModal').classList.add('active');
}, },
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); }, closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
switchSettingsTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'providers') {
UI.loadProviderList();
UI.checkUserProvidersAllowed();
}
if (tab === 'models') UI.loadUserModels();
},
async checkUserProvidersAllowed() {
const notice = document.getElementById('userProvidersDisabled');
const addBtn = document.getElementById('providerShowAddBtn');
if (!notice) return;
const allowed = App.serverSettings?.user_providers_enabled !== false;
notice.style.display = allowed ? 'none' : '';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
},
async loadProfileIntoSettings() { async loadProfileIntoSettings() {
try { try {
const p = await API.getProfile(); const p = await API.getProfile();
@@ -398,64 +420,173 @@ const UI = {
if (tab === 'stats') await this.loadAdminStats(); if (tab === 'stats') await this.loadAdminStats();
if (tab === 'providers') await this.loadAdminProviders(); if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels(); if (tab === 'models') await this.loadAdminModels();
if (tab === 'settings') await this.loadAdminSettings();
}, },
async loadAdminUsers() { async loadAdminUsers(quiet) {
const el = document.getElementById('adminUserList'); const el = document.getElementById('adminUserList');
el.innerHTML = '<div class="loading">Loading...</div>'; if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try { try {
const resp = await API.adminListUsers(); const resp = await API.adminListUsers();
const users = resp.data || []; const users = resp.data || [];
el.innerHTML = users.map(u => ` el.innerHTML = users.map(u => `
<div class="admin-user-row"> <div class="admin-user-row${!u.is_active ? ' user-inactive' : ''}">
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span> <div class="admin-user-info">
${!u.is_active ? '<span class="badge-inactive">disabled</span>' : ''}</div> <div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
<div class="admin-user-email">${esc(u.email)}</div> ${!u.is_active ? '<span class="badge-pending">pending</span>' : ''}</div>
<div class="admin-user-email">${esc(u.email)}</div>
</div>
<div class="admin-user-actions">
<button onclick="toggleUserActive('${u.id}', ${!u.is_active})">${u.is_active ? 'Disable' : 'Approve'}</button>
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
</div>
</div>`).join('') || '<div class="empty-hint">No users</div>'; </div>`).join('') || '<div class="empty-hint">No users</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
async loadAdminStats() { async loadAdminStats() {
const el = document.getElementById('adminStats'); const el = document.getElementById('adminStats');
try { const s = await API.adminGetStats(); el.innerHTML = `<pre>${esc(JSON.stringify(s, null, 2))}</pre>`; }
catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminProviders() {
const el = document.getElementById('adminProviderList');
el.innerHTML = '<div class="loading">Loading...</div>'; el.innerHTML = '<div class="loading">Loading...</div>';
try { try {
const data = await API.adminListGlobalConfigs(); const s = await API.adminGetStats();
const list = data.configs || data.data || data || []; const labels = { total_users: 'Users', active_users: 'Active Users', api_configs: 'Providers', total_channels: 'Channels', total_messages: 'Messages' };
el.innerHTML = (Array.isArray(list) ? list : []).map(c => ` el.innerHTML = '<div class="stats-grid">' +
<div class="provider-row"><span class="provider-name">${esc(c.name)}</span><span class="provider-meta">${esc(c.provider)}</span></div> Object.entries(s).map(([k, v]) => `
`).join('') || '<div class="empty-hint">No global providers</div>'; <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>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
async loadAdminModels() { async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(c => `
<div class="admin-provider-row">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)}</span>
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
</div>`).join('') || '<div class="empty-hint">No global providers — add one above</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminModels(quiet) {
const el = document.getElementById('adminModelList'); const el = document.getElementById('adminModelList');
el.innerHTML = '<div class="loading">Loading...</div>'; if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try { try {
const data = await API.adminListModels(); const data = await API.adminListModels();
const list = data.models || data.data || data || []; const list = data.models || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(m => { const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(m => {
const caps = m.capabilities || {}; const caps = m.capabilities || {};
const badges = []; const badges = [];
if (caps.max_output_tokens > 0) { if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K out</span>`);
}
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>'); if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>'); if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>'); if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent">🧠</span>');
return `<div class="admin-model-row"> return `<div class="admin-model-row">
<span>${esc(m.model_id || m.id)}</span> <span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span> <span class="model-caps-inline">${badges.join('')}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span> <span class="provider-meta">${esc(m.provider_name || '')}</span>
<span>${m.is_enabled ? '' : ''}</span> <button class="admin-model-toggle ${m.is_enabled ? 'enabled' : ''}" onclick="toggleModel('${m.id}', ${!m.is_enabled})">${m.is_enabled ? '✓ Enabled' : 'Disabled'}</button>
</div>`; </div>`;
}).join('') || '<div class="empty-hint">No models — use Fetch Models to sync from providers</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 loadAdminSettings() {
try {
const data = await API.adminGetSettings();
const settings = data.settings || data || [];
const arr = Array.isArray(settings) ? settings : [];
// Unwrap {value: X} wrapper from backend storage
const get = (key, fallback) => {
const s = arr.find(s => s.key === key);
if (!s) return fallback;
const v = s.value;
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
};
// Registration
document.getElementById('adminRegToggle').checked = get('registration_enabled', true) !== false;
// Registration default state
document.getElementById('adminRegDefaultState').value = get('registration_default_state', 'active') || 'active';
// User providers
document.getElementById('adminUserProvidersToggle').checked = get('user_providers_enabled', true) !== false;
// Banner
const banner = get('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 presets
const presets = get('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;
} 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;
},
// ── User Model List ─────────────────────
async loadUserModels() {
const el = document.getElementById('userModelList');
if (!el) return;
el.innerHTML = '<div class="loading">Loading models...</div>';
try {
const data = await API.listEnabledModels();
const models = data.models || [];
if (!models.length) {
el.innerHTML = '<div class="empty-hint">No models available — add a provider in the section above</div>';
return;
}
el.innerHTML = models.map(m => {
const caps = m.capabilities || {};
const badges = [];
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
return `<div class="model-list-item">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },