From 925b70f98c410ad14479f2d1cd4d009c6fcdd18d Mon Sep 17 00:00:00 2001 From: xcaliber Date: Fri, 20 Feb 2026 22:24:47 +0000 Subject: [PATCH] Changeset 0.6.0 (#36) --- .gitea/workflows/ci.yaml | 49 ++- ARCHITECTURE.md | 6 +- Dockerfile.frontend | 18 +- ROADMAP.md | 409 +++++++++++++----- VERSION | 2 +- docker-entrypoint-fe.sh | 109 +++++ k8s/backend.yaml | 32 +- k8s/frontend.yaml | 7 +- k8s/ingress.yaml | 29 +- scripts/db-validate.sh | 51 ++- server/config/config.go | 35 +- .../migrations/006_channels_unify.sql | 114 +++++ .../migrations/007_channel_members_models.sql | 56 +++ .../migrations/008_channel_cursors.sql | 29 ++ .../migrations/009_folders_projects.sql | 57 +++ server/database/migrations/010_banners.sql | 32 ++ .../migrations/011_banner_presets_fix.sql | 18 + server/go.sum | 40 ++ server/handlers/admin.go | 68 ++- server/handlers/apiconfigs_test.go | 4 +- server/handlers/auth.go | 110 ++++- server/handlers/{chats.go => channels.go} | 209 +++++---- .../{chats_test.go => channels_test.go} | 50 +-- server/handlers/completion.go | 99 +++-- server/handlers/messages.go | 85 ++-- server/main.go | 44 +- server/main_test.go | 42 +- server/models/models.go | 190 ++++---- src/css/styles.css | 399 +++++++++++++++-- src/index.html | 300 +++++++++---- src/js/api.js | 59 ++- src/js/app.js | 269 +++++++++++- src/js/events.js | 2 +- src/js/ui.js | 189 ++++++-- 34 files changed, 2575 insertions(+), 637 deletions(-) create mode 100644 docker-entrypoint-fe.sh create mode 100644 server/database/migrations/006_channels_unify.sql create mode 100644 server/database/migrations/007_channel_members_models.sql create mode 100644 server/database/migrations/008_channel_cursors.sql create mode 100644 server/database/migrations/009_folders_projects.sql create mode 100644 server/database/migrations/010_banners.sql create mode 100644 server/database/migrations/011_banner_presets_fix.sql create mode 100644 server/go.sum rename server/handlers/{chats.go => channels.go} (57%) rename server/handlers/{chats_test.go => channels_test.go} (76%) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f516661..35643ec 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,6 +1,6 @@ # .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. # Unified image is for Docker Hub only (docker-compose use). @@ -9,16 +9,17 @@ # 1. Go test (all PRs and pushes) # 2. Build + Deploy (depends on test passing) # -# Deployment mapping: -# PR → FE + BE :dev → dev.DOMAIN (DB wipe + fresh schema) -# Push to main → FE + BE :test → test.DOMAIN (migrate only) -# Tag v* → FE + BE :latest → www.DOMAIN (migrate only) -# → Unified :latest → Docker Hub (not deployed) +# Deployment mapping (single domain, path-based): +# PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema) +# Push to main → FE + BE :test → switchboard.DOMAIN/test/ (migrate only) +# Tag v* → FE + BE :latest → switchboard.DOMAIN/ (migrate only) +# → Unified :latest → Docker Hub (not deployed) # # Database lifecycle: # CI: bootstrap (admin creds) → create DB + role + extensions # CI: dev wipe (app creds) → drop tables for fresh install test # BE: auto-migrate on startup → schema_migrations tracking +# BE: bootstrap admin from SWITCHBOARD_ADMIN_* env vars (upsert on every restart) # # Shared PG safety: # - Bootstrap uses IF NOT EXISTS (idempotent) @@ -32,6 +33,7 @@ # Required Gitea Secrets: # POSTGRES_USER, POSTGRES_PASSWORD # POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD +# SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional) # # Global Variables (Gitea org-level): @@ -111,10 +113,15 @@ jobs: - name: Determine environment id: env 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 echo "ENVIRONMENT=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 "DB_NAME=chat_switchboard_dev" >> "$GITHUB_OUTPUT" echo "DB_WIPE=true" >> "$GITHUB_OUTPUT" @@ -134,7 +141,7 @@ jobs: echo "ENVIRONMENT=production" >> "$GITHUB_OUTPUT" echo "IMAGE_TAG=latest" >> "$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 "DB_NAME=chat_switchboard" >> "$GITHUB_OUTPUT" echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" @@ -153,7 +160,7 @@ jobs: else echo "ENVIRONMENT=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 "DB_NAME=chat_switchboard_test" >> "$GITHUB_OUTPUT" echo "DB_WIPE=false" >> "$GITHUB_OUTPUT" @@ -350,11 +357,24 @@ jobs: kubectl create namespace ${NAMESPACE} 2>/dev/null || true - 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: | kubectl create secret generic switchboard-db-credentials \ --namespace=${NAMESPACE} \ - --from-literal=POSTGRES_USER=${{ secrets.POSTGRES_USER }} \ - --from-literal=POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \ + --from-literal=POSTGRES_USER="${PG_USER}" \ + --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 - - name: Render and apply manifests @@ -363,6 +383,7 @@ jobs: IMAGE_TAG: ${{ steps.env.outputs.IMAGE_TAG }} DEPLOY_SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }} DEPLOY_HOST: ${{ steps.env.outputs.DEPLOY_HOST }} + BASE_PATH: ${{ steps.env.outputs.BASE_PATH }} DB_NAME: ${{ steps.env.outputs.DB_NAME }} FE_REPLICAS: ${{ steps.env.outputs.FE_REPLICAS }} BE_REPLICAS: ${{ steps.env.outputs.BE_REPLICAS }} @@ -415,7 +436,8 @@ jobs: run: | sleep 5 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}" # Extract fields (|| true prevents set -e from killing on no-match) @@ -432,11 +454,12 @@ jobs: - name: Summary run: | HOST="${{ steps.env.outputs.DEPLOY_HOST }}" + BP="${{ steps.env.outputs.BASE_PATH }}" echo "## ✅ Deployed to ${{ steps.env.outputs.env_label }}" >> $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 "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11a32ea..11e69e1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -179,7 +179,7 @@ authentication. Keycloak, Okta, Azure AD, etc. The backend is a relying party — it validates tokens against the IdP's JWKS 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 `100vh - banner_top - banner_bottom`. When no banners are 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. - 1:1 behavior unchanged (auto-complete rule). - 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 frontend. Small, zero-risk, and required before any 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 environment designation. When present, the content diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 774f0a8..0489064 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -5,6 +5,11 @@ # in k8s, the Ingress routes /api/ and /ws to # 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 # ========================================== @@ -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 && \ rm -rf /tmp/package /tmp/*.tgz -# Stage 2: nginx +# Stage 2: nginx + entrypoint 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 --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 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"] diff --git a/ROADMAP.md b/ROADMAP.md index f94f21a..cfe03c3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,177 +4,376 @@ - [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) -## Current State: v0.5.4 +**Versioning (pre-1.0):** `0..` — 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 **Backend (Go)** - [x] PostgreSQL schema + auto-migrations (go:embed, startup) - [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] Per-user + global API config management - [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] 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] Dynamic max_tokens resolution (no more hardcoded 4096) +- [x] Dynamic max_tokens resolution - [x] User + admin provider model listing with capabilities **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] User menu flyout (Settings, Admin, Debug, Sign Out) - [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] Full markdown rendering (marked.js + DOMPurify) +- [x] Full markdown rendering (marked.js + DOMPurify, vendor + CDN fallback) - [x] Thinking block display (``/`` 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] EventBus client with exponential backoff + max retries - [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)** - [x] Three-env pipeline (dev/test/prod) - [x] Dev: upgrade test → schema validate → wipe → fresh install test - [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] Post-deploy schema verification via /api/v1/health -**Architecture Design (v0.5.4)** +**Architecture Design** - [x] ARCHITECTURE.md: core services spec (13 sections) - [x] EXTENSIONS.md: three-tier extension system spec -- [x] Terminology: Roles, Teams, Channels, Message Tree, etc. -- [x] "Everything is a channel" — chat is a channel with type:'direct' -- [x] Conversation forking (message tree via parent_id) -- [x] Auth strategy (builtin/mTLS/OIDC) -- [x] Classification banners (CSS custom props) - -### Implementation Phasing - -**See ARCHITECTURE.md §10 for the authoritative implementation sequence.** -Phases 1–8, from channel foundation through RBAC. +- [x] "Everything is a channel" — unified model with type:'direct' +- [x] Message tree (parent_id for conversation forking) +- [x] Auth strategy design (builtin/mTLS/OIDC) --- -## Phase 1: Real-Time Foundation +## 0.7.0 — Custom Models / Presets -### 1.1 WebSocket Hub -**Priority:** HIGH — blocks Channels, typing indicators, live updates +Named wrappers around base models with bundled configuration. Admins create +org-wide presets, users create personal ones (if user providers are enabled). -- [ ] WebSocket upgrade endpoint (`/ws`) -- [ ] Connection manager (register, unregister, broadcast) -- [ ] Room-based pub/sub (per-chat, per-channel, global) -- [ ] JWT auth on WebSocket handshake -- [ ] Heartbeat / ping-pong keepalive -- [ ] Reconnection handling (frontend) -- [ ] Message types: `chat.message`, `chat.typing`, `user.presence`, `system.notify` +- [ ] `model_presets` table: name, base_model_id, system_prompt, temperature, + max_tokens, tools_enabled (jsonb), created_by, scope (global/team/personal), + team_id (nullable, for future team scoping), is_shared +- [ ] Permission gating: personal presets require user_providers_enabled +- [ ] Admin preset management UI (create, edit, delete org-wide presets) +- [ ] User preset management UI in Settings (personal presets) +- [ ] 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 -- [ ] New messages pushed via WebSocket (not just SSE streaming) -- [ ] Typing indicators -- [ ] Chat list updates when other sessions create/delete chats -- [ ] Online user presence +## 0.7.x — Branding + Polish + +White-label support and UX improvements that exploit existing infrastructure. + +**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 -- [ ] 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 +The missing middle tier: scoped administration without system-admin access. -### 2.2 Frontend -- [ ] Channels section in sidebar (below chats, collapsible) -- [ ] Channel creation modal (name, description, public/private) -- [ ] Member management (invite, remove, role change) -- [ ] Message display with user avatars and @mention highlighting -- [ ] AI responses inline with user messages -- [ ] Unread count badges +**Schema** +- [ ] `teams` table: id, name, description, created_by +- [ ] `team_members` table: team_id, user_id, role ('admin' | 'member') +- [ ] Add `team_id` (nullable) to: model_presets, channels, (future: notes, KBs) +- [ ] Team admin role: scoped per-team, not system-wide + (one person can be admin of Team A, member of Team B) + +**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) -- [ ] 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) +The tool-calling infrastructure that everything downstream depends on. -### 3.2 Built-in Plugin: Web Search -- [ ] Tool-use integration in completion flow -- [ ] Search provider abstraction (DuckDuckGo, SearXNG, Brave) -- [ ] Results injected into context +**Tool Framework** +- [ ] Tool calling pipeline in completion handler +- [ ] Tool registry (built-in + future plugin tools) +- [ ] 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 -- [ ] Per-message token estimation -- [ ] Per-chat cost tracking -- [ ] Provider-specific pricing data - ---- - -## Phase 4: Notes & Knowledge Base - -### 4.1 Notes -- [ ] Note model (title, content, folder, tags) -- [ ] CRUD endpoints -- [ ] Folder organization +**Notes** +- [ ] `notes` table: title, content, folder_id, tags, team_id (nullable), created_by +- [ ] Notes CRUD endpoints +- [ ] `note_create`, `note_update`, `note_search`, `note_list` tools - [ ] Full-text search (PostgreSQL `tsvector`) - [ ] Markdown editor in frontend -- [ ] Link notes to chats +- [ ] Team-scoped notes (schema ready from 0.8.0) -### 4.2 RAG (Retrieval Augmented Generation) -- [ ] Document upload (PDF, DOCX, TXT, MD) -- [ ] Chunking strategies (recursive, semantic) -- [ ] Embedding generation (OpenAI, local models) -- [ ] pgvector storage and similarity search +**Conversation Forking UI** +- [ ] Edit-and-resubmit creates siblings (tree structure from 0.6.0 schema) +- [ ] Branch indicator ← 1/2 → at fork points +- [ ] Context assembly follows active path + +## 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 -- [ ] 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 -- [ ] Message editing -- [ ] Chat folders / pinning -- [ ] Bulk chat operations -- [ ] Keyboard shortcuts (Ctrl+K command palette) -- [ ] PWA manifest + offline shell -- [ ] Performance: lazy load chat history, virtual scroll for long chats +Replaces the manual 0.9.x context management with automated background processing. + +- [ ] Auto-compaction service: background job that calls an LLM to summarize +- [ ] Channel-scoped: triggers when channel exceeds context threshold +- [ ] Compaction summaries stored as system messages in the channel +- [ ] Configurable: per-channel opt-in/out, summary model selection +- [ ] 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) -- Smart model routing (cost/quality/latency auto-selection) -- Workflow builder (visual DAG for chaining models + tools) -- Plugin marketplace -- SSO/SAML -- Audit logging +- Full PWA with offline capability +- Mobile-optimized layouts + +**Generation + Media** +- 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 +- 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:** -- [EXTENSIONS.md](EXTENSIONS.md) — Full extension system spec with three tiers - (Browser JS, Starlark sandbox, Sidecar containers), manifest format, - browser tool bridge, surface/mode system, and implementation roadmap. +**Deferred to post-1.0.** The tool execution framework (0.9.0) provides the +internal hook points. A formal plugin API, manifest format, and marketplace +are tracked in the dedicated design documents: + +- [EXTENSIONS.md](EXTENSIONS.md) — Three-tier extension system spec + (Browser JS, Starlark sandbox, Sidecar containers) - [ARCHITECTURE.md](ARCHITECTURE.md) — Core backend services that extensions - build on: Notes, Knowledge Bases, Tasks, Channels, Embeddings, and - Folders/Projects. Includes data models, sequencing, and admin controls. + build on diff --git a/VERSION b/VERSION index 7d85683..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.4 +0.6.0 diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh new file mode 100644 index 0000000..e354cf1 --- /dev/null +++ b/docker-entrypoint-fe.sh @@ -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 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 </dev/null || echo "none") echo " ✓ latest: ${LATEST}" -# ── 3. Core tables (001_full_schema) ───────── +# ── 3. Core tables (001 schema, renamed in 006) ─ echo "" echo "Core tables:" check_table "users" check_table "api_configs" -check_table "chats" -check_table "chat_messages" +check_table "channels" +check_table "messages" # Key columns check_column "users" "id" @@ -105,9 +105,11 @@ check_column "users" "role" check_column "api_configs" "user_id" check_column "api_configs" "provider" check_column "api_configs" "api_key_encrypted" -check_column "chats" "user_id" -check_column "chat_messages" "chat_id" -check_column "chat_messages" "role" +check_column "channels" "user_id" +check_column "channels" "type" +check_column "messages" "channel_id" +check_column "messages" "role" +check_column "messages" "parent_id" # ── 4. Refresh tokens (002) ───────────────── echo "" @@ -140,9 +142,44 @@ check_table "user_model_preferences" check_column "user_model_preferences" "user_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 -# When you add migration 006, add checks here. # ═══════════════════════════════════════════ # ── Summary ────────────────────────────────── diff --git a/server/config/config.go b/server/config/config.go index 211848b..a0c7a64 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -12,6 +12,12 @@ type Config struct { DatabaseURL string JWTSecret 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. @@ -21,13 +27,34 @@ func Load() *Config { _ = godotenv.Load() return &Config{ - Port: getEnv("PORT", "8080"), - DatabaseURL: getEnv("DATABASE_URL", ""), - JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"), - Environment: getEnv("ENVIRONMENT", "development"), + Port: getEnv("PORT", "8080"), + DatabaseURL: getEnv("DATABASE_URL", ""), + JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"), + 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 { if v := os.Getenv(key); v != "" { return v diff --git a/server/database/migrations/006_channels_unify.sql b/server/database/migrations/006_channels_unify.sql new file mode 100644 index 0000000..3bd46d6 --- /dev/null +++ b/server/database/migrations/006_channels_unify.sql @@ -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) diff --git a/server/database/migrations/007_channel_members_models.sql b/server/database/migrations/007_channel_members_models.sql new file mode 100644 index 0000000..9650886 --- /dev/null +++ b/server/database/migrations/007_channel_members_models.sql @@ -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; diff --git a/server/database/migrations/008_channel_cursors.sql b/server/database/migrations/008_channel_cursors.sql new file mode 100644 index 0000000..ad72e9c --- /dev/null +++ b/server/database/migrations/008_channel_cursors.sql @@ -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; diff --git a/server/database/migrations/009_folders_projects.sql b/server/database/migrations/009_folders_projects.sql new file mode 100644 index 0000000..6e56d80 --- /dev/null +++ b/server/database/migrations/009_folders_projects.sql @@ -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); diff --git a/server/database/migrations/010_banners.sql b/server/database/migrations/010_banners.sql new file mode 100644 index 0000000..dd2faff --- /dev/null +++ b/server/database/migrations/010_banners.sql @@ -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; diff --git a/server/database/migrations/011_banner_presets_fix.sql b/server/database/migrations/011_banner_presets_fix.sql new file mode 100644 index 0000000..3ac53a6 --- /dev/null +++ b/server/database/migrations/011_banner_presets_fix.sql @@ -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'; diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..1329769 --- /dev/null +++ b/server/go.sum @@ -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= diff --git a/server/handlers/admin.go b/server/handlers/admin.go index fc6e370..e30fade 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -278,6 +278,44 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { 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 ──────────────────── func (h *AdminHandler) ListGlobalSettings(c *gin.Context) { @@ -369,8 +407,8 @@ func (h *AdminHandler) GetStats(c *gin.Context) { queries := map[string]string{ "total_users": "SELECT COUNT(*) FROM users", "active_users": "SELECT COUNT(*) FROM users WHERE is_active = true", - "total_chats": "SELECT COUNT(*) FROM chats", - "total_messages": "SELECT COUNT(*) FROM chat_messages", + "total_channels": "SELECT COUNT(*) FROM channels", + "total_messages": "SELECT COUNT(*) FROM messages", "api_configs": "SELECT COUNT(*) FROM api_configs", } @@ -438,8 +476,8 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { var id string err := database.DB.QueryRow(` - INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id) - VALUES ($1, $2, $3, $4, $5, NULL) + INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id, is_global) + VALUES ($1, $2, $3, $4, $5, NULL, true) RETURNING id `, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id) if err != nil { @@ -666,6 +704,28 @@ func (h *AdminHandler) UpdateModelConfig(c *gin.Context) { 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) { modelID := c.Param("id") result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID) diff --git a/server/handlers/apiconfigs_test.go b/server/handlers/apiconfigs_test.go index 9bdcdaa..f408810 100644 --- a/server/handlers/apiconfigs_test.go +++ b/server/handlers/apiconfigs_test.go @@ -89,8 +89,8 @@ func TestCompletionHandlerMissingFields(t *testing.T) { name string body string }{ - {"missing chat_id", `{"content":"hello"}`}, - {"missing content", `{"chat_id":"abc"}`}, + {"missing channel_id", `{"content":"hello"}`}, + {"missing content", `{"channel_id":"abc"}`}, {"empty body", `{}`}, } diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 4229cd2..a159e19 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -6,7 +6,9 @@ import ( "database/sql" "encoding/hex" "fmt" + "log" "net/http" + "os" "strings" "time" @@ -91,8 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) { _ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount) isFirstUser := userCount == 0 - // If not first user, check if registration is enabled - if !isFirstUser { + // First-user-becomes-admin only when no env admin is configured + envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != "" + promoteFirst := isFirstUser && !envAdminSet + + // If not first user (or env admin handles bootstrap), check registration + if !promoteFirst { if !IsRegistrationEnabled() { c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"}) return @@ -106,19 +112,25 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - // First user gets admin role + // Determine role and active state role := "user" - if isFirstUser { + isActive := true + if promoteFirst { role = "admin" + } else { + // Apply registration default state + if GetRegistrationDefaultState() == "pending" { + isActive = false + } } // Insert user var user userResponse err = database.DB.QueryRow(` - INSERT INTO users (username, email, password_hash, role) - VALUES ($1, $2, $3, $4) + INSERT INTO users (username, email, password_hash, role, is_active) + VALUES ($1, $2, $3, $4, $5) 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, ) if err != nil { @@ -134,6 +146,15 @@ func (h *AuthHandler) Register(c *gin.Context) { 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 resp, err := h.generateTokenPair(user) if err != nil { @@ -152,8 +173,8 @@ func IsRegistrationEnabled() bool { } var enabled bool err := database.DB.QueryRow(` - SELECT COALESCE((value->>'enabled')::boolean, true) - FROM global_settings WHERE key = 'registration' + SELECT COALESCE((value->>'value')::boolean, true) + FROM global_settings WHERE key = 'registration_enabled' `).Scan(&enabled) if err != nil { return true // Default to open if setting missing @@ -161,6 +182,75 @@ func IsRegistrationEnabled() bool { 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 ─────────────────────────────────── func (h *AuthHandler) Login(c *gin.Context) { @@ -195,7 +285,7 @@ func (h *AuthHandler) Login(c *gin.Context) { } if !isActive { - c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"}) + c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"}) return } diff --git a/server/handlers/chats.go b/server/handlers/channels.go similarity index 57% rename from server/handlers/chats.go rename to server/handlers/channels.go index ffe9933..aca419d 100644 --- a/server/handlers/chats.go +++ b/server/handlers/channels.go @@ -14,8 +14,10 @@ import ( // ── Request / Response types ──────────────── -type createChatRequest struct { +type createChannelRequest struct { Title string `json:"title" binding:"required,max=500"` + Type string `json:"type,omitempty"` // direct (default), group, channel + Description string `json:"description,omitempty"` Model string `json:"model,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` APIConfigID *string `json:"api_config_id,omitempty"` @@ -23,8 +25,9 @@ type createChatRequest struct { Tags []string `json:"tags,omitempty"` } -type updateChatRequest struct { +type updateChannelRequest struct { Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` Model *string `json:"model,omitempty"` SystemPrompt *string `json:"system_prompt,omitempty"` APIConfigID *string `json:"api_config_id,omitempty"` @@ -34,10 +37,12 @@ type updateChatRequest struct { Tags []string `json:"tags,omitempty"` } -type chatResponse struct { +type channelResponse struct { ID string `json:"id"` UserID string `json:"user_id"` Title string `json:"title"` + Type string `json:"type"` + Description *string `json:"description"` Model *string `json:"model"` APIConfigID *string `json:"api_config_id"` SystemPrompt *string `json:"system_prompt"` @@ -58,12 +63,12 @@ type paginatedResponse struct { TotalPages int `json:"total_pages"` } -// ChatHandler holds dependencies for chat endpoints. -type ChatHandler struct{} +// ChannelHandler holds dependencies for channel endpoints. +type ChannelHandler struct{} -// NewChatHandler creates a new chat handler. -func NewChatHandler() *ChatHandler { - return &ChatHandler{} +// NewChannelHandler creates a new channel handler. +func NewChannelHandler() *ChannelHandler { + return &ChannelHandler{} } // ── Helpers ───────────────────────────────── @@ -93,46 +98,59 @@ func parsePagination(c *gin.Context) (page, perPage, offset int) { return } -// ── List Chats ────────────────────────────── +// ── List Channels ─────────────────────────── -func (h *ChatHandler) ListChats(c *gin.Context) { +func (h *ChannelHandler) ListChannels(c *gin.Context) { userID := getUserID(c) page, perPage, offset := parsePagination(c) // Optional filters archived := c.DefaultQuery("archived", "false") folder := c.Query("folder") + channelType := c.DefaultQuery("type", "") // empty = all types // Count total - var total int - countQuery := `SELECT COUNT(*) FROM chats WHERE user_id = $1 AND is_archived = $2` + countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2` countArgs := []interface{}{userID, archived == "true"} + argN := 3 + if channelType != "" { + countQuery += ` AND type = $` + strconv.Itoa(argN) + countArgs = append(countArgs, channelType) + argN++ + } if folder != "" { - countQuery += ` AND folder = $3` + countQuery += ` AND folder = $` + strconv.Itoa(argN) countArgs = append(countArgs, folder) + argN++ } + var total int if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count chats"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"}) return } - // Fetch chats with message count + // Fetch channels with message count 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, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at - FROM chats c + FROM channels c LEFT JOIN ( - SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id - ) mc ON mc.chat_id = c.id + SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id + ) mc ON mc.channel_id = c.id WHERE c.user_id = $1 AND c.is_archived = $2` args := []interface{}{userID, archived == "true"} - argN := 3 + argN = 3 + if channelType != "" { + query += ` AND c.type = $` + strconv.Itoa(argN) + args = append(args, channelType) + argN++ + } if folder != "" { query += ` AND c.folder = $` + strconv.Itoa(argN) args = append(args, folder) @@ -145,34 +163,34 @@ func (h *ChatHandler) ListChats(c *gin.Context) { rows, err := database.DB.Query(query, args...) 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 } defer rows.Close() - chats := make([]chatResponse, 0) + channels := make([]channelResponse, 0) for rows.Next() { - var chat chatResponse + var ch channelResponse var tags []string err := rows.Scan( - &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, - &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, + &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, + &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, pq.Array(&tags), - &chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt, + &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) 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 } if tags == nil { tags = []string{} } - chat.Tags = tags - chats = append(chats, chat) + ch.Tags = tags + channels = append(channels, ch) } c.JSON(http.StatusOK, paginatedResponse{ - Data: chats, + Data: channels, Page: page, PerPage: perPage, 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) - var req createChatRequest + var req createChannelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -195,88 +213,110 @@ func (h *ChatHandler) CreateChat(c *gin.Context) { 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 err := database.DB.QueryRow(` - INSERT INTO chats (user_id, title, model, system_prompt, api_config_id, folder, tags) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, user_id, title, model, api_config_id, system_prompt, + INSERT INTO channels (user_id, title, type, description, model, system_prompt, api_config_id, folder, tags) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, user_id, title, type, description, model, api_config_id, system_prompt, 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), ).Scan( - &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, - &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, - pq.Array(&tags), &chat.CreatedAt, &chat.UpdatedAt, + &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, + &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, + pq.Array(&tags), &ch.CreatedAt, &ch.UpdatedAt, ) 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 } if tags == nil { tags = []string{} } - chat.Tags = tags - chat.MessageCount = 0 + ch.Tags = tags + 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) - chatID := c.Param("id") + channelID := c.Param("id") - var chat chatResponse + var ch channelResponse var tags []string 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, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at - FROM chats c + FROM channels c LEFT JOIN ( - SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id - ) mc ON mc.chat_id = c.id + SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id + ) mc ON mc.channel_id = c.id WHERE c.id = $1 AND c.user_id = $2 - `, chatID, userID).Scan( - &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, - &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, + `, channelID, userID).Scan( + &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, + &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, pq.Array(&tags), - &chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt, + &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) 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 } 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 } if tags == nil { 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) - chatID := c.Param("id") - - if database.DB == nil { + channelID := c.Param("id") + + if database.DB == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"}) return } - var req updateChatRequest + var req updateChannelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -284,13 +324,13 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) { // Verify ownership 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 { - c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } if ownerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"}) + c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) return } @@ -308,6 +348,9 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) { if req.Title != nil { addClause("title", *req.Title) } + if req.Description != nil { + addClause("description", *req.Description) + } if req.Model != nil { addClause("model", *req.Model) } @@ -335,7 +378,7 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) { return } - query := "UPDATE chats SET " + query := "UPDATE channels SET " for i, clause := range setClauses { if i > 0 { query += ", " @@ -343,38 +386,38 @@ func (h *ChatHandler) UpdateChat(c *gin.Context) { query += clause } 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...) 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 updated chat - h.GetChat(c) + // Return updated channel + h.GetChannel(c) } -// ── Delete Chat ───────────────────────────── +// ── Delete Channel ────────────────────────── -func (h *ChatHandler) DeleteChat(c *gin.Context) { +func (h *ChannelHandler) DeleteChannel(c *gin.Context) { userID := getUserID(c) - chatID := c.Param("id") + channelID := c.Param("id") result, err := database.DB.Exec( - `DELETE FROM chats WHERE id = $1 AND user_id = $2`, - chatID, userID, + `DELETE FROM channels WHERE id = $1 AND user_id = $2`, + channelID, userID, ) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete chat"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) return } rows, _ := result.RowsAffected() if rows == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } - c.JSON(http.StatusOK, gin.H{"message": "chat deleted"}) + c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) } diff --git a/server/handlers/chats_test.go b/server/handlers/channels_test.go similarity index 76% rename from server/handlers/chats_test.go rename to server/handlers/channels_test.go index b966439..3b07201 100644 --- a/server/handlers/chats_test.go +++ b/server/handlers/channels_test.go @@ -13,56 +13,56 @@ func init() { gin.SetMode(gin.TestMode) } -// ── Chat Request Validation ───────────────── +// ── Channel Request Validation ───────────────── -func TestCreateChatMissingTitle(t *testing.T) { - h := NewChatHandler() +func TestCreateChannelMissingTitle(t *testing.T) { + h := NewChannelHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("POST", "/api/v1/chats", + c.Request = httptest.NewRequest("POST", "/api/v1/channels", strings.NewReader(`{}`)) c.Request.Header.Set("Content-Type", "application/json") - h.CreateChat(c) + h.CreateChannel(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String()) } } -func TestCreateChatTitleTooLong(t *testing.T) { - h := NewChatHandler() +func TestCreateChannelTitleTooLong(t *testing.T) { + h := NewChannelHandler() longTitle := strings.Repeat("x", 501) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("POST", "/api/v1/chats", + c.Request = httptest.NewRequest("POST", "/api/v1/channels", strings.NewReader(`{"title":"`+longTitle+`"}`)) c.Request.Header.Set("Content-Type", "application/json") - h.CreateChat(c) + h.CreateChannel(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code) } } -func TestUpdateChatEmptyBody(t *testing.T) { - h := NewChatHandler() +func TestUpdateChannelEmptyBody(t *testing.T) { + h := NewChannelHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("user_id", "test-user-id") - c.Params = gin.Params{{Key: "id", Value: "test-chat-id"}} - c.Request = httptest.NewRequest("PUT", "/api/v1/chats/test-chat-id", + c.Params = gin.Params{{Key: "id", Value: "test-channel-id"}} + c.Request = httptest.NewRequest("PUT", "/api/v1/channels/test-channel-id", strings.NewReader(`{}`)) 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. // Here we just confirm it doesn't return 400 for valid JSON. - h.UpdateChat(c) + h.UpdateChannel(c) if w.Code == http.StatusBadRequest { t.Error("Empty JSON body should not be a parse error") @@ -76,8 +76,8 @@ func TestCreateMessageMissingRole(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "test-chat"}} - c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages", + c.Params = gin.Params{{Key: "id", Value: "test-channel"}} + c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages", strings.NewReader(`{"content":"hello"}`)) c.Request.Header.Set("Content-Type", "application/json") @@ -93,8 +93,8 @@ func TestCreateMessageInvalidRole(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "test-chat"}} - c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages", + c.Params = gin.Params{{Key: "id", Value: "test-channel"}} + c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages", strings.NewReader(`{"role":"invalid","content":"hello"}`)) c.Request.Header.Set("Content-Type", "application/json") @@ -110,8 +110,8 @@ func TestCreateMessageMissingContent(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "test-chat"}} - c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/messages", + c.Params = gin.Params{{Key: "id", Value: "test-channel"}} + c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages", strings.NewReader(`{"role":"user"}`)) c.Request.Header.Set("Content-Type", "application/json") @@ -135,7 +135,7 @@ func TestRegenerateReturns501(t *testing.T) { w := httptest.NewRecorder() 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) @@ -149,7 +149,7 @@ func TestRegenerateReturns501(t *testing.T) { func TestParsePaginationDefaults(t *testing.T) { w := httptest.NewRecorder() 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) @@ -167,7 +167,7 @@ func TestParsePaginationDefaults(t *testing.T) { func TestParsePaginationCustom(t *testing.T) { w := httptest.NewRecorder() 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) @@ -185,7 +185,7 @@ func TestParsePaginationCustom(t *testing.T) { func TestParsePaginationClampMax(t *testing.T) { w := httptest.NewRecorder() 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) diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 5fc934a..5503710 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -17,7 +17,8 @@ import ( // ── Request Types ─────────────────────────── 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"` Model string `json:"model,omitempty"` APIConfigID string `json:"api_config_id,omitempty"` @@ -41,7 +42,7 @@ func NewCompletionHandler() *CompletionHandler { // Flow: // 1. Validate request, verify chat ownership // 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 // 5. Call provider (stream or non-stream) // 6. Stream SSE to client / return JSON @@ -54,15 +55,25 @@ func (h *CompletionHandler) Complete(c *gin.Context) { 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) - // Verify chat ownership - if !userOwnsChat(c, req.ChatID, userID) { + // Verify channel ownership + if !userOwnsChannel(c, channelID, userID) { return } // 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 { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -75,7 +86,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } // Load conversation history - messages, err := h.loadConversation(req.ChatID) + messages, err := h.loadConversation(channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) return @@ -88,7 +99,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { }) // 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) } @@ -122,9 +133,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } if stream { - h.streamCompletion(c, provider, providerCfg, provReq, req.ChatID, model) + h.streamCompletion(c, provider, providerCfg, provReq, channelID, model) } 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, cfg providers.ProviderConfig, req providers.CompletionRequest, - chatID, model string, + channelID, model string, ) { ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req) if err != nil { @@ -190,7 +201,7 @@ func (h *CompletionHandler) streamCompletion( // Persist assistant response 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) } } @@ -203,7 +214,7 @@ func (h *CompletionHandler) syncCompletion( provider providers.Provider, cfg providers.ProviderConfig, req providers.CompletionRequest, - chatID, model string, + channelID, model string, ) { resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req) if err != nil { @@ -212,7 +223,7 @@ func (h *CompletionHandler) syncCompletion( } // 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) } @@ -271,7 +282,7 @@ func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) prov // ── Config Resolution ─────────────────────── // 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 // 1. Explicit config from request @@ -279,14 +290,14 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) configID = req.APIConfigID } - // 2. Config from chat + // 2. Config from channel if configID == "" { - var chatConfigID *string + var channelConfigID *string err := database.DB.QueryRow( - `SELECT api_config_id FROM chats WHERE id = $1`, req.ChatID, - ).Scan(&chatConfigID) - if err == nil && chatConfigID != nil { - configID = *chatConfigID + `SELECT api_config_id FROM channels WHERE id = $1`, channelID, + ).Scan(&channelConfigID) + if err == nil && channelConfigID != nil { + configID = *channelConfigID } } @@ -294,7 +305,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) if configID == "" { err := database.DB.QueryRow(` 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 LIMIT 1 `, userID).Scan(&configID) @@ -310,7 +321,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) err := database.DB.QueryRow(` SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings 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) if err == sql.ErrNoRows { @@ -356,11 +367,11 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) // ── Conversation Loader ───────────────────── -func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message, error) { - // Load system prompt from chat +func (h *CompletionHandler) loadConversation(channelID string) ([]providers.Message, error) { + // Load system prompt from channel var systemPrompt *string _ = database.DB.QueryRow( - `SELECT system_prompt FROM chats WHERE id = $1`, chatID, + `SELECT system_prompt FROM channels WHERE id = $1`, channelID, ).Scan(&systemPrompt) messages := make([]providers.Message, 0) @@ -374,10 +385,10 @@ func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message // Load message history (oldest first) rows, err := database.DB.Query(` - SELECT role, content FROM chat_messages - WHERE chat_id = $1 + SELECT role, content FROM messages + WHERE channel_id = $1 ORDER BY created_at ASC - `, chatID) + `, channelID) if err != nil { return nil, err } @@ -396,22 +407,42 @@ func (h *CompletionHandler) loadConversation(chatID string) ([]providers.Message // ── 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 if inputTokens > 0 || outputTokens > 0 { total := inputTokens + outputTokens 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(` - INSERT INTO chat_messages (chat_id, role, content, model, tokens_used) - VALUES ($1, $2, $3, NULLIF($4, ''), $5) - `, chatID, role, content, model, tokensUsed) + INSERT INTO messages (channel_id, role, content, model, tokens_used, parent_id, participant_type, participant_id) + VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8) + `, channelID, role, content, model, tokensUsed, parentID, participantType, participantID) if err != nil { return err } - // Touch chat updated_at - _, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID) + // Touch channel updated_at + _, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID) return nil } diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 1266b06..ec3a63d 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -19,13 +19,14 @@ type createMessageRequest struct { } type messageResponse struct { - ID string `json:"id"` - ChatID string `json:"chat_id"` - Role string `json:"role"` - Content string `json:"content"` - Model *string `json:"model"` - TokensUsed *int `json:"tokens_used"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Role string `json:"role"` + Content string `json:"content"` + Model *string `json:"model"` + TokensUsed *int `json:"tokens_used"` + ParentID *string `json:"parent_id,omitempty"` + CreatedAt string `json:"created_at"` } // MessageHandler holds dependencies for message endpoints. @@ -42,18 +43,18 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { page, perPage, offset := parsePagination(c) userID := getUserID(c) - chatID := c.Param("id") + channelID := c.Param("id") // Verify ownership - if !userOwnsChat(c, chatID, userID) { + if !userOwnsChannel(c, channelID, userID) { return } // Count total messages var total int err := database.DB.QueryRow( - `SELECT COUNT(*) FROM chat_messages WHERE chat_id = $1`, - chatID, + `SELECT COUNT(*) FROM messages WHERE channel_id = $1`, + channelID, ).Scan(&total) if err != nil { 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) rows, err := database.DB.Query(` - SELECT id, chat_id, role, content, model, tokens_used, created_at - FROM chat_messages - WHERE chat_id = $1 + SELECT id, channel_id, role, content, model, tokens_used, parent_id, created_at + FROM messages + WHERE channel_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3 - `, chatID, perPage, offset) + `, channelID, perPage, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"}) return @@ -78,8 +79,8 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { for rows.Next() { var msg messageResponse err := rows.Scan( - &msg.ID, &msg.ChatID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.CreatedAt, + &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, + &msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt, ) if err != nil { 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) - chatID := c.Param("id") + channelID := c.Param("id") // Verify ownership - if !userOwnsChat(c, chatID, userID) { + if !userOwnsChannel(c, channelID, userID) { 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 err := database.DB.QueryRow(` - INSERT INTO chat_messages (chat_id, role, content, model) - VALUES ($1, $2, $3, $4) - RETURNING id, chat_id, role, content, model, tokens_used, created_at - `, chatID, req.Role, req.Content, req.Model).Scan( - &msg.ID, &msg.ChatID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.CreatedAt, + INSERT INTO messages (channel_id, role, content, model, parent_id, participant_type, participant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, channel_id, role, content, model, tokens_used, parent_id, created_at + `, channelID, req.Role, req.Content, req.Model, parentID, + func() string { + 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) return } - // Touch the chat's updated_at so it sorts to top of list - _, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID) + // Touch the channel's updated_at so it sorts to top of list + _, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID) c.JSON(http.StatusCreated, msg) } @@ -152,22 +173,22 @@ func (h *MessageHandler) Stream(c *gin.Context) { // ── Ownership Check ───────────────────────── -func userOwnsChat(c *gin.Context, chatID, userID string) bool { +func userOwnsChannel(c *gin.Context, channelID, userID string) bool { var ownerID string err := database.DB.QueryRow( - `SELECT user_id FROM chats WHERE id = $1`, chatID, + `SELECT user_id FROM channels WHERE id = $1`, channelID, ).Scan(&ownerID) 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 } 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 } 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 true diff --git a/server/main.go b/server/main.go index e76b40a..32dec8f 100644 --- a/server/main.go +++ b/server/main.go @@ -27,18 +27,24 @@ func main() { if err := database.Migrate(); err != nil { log.Fatalf("❌ Schema migration failed: %v", err) } + // Bootstrap admin from env (K8s secret) — upserts on every restart + handlers.BootstrapAdmin(cfg) } defer database.Close() r := gin.Default() 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 ───────────── bus := events.NewBus() hub := events.NewHub(bus) // 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{ "status": "ok", "version": Version, @@ -48,13 +54,13 @@ func main() { }) // 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 := handlers.NewAuthHandler(cfg) authLimiter := middleware.NewRateLimiter(1, 5) - api := r.Group("/api/v1") + api := base.Group("/api/v1") { // Health (routable through ingress) api.GET("/health", func(c *gin.Context) { @@ -85,23 +91,23 @@ func main() { protected := api.Group("") protected.Use(middleware.Auth(cfg)) { - // Chats - chats := handlers.NewChatHandler() - protected.GET("/chats", chats.ListChats) - protected.POST("/chats", chats.CreateChat) - protected.GET("/chats/:id", chats.GetChat) - protected.PUT("/chats/:id", chats.UpdateChat) - protected.DELETE("/chats/:id", chats.DeleteChat) + // Channels (unified: replaces /chats) + channels := handlers.NewChannelHandler() + protected.GET("/channels", channels.ListChannels) + protected.POST("/channels", channels.CreateChannel) + protected.GET("/channels/:id", channels.GetChannel) + protected.PUT("/channels/:id", channels.UpdateChannel) + protected.DELETE("/channels/:id", channels.DeleteChannel) // Messages msgs := handlers.NewMessageHandler() - protected.GET("/chats/:id/messages", msgs.ListMessages) - protected.POST("/chats/:id/messages", msgs.CreateMessage) + protected.GET("/channels/:id/messages", msgs.ListMessages) + protected.POST("/channels/:id/messages", msgs.CreateMessage) // ── Chat Engine (#8) ──────────────── comp := handlers.NewCompletionHandler() 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 apiCfg := handlers.NewAPIConfigHandler() @@ -123,6 +129,10 @@ func main() { protected.POST("/profile/password", settings.ChangePassword) protected.GET("/settings", settings.GetSettings) 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 ──────────────────────── @@ -156,15 +166,21 @@ func main() { // Model Configs admin.GET("/models", adm.ListModelConfigs) admin.POST("/models/fetch", adm.FetchModels) + admin.PUT("/models/bulk", adm.BulkUpdateModels) admin.PUT("/models/:id", adm.UpdateModelConfig) admin.DELETE("/models/:id", adm.DeleteModelConfig) } } + bp := cfg.BasePath + if bp == "" { + bp = "/" + } log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) + log.Printf(" Base path: %s", bp) log.Printf(" Schema: %s", database.SchemaVersion()) log.Printf(" Providers: %v", providers.List()) - 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 { log.Fatalf("Failed to start server: %v", err) } diff --git a/server/main_test.go b/server/main_test.go index 80ef7b1..3a6de9f 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -53,7 +53,7 @@ func TestCORSHeaders(t *testing.T) { func TestAllRoutesRegistered(t *testing.T) { cfg := &config.Config{JWTSecret: "test"} auth := handlers.NewAuthHandler(cfg) - chats := handlers.NewChatHandler() + channels := handlers.NewChannelHandler() msgs := handlers.NewMessageHandler() comp := handlers.NewCompletionHandler() apiCfg := handlers.NewAPIConfigHandler() @@ -73,19 +73,19 @@ func TestAllRoutesRegistered(t *testing.T) { protected := api.Group("") { - // Chats - protected.GET("/chats", chats.ListChats) - protected.POST("/chats", chats.CreateChat) - protected.GET("/chats/:id", chats.GetChat) - protected.PUT("/chats/:id", chats.UpdateChat) - protected.DELETE("/chats/:id", chats.DeleteChat) + // Channels + protected.GET("/channels", channels.ListChannels) + protected.POST("/channels", channels.CreateChannel) + protected.GET("/channels/:id", channels.GetChannel) + protected.PUT("/channels/:id", channels.UpdateChannel) + protected.DELETE("/channels/:id", channels.DeleteChannel) // Messages - protected.GET("/chats/:id/messages", msgs.ListMessages) - protected.POST("/chats/:id/messages", msgs.CreateMessage) - protected.POST("/chats/:id/regenerate", msgs.Regenerate) + protected.GET("/channels/:id/messages", msgs.ListMessages) + protected.POST("/channels/:id/messages", msgs.CreateMessage) + protected.POST("/channels/:id/regenerate", msgs.Regenerate) - // Chat Engine + // Completion Engine protected.POST("/chat/completions", comp.Complete) // API Configs @@ -140,17 +140,17 @@ func TestAllRoutesRegistered(t *testing.T) { "POST /api/v1/auth/login", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", - // Chats - "GET /api/v1/chats", - "POST /api/v1/chats", - "GET /api/v1/chats/:id", - "PUT /api/v1/chats/:id", - "DELETE /api/v1/chats/:id", + // Channels + "GET /api/v1/channels", + "POST /api/v1/channels", + "GET /api/v1/channels/:id", + "PUT /api/v1/channels/:id", + "DELETE /api/v1/channels/:id", // Messages - "GET /api/v1/chats/:id/messages", - "POST /api/v1/chats/:id/messages", - "POST /api/v1/chats/:id/regenerate", - // Chat Engine + "GET /api/v1/channels/:id/messages", + "POST /api/v1/channels/:id/messages", + "POST /api/v1/channels/:id/regenerate", + // Completion Engine "POST /api/v1/chat/completions", // API Configs "GET /api/v1/api-configs", diff --git a/server/models/models.go b/server/models/models.go index 699b405..6590193 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -14,12 +14,12 @@ type BaseModel struct { // User represents a user in the system type User struct { BaseModel - Email string `json:"email" db:"email"` - PasswordHash string `json:"-" db:"password_hash"` - Name string `json:"name" db:"name"` - Role string `json:"role" db:"role"` + Email string `json:"email" db:"email"` + PasswordHash string `json:"-" db:"password_hash"` + Name string `json:"name" db:"name"` + Role string `json:"role" db:"role"` 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 @@ -28,121 +28,147 @@ const ( UserRoleAdmin = "admin" ) -// Chat represents a chat conversation -type Chat struct { +// ── Channel Types ─────────────────────────── + +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 - UserID string `json:"user_id" db:"user_id"` - Title string `json:"title" db:"title"` - Model string `json:"model" db:"model"` - SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` - IsArchived bool `json:"is_archived" db:"is_archived"` + UserID string `json:"user_id" db:"user_id"` + Title string `json:"title" db:"title"` + Description string `json:"description,omitempty" db:"description"` + Type string `json:"type" db:"type"` + 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 { BaseModel - ChatID string `json:"chat_id" db:"chat_id"` - Role string `json:"role" db:"role"` // "user", "assistant", "system" - Content string `json:"content" db:"content"` - Tokens int `json:"tokens,omitempty" db:"tokens"` - Model string `json:"model,omitempty" db:"model"` - FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"` + ChannelID string `json:"channel_id" db:"channel_id"` + Role string `json:"role" db:"role"` + Content string `json:"content" db:"content"` + Tokens int `json:"tokens,omitempty" db:"tokens"` + Model string `json:"model,omitempty" db:"model"` + 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 ( MessageRoleUser = "user" MessageRoleAssistant = "assistant" MessageRoleSystem = "system" ) -// APIConfig represents a configured API provider -type APIConfig struct { +// ── Channel Members & Models ──────────────── + +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 UserID string `json:"user_id" db:"user_id"` 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"` - IsPrivate bool `json:"is_private" db:"is_private"` - OwnerID string `json:"owner_id" db:"owner_id"` + Color string `json:"color,omitempty" db:"color"` } -// ChannelMember represents a member of a channel -type ChannelMember struct { +// ── API Config ────────────────────────────── + +type APIConfig struct { BaseModel - ChannelID string `json:"channel_id" db:"channel_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 -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"` -} +// ── Notes (future Phase 2) ────────────────── -// Note represents a user note type Note struct { BaseModel - UserID string `json:"user_id" db:"user_id"` - Title string `json:"title" db:"title"` - Content string `json:"content" db:"content"` - Tags []string `json:"tags,omitempty" db:"tags"` - FolderID string `json:"folder_id,omitempty" db:"folder_id"` - IsShared bool `json:"is_shared" db:"is_shared"` + UserID string `json:"user_id" db:"user_id"` + Title string `json:"title" db:"title"` + Content string `json:"content" db:"content"` + Tags []string `json:"tags,omitempty" db:"tags"` + FolderID string `json:"folder_id,omitempty" db:"folder_id"` + IsShared bool `json:"is_shared" db:"is_shared"` } -// KnowledgeBase represents a knowledge base collection type KnowledgeBase struct { BaseModel UserID string `json:"user_id" db:"user_id"` Name string `json:"name" db:"name"` - Source string `json:"source" db:"source"` // "url", "file", "text" - 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 + Source string `json:"source" db:"source"` Settings string `json:"settings,omitempty" db:"settings"` - IsPublic bool `json:"is_public" db:"is_public"` } -// Settings represents user settings +// ── Settings ──────────────────────────────── + type Settings struct { - UserID string `json:"user_id" db:"user_id"` - Theme string `json:"theme" db:"theme"` - Language string `json:"language" db:"language"` - Model string `json:"model" db:"model"` - SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` - MaxTokens int `json:"max_tokens" db:"max_tokens"` - Temperature float64 `json:"temperature" db:"temperature"` - DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"` + UserID string `json:"user_id" db:"user_id"` + Theme string `json:"theme" db:"theme"` + Language string `json:"language" db:"language"` + Model string `json:"model" db:"model"` + SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` + MaxTokens int `json:"max_tokens" db:"max_tokens"` + Temperature float64 `json:"temperature" db:"temperature"` + DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"` } -// APIToken represents an API token for external access type APIToken struct { BaseModel - UserID string `json:"user_id" db:"user_id"` - Name string `json:"name" db:"name"` - Token string `json:"-" db:"token"` - ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"` + UserID string `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + Token string `json:"-" db:"token"` + ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"` LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` - IsActive bool `json:"is_active" db:"is_active"` -} \ No newline at end of file + IsActive bool `json:"is_active" db:"is_active"` +} diff --git a/src/css/styles.css b/src/css/styles.css index aad0422..52921e1 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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 ========================================== */ @@ -19,25 +21,56 @@ --danger: #ef4444; --success: #22c55e; --warning: #eab308; + --purple: #a78bfa; + --purple-dim: rgba(167,139,250,0.10); --radius: 8px; --radius-lg: 12px; --sidebar-w: 260px; --sidebar-rail: 52px; - --font: 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --mono: 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace; + --font: 'DM Sans', 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace; --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; } html, body { height: 100%; } 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:hover { text-decoration: underline; } ::selection { background: var(--accent-dim); } /* ── 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 ─────────────────────────────── */ @@ -85,13 +118,105 @@ a:hover { text-decoration: underline; } .sidebar.collapsed .sb-label { 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 { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 8px 6px; } .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 { font-size: 11px; font-weight: 600; color: var(--text-3); @@ -231,8 +356,6 @@ a:hover { text-decoration: underline; } /* Messages */ .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: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 ─────────────────────────── */ .splash { - display: flex; align-items: center; justify-content: center; - height: 100vh; background: var(--bg); + display: flex; + 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 { 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; transition: color var(--transition); } +.auth-tab:hover { color: var(--text-2); } .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-actions { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; } +.auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; } +.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 ────────────────────────────────── */ .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 { width: 100%; background: var(--bg-raised); border: 1px solid var(--border); 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 .form-group { flex: 1; } .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 ────────────────────────────────── */ @@ -478,7 +710,7 @@ button { font-family: var(--font); cursor: pointer; } display: flex; align-items: center; justify-content: space-between; 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 { background: none; border: none; color: var(--text-3); font-size: 18px; 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: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 */ .provider-row { @@ -508,22 +758,95 @@ button { font-family: var(--font); cursor: pointer; } /* Admin tabs */ .admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); } .admin-tab { - padding: 8px 12px; background: none; border: none; color: var(--text-3); - cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; + padding: 10px 14px; background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; } .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-user-email { font-size: 11px; 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-model-row .model-caps-inline { display: flex; gap: 3px; margin-left: auto; } -.admin-model-row .provider-meta { min-width: 80px; text-align: right; } +.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; } +.admin-hint { font-size: 12px; color: var(--text-3); } +.admin-inline-form { + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); + 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-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; } -.loading { color: var(--text-3); font-size: 12px; padding: 0.5rem; } -.error-hint { color: var(--danger); font-size: 12px; padding: 0.5rem; } -.empty-hint { color: var(--text-3); font-size: 12px; text-align: center; padding: 1rem; } +.badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.admin-user-row.user-inactive { opacity: 0.7; } +.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; } +.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; } +.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; } /* ── Toast ────────────────────────────────── */ diff --git a/src/index.html b/src/index.html index 6d36de9..6bce5be 100644 --- a/src/index.html +++ b/src/index.html @@ -3,17 +3,23 @@ + + Chat Switchboard - + + + + +
-
-
- -

Chat Switchboard

-

Multi-Model AI Chat

+
+
+
+ + + + + + + + +
Chat Switchboard
+
+

One interface.
Every AI model.

+

Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.

+
+
Streaming responses
+
🔀 Multi-provider routing
+
🔑 Bring your own API keys
+
🏠 Self-hosted
+
✈️ Air-gap ready
+
🧠 Thinking blocks
+
+
v0.6.2
-
- - -
-
-
-
-
- -
-
-
- - +
+
+
+
+

Welcome back

+

Sign in to continue to your workspace

+
+
+ + +
+
+
+
+
+ +
+
+
+ + +
+
@@ -131,44 +190,62 @@ @@ -186,12 +263,89 @@
@@ -235,10 +389,10 @@ - - - - - + + + + + diff --git a/src/js/api.js b/src/js/api.js index 8e4eb0f..62b2a2b 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -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 // all HTTP calls. No offline fallback. +// +// BASE_PATH: injected via index.html