Changeset 0.6.0 (#36)
This commit is contained in:
@@ -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)
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
409
ROADMAP.md
409
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.<major>.<minor>` — hotfixes use quad: `0.x.y.z`
|
||||
No compatibility guarantees before 1.0. Post-1.0: major = long-lived (break nothing),
|
||||
minor = add features (deprecate, don't remove), patch = fix something.
|
||||
|
||||
---
|
||||
|
||||
## Current State: v0.6.2
|
||||
|
||||
### ✅ Done
|
||||
|
||||
**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 (`<think>`/`<thinking>` tags)
|
||||
- [x] Settings modal (profile, chat prefs, provider management)
|
||||
- [x] Admin modal (users, providers, models with cap badges, settings, stats)
|
||||
- [x] Debug modal (console intercept, network log, state inspector)
|
||||
- [x] 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
|
||||
|
||||
109
docker-entrypoint-fe.sh
Normal file
109
docker-entrypoint-fe.sh
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/bin/sh
|
||||
# ============================================
|
||||
# Chat Switchboard - Frontend Entrypoint
|
||||
# ============================================
|
||||
# Injects BASE_PATH into index.html and nginx
|
||||
# config at container startup. Supports path-
|
||||
# based multi-env deployment on a single domain.
|
||||
#
|
||||
# Environment:
|
||||
# BASE_PATH - URL prefix (e.g. "/dev", "/test", or "")
|
||||
# ============================================
|
||||
set -e
|
||||
|
||||
BASE_PATH="${BASE_PATH:-}"
|
||||
|
||||
# Compute base href (needs trailing slash for <base> tag)
|
||||
if [ -z "${BASE_PATH}" ]; then
|
||||
BASE_HREF="/"
|
||||
else
|
||||
BASE_HREF="${BASE_PATH}/"
|
||||
fi
|
||||
|
||||
# ── Inject into index.html ──────────────────
|
||||
sed -i \
|
||||
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
|
||||
-e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
|
||||
/usr/share/nginx/html/index.html
|
||||
|
||||
echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/}"
|
||||
|
||||
# ── Generate nginx config ───────────────────
|
||||
# If BASE_PATH is set, serve under that prefix with alias.
|
||||
# If empty, serve from root (default behavior).
|
||||
if [ -z "${BASE_PATH}" ]; then
|
||||
cat > /etc/nginx/conf.d/default.conf <<'NGINX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
|
||||
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check for k8s probes
|
||||
location = /healthz {
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
NGINX
|
||||
else
|
||||
cat > /etc/nginx/conf.d/default.conf <<NGINX
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
|
||||
|
||||
location ${BASE_PATH}/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files \$uri \$uri/ ${BASE_PATH}/index.html;
|
||||
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)\$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect bare path to trailing slash
|
||||
location = ${BASE_PATH} {
|
||||
return 301 ${BASE_PATH}/;
|
||||
}
|
||||
|
||||
# Health check for k8s probes (always at root)
|
||||
location = /healthz {
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
NGINX
|
||||
fi
|
||||
|
||||
# ── Start nginx ─────────────────────────────
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -2,6 +2,14 @@
|
||||
# ============================================
|
||||
# Chat Switchboard - Backend Deployment
|
||||
# ============================================
|
||||
# Secrets:
|
||||
# switchboard-db-credentials - POSTGRES_USER, POSTGRES_PASSWORD
|
||||
# switchboard-admin - username, password, email (optional)
|
||||
#
|
||||
# Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars
|
||||
# are set, the backend upserts an admin user. Change the secret and
|
||||
# restart the pod to reset the admin password.
|
||||
# ============================================
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -37,6 +45,8 @@ spec:
|
||||
value: "${ENVIRONMENT}"
|
||||
- name: PORT
|
||||
value: "8080"
|
||||
- name: BASE_PATH
|
||||
value: "${BASE_PATH}"
|
||||
- name: POSTGRES_HOST
|
||||
value: "${POSTGRES_HOST}"
|
||||
- name: POSTGRES_PORT
|
||||
@@ -55,6 +65,24 @@ spec:
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: DATABASE_URL
|
||||
value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable"
|
||||
- name: SWITCHBOARD_ADMIN_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: switchboard-admin
|
||||
key: username
|
||||
optional: true
|
||||
- name: SWITCHBOARD_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: switchboard-admin
|
||||
key: password
|
||||
optional: true
|
||||
- name: SWITCHBOARD_ADMIN_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: switchboard-admin
|
||||
key: email
|
||||
optional: true
|
||||
resources:
|
||||
requests:
|
||||
memory: "${BE_MEMORY_REQUEST}"
|
||||
@@ -64,7 +92,7 @@ spec:
|
||||
cpu: "${BE_CPU_LIMIT}"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
path: ${BASE_PATH}/health
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
@@ -72,7 +100,7 @@ spec:
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
path: ${BASE_PATH}/health
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
|
||||
@@ -35,6 +35,9 @@ spec:
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: http
|
||||
env:
|
||||
- name: BASE_PATH
|
||||
value: "${BASE_PATH}"
|
||||
resources:
|
||||
requests:
|
||||
memory: "${FE_MEMORY_REQUEST}"
|
||||
@@ -44,7 +47,7 @@ spec:
|
||||
cpu: "${FE_CPU_LIMIT}"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /healthz
|
||||
port: 80
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
@@ -52,7 +55,7 @@ spec:
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /healthz
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
# ============================================
|
||||
# Chat Switchboard - Ingress
|
||||
# ============================================
|
||||
# Routes for a single subdomain:
|
||||
# /api/* → backend service (Go API on :8080)
|
||||
# /health → backend service (health check)
|
||||
# /ws → backend service (WebSocket, upgraded)
|
||||
# /* → frontend service (nginx SPA on :80)
|
||||
# Path-based routing on a single domain:
|
||||
# switchboard.DOMAIN/ → production
|
||||
# switchboard.DOMAIN/test/ → test (main branch)
|
||||
# switchboard.DOMAIN/dev/ → dev (PR branches)
|
||||
#
|
||||
# Each environment deploys its own Ingress resource.
|
||||
# Traefik merges rules for the same host automatically.
|
||||
# Longer path prefixes take priority (Traefik default).
|
||||
#
|
||||
# BASE_PATH is empty for prod, "/test" or "/dev" for others.
|
||||
# Backend handles BASE_PATH via r.Group(cfg.BasePath).
|
||||
# Frontend entrypoint injects BASE_PATH into nginx + HTML.
|
||||
# ============================================
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
@@ -24,13 +31,13 @@ spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- ${DEPLOY_HOST}
|
||||
secretName: switchboard${DEPLOY_SUFFIX}-tls
|
||||
secretName: switchboard-tls
|
||||
rules:
|
||||
- host: "${DEPLOY_HOST}"
|
||||
http:
|
||||
paths:
|
||||
# API routes → backend
|
||||
- path: /api
|
||||
- path: ${BASE_PATH}/api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
@@ -38,7 +45,7 @@ spec:
|
||||
port:
|
||||
number: 8080
|
||||
# Health check → backend
|
||||
- path: /health
|
||||
- path: ${BASE_PATH}/health
|
||||
pathType: Exact
|
||||
backend:
|
||||
service:
|
||||
@@ -46,15 +53,15 @@ spec:
|
||||
port:
|
||||
number: 8080
|
||||
# WebSocket → backend (traefik handles upgrade automatically)
|
||||
- path: /ws
|
||||
- path: ${BASE_PATH}/ws
|
||||
pathType: Exact
|
||||
backend:
|
||||
service:
|
||||
name: switchboard-be${DEPLOY_SUFFIX}
|
||||
port:
|
||||
number: 8080
|
||||
# Everything else → frontend
|
||||
- path: /
|
||||
# Everything else under this prefix → frontend
|
||||
- path: ${BASE_PATH}/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
|
||||
@@ -90,13 +90,13 @@ echo " ✓ ${MIGRATION_COUNT} migrations applied"
|
||||
LATEST=$(psql -tAc "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" 2>/dev/null || echo "none")
|
||||
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 ──────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
@@ -25,9 +31,30 @@ func Load() *Config {
|
||||
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
|
||||
|
||||
114
server/database/migrations/006_channels_unify.sql
Normal file
114
server/database/migrations/006_channels_unify.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
-- ==========================================
|
||||
-- Migration 006: Unify Chats → Channels
|
||||
-- ==========================================
|
||||
-- "Everything is a channel." Merges the separate chats/channels
|
||||
-- schema into a single unified channel model.
|
||||
--
|
||||
-- The old channels/channel_members/channel_messages tables (from 001)
|
||||
-- were never populated — safe to drop and rebuild on top of chats.
|
||||
-- ==========================================
|
||||
|
||||
-- ── 1. Drop unused legacy channel tables ────
|
||||
-- These were placeholders from 001; no data, no handlers.
|
||||
-- Order matters: drop dependents first.
|
||||
|
||||
-- Remove FK references in tool_usage_log before dropping
|
||||
ALTER TABLE tool_usage_log DROP CONSTRAINT IF EXISTS tool_usage_log_channel_id_fkey;
|
||||
ALTER TABLE tool_usage_log DROP COLUMN IF EXISTS channel_id;
|
||||
|
||||
DROP TABLE IF EXISTS channel_messages CASCADE;
|
||||
DROP TABLE IF EXISTS channel_members CASCADE;
|
||||
DROP TABLE IF EXISTS channels CASCADE;
|
||||
|
||||
-- Drop the old trigger (will re-create after rename)
|
||||
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
|
||||
|
||||
-- ── 2. Rename chats → channels ──────────────
|
||||
|
||||
ALTER TABLE chats RENAME TO channels;
|
||||
ALTER TABLE chat_messages RENAME TO messages;
|
||||
|
||||
-- Rename columns to match new schema
|
||||
ALTER TABLE messages RENAME COLUMN chat_id TO channel_id;
|
||||
|
||||
-- Rename indexes
|
||||
ALTER INDEX IF EXISTS idx_chats_user RENAME TO idx_channels_user;
|
||||
ALTER INDEX IF EXISTS idx_chats_updated RENAME TO idx_channels_updated;
|
||||
ALTER INDEX IF EXISTS idx_chats_tags RENAME TO idx_channels_tags;
|
||||
ALTER INDEX IF EXISTS idx_chat_messages_chat RENAME TO idx_messages_channel;
|
||||
|
||||
-- Rename constraints (PK and FK auto-renamed with table on some PG versions,
|
||||
-- but let's be explicit for the FK)
|
||||
-- Note: PG auto-renames PKs but not FKs or check constraints
|
||||
|
||||
-- Rename the updated_at trigger
|
||||
DROP TRIGGER IF EXISTS chats_updated_at ON channels;
|
||||
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- ── 3. Add channel type + description ───────
|
||||
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'direct';
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
|
||||
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
|
||||
|
||||
-- Backfill: all existing rows are 1:1 AI chats
|
||||
UPDATE channels SET type = 'direct' WHERE type IS NULL;
|
||||
|
||||
-- Index on type for filtered queries
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
|
||||
|
||||
-- ── 4. Add message tree (parent_id) ─────────
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
|
||||
-- Backfill linear parent chains on existing messages.
|
||||
-- Each message's parent is the previous message in the same channel (by created_at).
|
||||
WITH ordered AS (
|
||||
SELECT id, channel_id, created_at,
|
||||
LAG(id) OVER (PARTITION BY channel_id ORDER BY created_at) AS prev_id
|
||||
FROM messages
|
||||
)
|
||||
UPDATE messages m
|
||||
SET parent_id = o.prev_id
|
||||
FROM ordered o
|
||||
WHERE m.id = o.id AND o.prev_id IS NOT NULL AND m.parent_id IS NULL;
|
||||
|
||||
-- ── 5. Add participant columns on messages ──
|
||||
-- Decouples messages from user-only: AI models are participants too.
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_type VARCHAR(10) DEFAULT 'user';
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_id VARCHAR(255);
|
||||
|
||||
COMMENT ON COLUMN messages.participant_type IS 'user or model';
|
||||
COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string';
|
||||
|
||||
-- Backfill: user messages get the channel owner's user_id;
|
||||
-- assistant messages get the model name as participant_id.
|
||||
UPDATE messages m
|
||||
SET participant_type = CASE WHEN m.role = 'assistant' THEN 'model' ELSE 'user' END,
|
||||
participant_id = CASE
|
||||
WHEN m.role = 'assistant' THEN COALESCE(m.model, 'unknown')
|
||||
ELSE (SELECT c.user_id::text FROM channels c WHERE c.id = m.channel_id)
|
||||
END
|
||||
WHERE m.participant_id IS NULL;
|
||||
|
||||
-- ── 6. Update model_routing_log FK ──────────
|
||||
|
||||
-- The FK column was named chat_id — rename it
|
||||
ALTER TABLE model_routing_log RENAME COLUMN chat_id TO channel_id;
|
||||
ALTER INDEX IF EXISTS idx_routing_log_chat RENAME TO idx_routing_log_channel;
|
||||
|
||||
-- message_id FK still valid (messages table was renamed, FK follows)
|
||||
|
||||
-- ── 7. Update tool_usage_log ────────────────
|
||||
-- We already dropped the old channel_id column above.
|
||||
-- Re-add it pointing to the unified channels table.
|
||||
-- Also rename the old chat_id column.
|
||||
|
||||
ALTER TABLE tool_usage_log RENAME COLUMN chat_id TO channel_id;
|
||||
-- The FK auto-follows the table rename, but let's be safe:
|
||||
-- (chat_id FK pointed to chats(id), which is now channels(id) — PG handles this)
|
||||
56
server/database/migrations/007_channel_members_models.sql
Normal file
56
server/database/migrations/007_channel_members_models.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- ==========================================
|
||||
-- Migration 007: Channel Members & Models
|
||||
-- ==========================================
|
||||
-- Membership and model assignment tables for
|
||||
-- multi-user and multi-model channels.
|
||||
-- ==========================================
|
||||
|
||||
-- ── Channel Members ─────────────────────────
|
||||
-- Who is in this channel (human participants).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) DEFAULT 'member', -- owner, admin, member
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_members_channel ON channel_members(channel_id);
|
||||
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
|
||||
|
||||
-- Backfill: existing channels are 1:1, so the channel owner is the sole member.
|
||||
INSERT INTO channel_members (channel_id, user_id, role)
|
||||
SELECT id, user_id, 'owner'
|
||||
FROM channels
|
||||
WHERE user_id IS NOT NULL
|
||||
ON CONFLICT (channel_id, user_id) DO NOTHING;
|
||||
|
||||
-- ── Channel Models ──────────────────────────
|
||||
-- Which AI models are assigned to this channel.
|
||||
-- For direct chats this is one model; for group/channel
|
||||
-- there can be multiple with @mention routing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_models (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
model_id VARCHAR(255) NOT NULL, -- e.g. 'claude-sonnet-4'
|
||||
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
|
||||
display_name VARCHAR(100), -- optional alias in this channel
|
||||
system_prompt TEXT, -- per-model system prompt override
|
||||
settings JSONB DEFAULT '{}'::jsonb, -- temperature, max_tokens overrides
|
||||
is_default BOOLEAN DEFAULT false, -- auto-complete (no @mention needed)
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, model_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
-- Backfill: existing channels have a model in the channels.model column.
|
||||
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
|
||||
SELECT id, model, api_config_id, true
|
||||
FROM channels
|
||||
WHERE model IS NOT NULL AND model != ''
|
||||
ON CONFLICT (channel_id, model_id) DO NOTHING;
|
||||
29
server/database/migrations/008_channel_cursors.sql
Normal file
29
server/database/migrations/008_channel_cursors.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- ==========================================
|
||||
-- Migration 008: Channel Cursors
|
||||
-- ==========================================
|
||||
-- Tracks each user's active branch position
|
||||
-- per channel. Essential for conversation forking.
|
||||
-- ==========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_cursors_channel ON channel_cursors(channel_id);
|
||||
CREATE INDEX idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
-- Backfill: set cursor to the last message in each channel for the owner.
|
||||
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
|
||||
SELECT c.id, c.user_id, (
|
||||
SELECT m.id FROM messages m
|
||||
WHERE m.channel_id = c.id
|
||||
ORDER BY m.created_at DESC LIMIT 1
|
||||
)
|
||||
FROM channels c
|
||||
WHERE c.user_id IS NOT NULL
|
||||
ON CONFLICT (channel_id, user_id) DO NOTHING;
|
||||
57
server/database/migrations/009_folders_projects.sql
Normal file
57
server/database/migrations/009_folders_projects.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- ==========================================
|
||||
-- Migration 009: Folders & Projects
|
||||
-- ==========================================
|
||||
-- Organizational structures for channels.
|
||||
-- Folders are simple containers; projects are
|
||||
-- tagged collections that can span folders.
|
||||
-- ==========================================
|
||||
|
||||
-- ── Folders ─────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, name, parent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_user ON folders(user_id);
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||
|
||||
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Add folder_id to channels (replaces the old text folder column)
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES folders(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id);
|
||||
|
||||
-- ── Projects ────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7), -- hex color for UI badge
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_projects_user ON projects(user_id);
|
||||
|
||||
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Junction: channels can belong to multiple projects
|
||||
CREATE TABLE IF NOT EXISTS project_channels (
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_channels_channel ON project_channels(channel_id);
|
||||
32
server/database/migrations/010_banners.sql
Normal file
32
server/database/migrations/010_banners.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- ==========================================
|
||||
-- Migration 010: Environment Banners
|
||||
-- ==========================================
|
||||
-- Environment banners for deployment context
|
||||
-- (dev, staging, production, etc). Stored in
|
||||
-- global_settings with a dedicated key.
|
||||
-- ==========================================
|
||||
|
||||
-- Seed default banner config (disabled).
|
||||
-- Schema: { enabled, text, position, bg, fg }
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('banner', '{
|
||||
"enabled": false,
|
||||
"text": "",
|
||||
"position": "both",
|
||||
"bg": "#007a33",
|
||||
"fg": "#ffffff"
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Banner presets for quick selection in admin UI.
|
||||
-- Generic environment labels — admins can set custom text.
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('banner_presets', '{
|
||||
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
|
||||
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
|
||||
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
|
||||
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
|
||||
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
|
||||
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
18
server/database/migrations/011_banner_presets_fix.sql
Normal file
18
server/database/migrations/011_banner_presets_fix.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- ==========================================
|
||||
-- Migration 011: Replace banner presets
|
||||
-- ==========================================
|
||||
-- Replaces legacy presets with
|
||||
-- generic environment labels. Existing databases
|
||||
-- that ran 010 have the old presets; this overwrites.
|
||||
-- ==========================================
|
||||
|
||||
UPDATE global_settings
|
||||
SET value = '{
|
||||
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
|
||||
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
|
||||
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
|
||||
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
|
||||
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
|
||||
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
|
||||
}'::jsonb
|
||||
WHERE key = 'banner_presets';
|
||||
40
server/go.sum
Normal file
40
server/go.sum
Normal file
@@ -0,0 +1,40 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
@@ -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)
|
||||
|
||||
@@ -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", `{}`},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
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"})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ type createMessageRequest struct {
|
||||
|
||||
type messageResponse struct {
|
||||
ID string `json:"id"`
|
||||
ChatID string `json:"chat_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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,73 +28,110 @@ 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"`
|
||||
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"
|
||||
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
|
||||
// ── 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"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
Color string `json:"color,omitempty" db:"color"`
|
||||
}
|
||||
|
||||
// ── API Config ──────────────────────────────
|
||||
|
||||
type APIConfig 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.
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
// ── Notes (future Phase 2) ──────────────────
|
||||
|
||||
// ChannelMember represents a member of a channel
|
||||
type ChannelMember 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"
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Note represents a user note
|
||||
type Note struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
@@ -105,26 +142,16 @@ type Note struct {
|
||||
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"`
|
||||
@@ -136,7 +163,6 @@ type Settings struct {
|
||||
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"`
|
||||
|
||||
@@ -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 ────────────────────────────────── */
|
||||
|
||||
|
||||
200
src/index.html
200
src/index.html
@@ -3,17 +3,23 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="%%BASE_HREF%%">
|
||||
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||
<title>Chat Switchboard</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||
<link rel="stylesheet" href="css/styles.css?v=0.5.4">
|
||||
<link rel="stylesheet" href="css/styles.css?v=0.6.2">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── App Shell ───────────────────────────── -->
|
||||
<div id="appContainer" class="app" style="display:none">
|
||||
|
||||
<!-- Environment Banner (top) -->
|
||||
<div class="banner banner-top" id="bannerTop"></div>
|
||||
|
||||
<div class="app-body">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-top">
|
||||
@@ -22,10 +28,29 @@
|
||||
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||
<span class="sb-label brand-text">Chat Switchboard</span>
|
||||
</button>
|
||||
<button class="sb-btn" id="newChatBtn" title="New Chat">
|
||||
<div class="split-btn" style="position:relative">
|
||||
<button class="split-btn-main" id="newChatBtn" title="New Chat">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
<span class="sb-label">New Chat</span>
|
||||
</button>
|
||||
<button class="split-btn-drop" id="newChatDropBtn" title="More options">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="split-dropdown" id="newChatDropdown">
|
||||
<button class="split-dropdown-item" onclick="newChat()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
New Chat
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Group Chat <span class="dd-hint">soon</span>
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
New Channel <span class="dd-hint">soon</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-chats" id="chatHistory"></div>
|
||||
@@ -95,28 +120,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div><!-- .app-body -->
|
||||
|
||||
<!-- Environment Banner (bottom) -->
|
||||
<div class="banner banner-bottom" id="bannerBottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Auth Splash ─────────────────────────── -->
|
||||
<div class="splash" id="splashGate">
|
||||
<div class="splash-card">
|
||||
<div class="splash-brand">
|
||||
<div class="splash-logo">🔀</div>
|
||||
<h1>Chat Switchboard</h1>
|
||||
<p>Multi-Model AI Chat</p>
|
||||
<div class="splash-hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-logo-row">
|
||||
<svg class="hero-logo-mark" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#1a1a2e"/>
|
||||
<path d="M12 48 L36 16" stroke="#6c9fff" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<polygon points="42,12 34,14 38,22" fill="#6c9fff"/>
|
||||
<path d="M12 16 L24 30" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M30 38 L36 48" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<polygon points="42,52 34,50 38,42" fill="#a78bfa"/>
|
||||
</svg>
|
||||
<div class="hero-wordmark">Chat <span>Switchboard</span></div>
|
||||
</div>
|
||||
<h1 class="hero-headline">One interface.<br>Every AI model.</h1>
|
||||
<p class="hero-sub">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
|
||||
<div class="hero-features">
|
||||
<div class="hero-pill accent"><span class="pill-icon">⚡</span> Streaming responses</div>
|
||||
<div class="hero-pill purple"><span class="pill-icon">🔀</span> Multi-provider routing</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🏠</span> Self-hosted</div>
|
||||
<div class="hero-pill"><span class="pill-icon">✈️</span> Air-gap ready</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🧠</span> Thinking blocks</div>
|
||||
</div>
|
||||
<div class="hero-version">v0.6.2</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="splash-auth">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card-header">
|
||||
<h2>Welcome back</h2>
|
||||
<p>Sign in to continue to your workspace</p>
|
||||
</div>
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
|
||||
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
|
||||
</div>
|
||||
<div id="authLoginForm">
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" onkeydown="if(event.key==='Enter')handleLogin()"></div>
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username" placeholder="you@example.com"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" placeholder="••••••••" onkeydown="if(event.key==='Enter')handleLogin()"></div>
|
||||
</div>
|
||||
<div id="authRegisterForm" style="display:none">
|
||||
<div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" onkeydown="if(event.key==='Enter')handleRegister()"></div>
|
||||
<div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username" placeholder="Choose a username"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email" placeholder="you@example.com"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" placeholder="Min 8 characters" onkeydown="if(event.key==='Enter')handleRegister()"></div>
|
||||
</div>
|
||||
<div class="auth-error" id="authError"></div>
|
||||
<div class="splash-error" id="splashError"></div>
|
||||
@@ -124,6 +179,10 @@
|
||||
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
|
||||
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
|
||||
</div>
|
||||
<div class="auth-footer">
|
||||
<p>Self-hosted AI chat · Your keys, your data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +190,14 @@
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
||||
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
||||
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- General Tab -->
|
||||
<div class="settings-tab-content" id="settingsGeneralTab">
|
||||
<section class="settings-section">
|
||||
<h3>Profile</h3>
|
||||
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
|
||||
@@ -156,8 +222,10 @@
|
||||
</div>
|
||||
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Providers Tab -->
|
||||
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
|
||||
<section class="settings-section">
|
||||
<h3>API Providers</h3>
|
||||
<div id="providerList"></div>
|
||||
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
|
||||
<div id="providerAddForm" style="display:none">
|
||||
@@ -169,6 +237,15 @@
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="providerCreateBtn">Save</button><button class="btn-small" id="providerCancelBtn">Cancel</button></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="userProvidersDisabled" class="settings-notice" style="display:none">
|
||||
<span>⚠️</span> User-configured providers have been disabled by the administrator.
|
||||
</div>
|
||||
</div>
|
||||
<!-- Models Tab -->
|
||||
<div class="settings-tab-content" id="settingsModelsTab" style="display:none">
|
||||
<p class="section-hint">Models available from your providers and global configs.</p>
|
||||
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
||||
</div>
|
||||
@@ -186,12 +263,89 @@
|
||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="admin-tab-content" id="adminUsersTab"><div id="adminUserList"></div></div>
|
||||
<div class="admin-tab-content" id="adminProvidersTab" style="display:none"><div id="adminProviderList"></div></div>
|
||||
<div class="admin-tab-content" id="adminModelsTab" style="display:none"><div id="adminModelList"></div></div>
|
||||
<div class="admin-tab-content" id="adminUsersTab">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminAddUserBtn">+ Add User</button>
|
||||
</div>
|
||||
<div id="adminAddUserForm" style="display:none" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="adminNewEmail" placeholder="email@example.com"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>
|
||||
<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>
|
||||
</div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateUserBtn">Create</button><button class="btn-small" id="adminCancelUserBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminUserList"></div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
|
||||
</div>
|
||||
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input type="text" id="adminProvName" placeholder="OpenAI Production"></div>
|
||||
<div class="form-group"><label>Provider</label><select id="adminProvType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Endpoint</label><input type="text" id="adminProvEndpoint" placeholder="https://api.openai.com/v1"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>API Key</label><input type="password" id="adminProvKey" placeholder="sk-..."></div>
|
||||
<div class="form-group"><label>Default Model</label><input type="text" id="adminProvModel" placeholder="gpt-4o"></div>
|
||||
</div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateProvBtn">Save</button><button class="btn-small" id="adminCancelProvBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminProviderList"></div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button>
|
||||
<button class="btn-small" onclick="bulkToggleModels(true)">Enable All</button>
|
||||
<button class="btn-small" onclick="bulkToggleModels(false)">Disable All</button>
|
||||
<span class="admin-hint" id="adminModelsHint"></span>
|
||||
</div>
|
||||
<div id="adminModelList"></div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow registration</label>
|
||||
<button class="btn-primary btn-small" id="adminSaveSettings">Save</button>
|
||||
<section class="settings-section">
|
||||
<h3>Registration</h3>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow new user registration</label>
|
||||
<div class="form-group" style="margin-top: 8px;">
|
||||
<label>Default state for new accounts</label>
|
||||
<select id="adminRegDefaultState">
|
||||
<option value="active">Active — immediate access</option>
|
||||
<option value="pending">Pending — require admin approval</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>User Providers</h3>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle" checked> Allow users to configure their own API providers</label>
|
||||
<p class="section-hint">When disabled, users can only use admin-configured global providers.</p>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>Environment Banner</h3>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>
|
||||
<div id="bannerConfigFields" style="display:none">
|
||||
<div class="form-group">
|
||||
<label>Preset</label>
|
||||
<select id="adminBannerPreset"><option value="">Custom</option></select>
|
||||
</div>
|
||||
<div class="form-group"><label>Banner Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Position</label>
|
||||
<select id="adminBannerPosition"><option value="both">Top & Bottom</option><option value="top">Top only</option><option value="bottom">Bottom only</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Background</label><div class="color-input-wrap"><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" class="color-hex" value="#007a33" maxlength="7"></div></div>
|
||||
<div class="form-group"><label>Text Color</label><div class="color-input-wrap"><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" class="color-hex" value="#ffffff" maxlength="7"></div></div>
|
||||
</div>
|
||||
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
|
||||
</div>
|
||||
</section>
|
||||
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
||||
</div>
|
||||
@@ -235,10 +389,10 @@
|
||||
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
|
||||
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
|
||||
|
||||
<script src="js/debug.js?v=0.5.4"></script>
|
||||
<script src="js/events.js?v=0.5.4"></script>
|
||||
<script src="js/api.js?v=0.5.4"></script>
|
||||
<script src="js/ui.js?v=0.5.4"></script>
|
||||
<script src="js/app.js?v=0.5.4"></script>
|
||||
<script src="js/debug.js?v=0.6.2"></script>
|
||||
<script src="js/events.js?v=0.6.2"></script>
|
||||
<script src="js/api.js?v=0.6.2"></script>
|
||||
<script src="js/ui.js?v=0.6.2"></script>
|
||||
<script src="js/app.js?v=0.6.2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 <script>
|
||||
// at container startup. All API paths are
|
||||
// prefixed automatically.
|
||||
// ==========================================
|
||||
|
||||
const BASE = window.__BASE__ || '';
|
||||
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
|
||||
|
||||
const API = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
@@ -15,7 +22,7 @@ const API = {
|
||||
|
||||
loadTokens() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('sb_auth') || 'null');
|
||||
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
|
||||
if (saved) {
|
||||
this.accessToken = saved.accessToken || null;
|
||||
this.refreshToken = saved.refreshToken || null;
|
||||
@@ -25,7 +32,7 @@ const API = {
|
||||
},
|
||||
|
||||
saveTokens() {
|
||||
localStorage.setItem('sb_auth', JSON.stringify({
|
||||
localStorage.setItem(_storageKey, JSON.stringify({
|
||||
accessToken: this.accessToken,
|
||||
refreshToken: this.refreshToken,
|
||||
user: this.user
|
||||
@@ -36,7 +43,7 @@ const API = {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem('sb_auth');
|
||||
localStorage.removeItem(_storageKey);
|
||||
},
|
||||
|
||||
get isAuthed() { return !!this.accessToken; },
|
||||
@@ -45,7 +52,7 @@ const API = {
|
||||
// ── Auth ─────────────────────────────────
|
||||
|
||||
async health() {
|
||||
const resp = await fetch('/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||||
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||||
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
|
||||
return resp.json();
|
||||
},
|
||||
@@ -58,7 +65,7 @@ const API = {
|
||||
|
||||
async register(username, email, password) {
|
||||
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
|
||||
this._setAuth(data);
|
||||
if (!data.pending) this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -92,36 +99,39 @@ const API = {
|
||||
this.saveTokens();
|
||||
},
|
||||
|
||||
// ── Chats ────────────────────────────────
|
||||
// ── Channels ──────────────────────────────
|
||||
|
||||
listChats(page = 1, perPage = 100) {
|
||||
return this._get(`/api/v1/chats?page=${page}&per_page=${perPage}`);
|
||||
listChannels(page = 1, perPage = 100, type = '') {
|
||||
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
|
||||
if (type) url += `&type=${type}`;
|
||||
return this._get(url);
|
||||
},
|
||||
createChat(title, model, systemPrompt) {
|
||||
const body = { title };
|
||||
createChannel(title, model, systemPrompt, type = 'direct') {
|
||||
const body = { title, type };
|
||||
if (model) body.model = model;
|
||||
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||
return this._post('/api/v1/chats', body);
|
||||
return this._post('/api/v1/channels', body);
|
||||
},
|
||||
getChat(id) { return this._get(`/api/v1/chats/${id}`); },
|
||||
updateChat(id, updates) { return this._put(`/api/v1/chats/${id}`, updates); },
|
||||
deleteChat(id) { return this._del(`/api/v1/chats/${id}`); },
|
||||
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
|
||||
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
listMessages(chatId, page = 1, perPage = 200) {
|
||||
return this._get(`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`);
|
||||
listMessages(channelId, page = 1, perPage = 200) {
|
||||
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(chatId, content, model, signal) {
|
||||
const body = { chat_id: chatId, content, stream: true };
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId) {
|
||||
const body = { channel_id: channelId, content, stream: true };
|
||||
if (model) body.model = model;
|
||||
if (apiConfigId) body.api_config_id = apiConfigId;
|
||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||
|
||||
let resp = await fetch('/api/v1/chat/completions', {
|
||||
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
@@ -130,7 +140,7 @@ const API = {
|
||||
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) {
|
||||
resp = await fetch('/api/v1/chat/completions', {
|
||||
resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
@@ -182,6 +192,7 @@ const API = {
|
||||
adminToggleActive(id, active) { return this._put(`/api/v1/admin/users/${id}/active`, { is_active: active }); },
|
||||
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
|
||||
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
|
||||
getPublicSettings() { return this._get('/api/v1/settings/public'); },
|
||||
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
|
||||
adminGetStats() { return this._get('/api/v1/admin/stats'); },
|
||||
|
||||
@@ -196,8 +207,12 @@ const API = {
|
||||
adminListModels() { return this._get('/api/v1/admin/models'); },
|
||||
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
|
||||
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
|
||||
adminBulkUpdateModels(isEnabled) { return this._put('/api/v1/admin/models/bulk', { is_enabled: isEnabled }); },
|
||||
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||||
|
||||
// ── User Models ──────────────────────────
|
||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||
|
||||
// ── HTTP Internals ───────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
@@ -230,7 +245,7 @@ const API = {
|
||||
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
|
||||
const resp = await fetch(path, opts);
|
||||
const resp = await fetch(BASE + path, opts);
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||
|
||||
269
src/js/app.js
269
src/js/app.js
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Application (v0.5.4)
|
||||
// Chat Switchboard – Application (v0.6.1)
|
||||
// ==========================================
|
||||
|
||||
const App = {
|
||||
@@ -8,6 +8,7 @@ const App = {
|
||||
models: [],
|
||||
isGenerating: false,
|
||||
abortController: null,
|
||||
serverSettings: {},
|
||||
|
||||
settings: {
|
||||
model: '',
|
||||
@@ -59,6 +60,7 @@ async function startApp() {
|
||||
await loadSettings();
|
||||
await loadChats();
|
||||
await fetchModels();
|
||||
await initBanners();
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateUser();
|
||||
@@ -67,7 +69,7 @@ async function startApp() {
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||
try {
|
||||
Events.connect('/ws');
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
} catch (e) {
|
||||
console.warn('EventBus WebSocket not available:', e.message);
|
||||
}
|
||||
@@ -206,10 +208,11 @@ async function fetchModels() {
|
||||
|
||||
async function loadChats() {
|
||||
try {
|
||||
const resp = await API.listChats();
|
||||
const resp = await API.listChannels(1, 100, 'direct');
|
||||
App.chats = (resp.data || []).map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
type: c.type || 'direct',
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
messages: [],
|
||||
@@ -255,7 +258,7 @@ async function newChat() {
|
||||
async function deleteChat(chatId) {
|
||||
if (!confirm('Delete this chat?')) return;
|
||||
try {
|
||||
await API.deleteChat(chatId);
|
||||
await API.deleteChannel(chatId);
|
||||
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||
if (App.currentChatId === chatId) newChat();
|
||||
UI.renderChatList();
|
||||
@@ -277,8 +280,8 @@ async function sendMessage() {
|
||||
if (!App.currentChatId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||
const resp = await API.createChat(title, model, App.settings.systemPrompt);
|
||||
const chat = { id: resp.id, title: resp.title, model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
UI.renderChatList();
|
||||
@@ -295,8 +298,9 @@ async function sendMessage() {
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
const modelInfo = App.models.find(m => m.id === model);
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal);
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId);
|
||||
const assistantContent = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
|
||||
chat.messageCount = chat.messages.length;
|
||||
@@ -340,12 +344,13 @@ async function regenerate() {
|
||||
if (lastUser.role !== 'user') return;
|
||||
|
||||
const model = document.getElementById('modelSelect').value || App.settings.model;
|
||||
const modelInfo = App.models.find(m => m.id === model);
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal);
|
||||
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId);
|
||||
const content = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
|
||||
UI.renderMessages(chat.messages);
|
||||
@@ -396,7 +401,18 @@ async function handleRegister() {
|
||||
if (!username || !email || !password) return setAuthError('Fill in all fields');
|
||||
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
|
||||
setAuthLoading(true);
|
||||
try { await API.register(username, email, password); await startApp(); }
|
||||
try {
|
||||
const resp = await API.register(username, email, password);
|
||||
if (resp.pending) {
|
||||
setAuthError('');
|
||||
const errEl = document.getElementById('authError');
|
||||
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
|
||||
errEl.style.color = 'var(--accent)';
|
||||
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
|
||||
} else {
|
||||
await startApp();
|
||||
}
|
||||
}
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
@@ -418,6 +434,13 @@ function switchAuthTab(tab) {
|
||||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
|
||||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
|
||||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
|
||||
const hdr = document.querySelector('.auth-card-header');
|
||||
if (hdr) {
|
||||
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
hdr.querySelector('p').textContent = tab === 'login'
|
||||
? 'Sign in to continue to your workspace'
|
||||
: 'Get started with Chat Switchboard';
|
||||
}
|
||||
setAuthError('');
|
||||
}
|
||||
|
||||
@@ -440,6 +463,17 @@ function initListeners() {
|
||||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||
|
||||
// Split button dropdown
|
||||
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.getElementById('newChatDropdown').classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.split-btn')) {
|
||||
document.getElementById('newChatDropdown').classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// User flyout
|
||||
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -451,8 +485,8 @@ function initListeners() {
|
||||
|
||||
// Flyout items
|
||||
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
|
||||
document.getElementById('menuAdmin').addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
||||
document.getElementById('menuDebug').addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||||
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
||||
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||||
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
||||
|
||||
// Chat actions
|
||||
@@ -465,7 +499,7 @@ function initListeners() {
|
||||
if (this.value) { App.settings.model = this.value; saveSettings(); }
|
||||
UI.updateCapabilityBadges();
|
||||
});
|
||||
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
|
||||
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
UI.toast(`Loaded ${App.models.length} models`, 'success');
|
||||
});
|
||||
@@ -473,6 +507,9 @@ function initListeners() {
|
||||
// Settings modal
|
||||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
|
||||
});
|
||||
document.getElementById('settingsModel').addEventListener('change', function() {
|
||||
const model = App.models.find(m => m.id === this.value);
|
||||
const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
|
||||
@@ -503,12 +540,55 @@ function initListeners() {
|
||||
}
|
||||
});
|
||||
|
||||
// Admin modal
|
||||
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
|
||||
// Admin modal (elements may not exist for non-admin users)
|
||||
document.getElementById('adminCloseBtn')?.addEventListener('click', UI.closeAdmin);
|
||||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
|
||||
});
|
||||
document.getElementById('adminSaveSettings').addEventListener('click', handleSaveAdminSettings);
|
||||
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
|
||||
|
||||
// Admin — users
|
||||
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddUserForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
|
||||
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
|
||||
|
||||
// Admin — providers
|
||||
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddProviderForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => { document.getElementById('adminAddProviderForm').style.display = 'none'; });
|
||||
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
|
||||
|
||||
// Admin — models
|
||||
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
|
||||
|
||||
// Admin — banner controls
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
|
||||
const preset = UI._bannerPresets[e.target.value];
|
||||
if (preset) {
|
||||
document.getElementById('adminBannerText').value = preset.text || '';
|
||||
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerFg').value = preset.fg || '#ffffff';
|
||||
document.getElementById('adminBannerFgHex').value = preset.fg || '#ffffff';
|
||||
UI.updateBannerPreview();
|
||||
}
|
||||
});
|
||||
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
|
||||
});
|
||||
// Sync color pickers with hex inputs
|
||||
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
|
||||
// Input
|
||||
const input = document.getElementById('messageInput');
|
||||
@@ -590,11 +670,168 @@ async function deleteProvider(id, name) {
|
||||
|
||||
async function handleSaveAdminSettings() {
|
||||
try {
|
||||
// Registration
|
||||
const reg = document.getElementById('adminRegToggle').checked;
|
||||
await API.adminUpdateSetting('registration_enabled', { value: reg });
|
||||
UI.toast('Admin settings saved', 'success');
|
||||
|
||||
// Registration default state
|
||||
const regState = document.getElementById('adminRegDefaultState').value;
|
||||
await API.adminUpdateSetting('registration_default_state', { value: regState });
|
||||
|
||||
// User providers
|
||||
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
|
||||
await API.adminUpdateSetting('user_providers_enabled', { value: userProviders });
|
||||
|
||||
// Banner
|
||||
const banner = {
|
||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||
text: document.getElementById('adminBannerText').value,
|
||||
position: document.getElementById('adminBannerPosition').value,
|
||||
bg: document.getElementById('adminBannerBg').value,
|
||||
fg: document.getElementById('adminBannerFg').value,
|
||||
};
|
||||
await API.adminUpdateSetting('banner', { value: banner });
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply banner
|
||||
initBanners();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Admin Actions ────────────────────────────
|
||||
|
||||
function _adminScroll() { return document.querySelector('#adminModal .modal-body'); }
|
||||
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
|
||||
async function toggleUserActive(id, active) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function toggleUserRole(id, role) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function deleteUser(id, username) {
|
||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function createAdminUser() {
|
||||
try {
|
||||
const u = document.getElementById('adminNewUsername').value.trim();
|
||||
const e = document.getElementById('adminNewEmail').value.trim();
|
||||
const p = document.getElementById('adminNewPassword').value;
|
||||
const r = document.getElementById('adminNewRole').value;
|
||||
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
|
||||
await API.adminCreateUser(u, e, p, r);
|
||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
||||
UI.toast('User created', 'success');
|
||||
await UI.loadAdminUsers();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteGlobalProvider(id) {
|
||||
if (!confirm('Delete this global provider?')) return;
|
||||
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function createGlobalProvider() {
|
||||
try {
|
||||
const name = document.getElementById('adminProvName').value.trim();
|
||||
const prov = document.getElementById('adminProvType').value;
|
||||
const ep = document.getElementById('adminProvEndpoint').value.trim();
|
||||
const key = document.getElementById('adminProvKey').value;
|
||||
const model = document.getElementById('adminProvModel').value.trim();
|
||||
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
|
||||
await API.adminCreateGlobalConfig(name, prov, ep, key, model);
|
||||
document.getElementById('adminAddProviderForm').style.display = 'none';
|
||||
UI.toast('Provider added', 'success');
|
||||
await UI.loadAdminProviders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function fetchAdminModels() {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = 'Fetching...';
|
||||
try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
async function toggleModel(id, enabled) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdateModel(id, { is_enabled: enabled });
|
||||
await UI.loadAdminModels();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkToggleModels(enabled) {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = enabled ? 'Enabling all...' : 'Disabling all...';
|
||||
try {
|
||||
const resp = await API.adminBulkUpdateModels(enabled);
|
||||
hint.textContent = `${resp.count || 'All'} models ${enabled ? 'enabled' : 'disabled'}`;
|
||||
setTimeout(() => { hint.textContent = ''; }, 3000);
|
||||
await UI.loadAdminModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
|
||||
// ── Banners ──────────────────────────────────
|
||||
|
||||
async function initBanners() {
|
||||
try {
|
||||
const data = await API.getPublicSettings?.() || [];
|
||||
const settings = data.settings || data || [];
|
||||
const arr = Array.isArray(settings) ? settings : [];
|
||||
|
||||
// Store globally for user-facing checks — unwrap {value: X} wrapper
|
||||
App.serverSettings = {};
|
||||
arr.forEach(s => {
|
||||
const v = s.value;
|
||||
App.serverSettings[s.key] = (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||
});
|
||||
|
||||
const root = document.documentElement;
|
||||
const banner = App.serverSettings.banner;
|
||||
|
||||
// Clear previous banner state
|
||||
['bannerTop', 'bannerBottom'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.classList.remove('active'); el.textContent = ''; }
|
||||
});
|
||||
root.style.setProperty('--banner-top-height', '0px');
|
||||
root.style.setProperty('--banner-bottom-height', '0px');
|
||||
|
||||
if (!banner || !banner.enabled) return;
|
||||
|
||||
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
|
||||
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
|
||||
|
||||
const text = banner.text || '';
|
||||
const pos = banner.position || 'both';
|
||||
|
||||
if (pos === 'top' || pos === 'both') {
|
||||
const el = document.getElementById('bannerTop');
|
||||
el.textContent = text;
|
||||
el.classList.add('active');
|
||||
root.style.setProperty('--banner-top-height', '22px');
|
||||
}
|
||||
if (pos === 'bottom' || pos === 'both') {
|
||||
const el = document.getElementById('bannerBottom');
|
||||
el.textContent = text;
|
||||
el.classList.add('active');
|
||||
root.style.setProperty('--banner-bottom-height', '22px');
|
||||
}
|
||||
} catch (e) {
|
||||
// Banners are optional — non-admin users may not have access to settings
|
||||
console.debug('Banner init skipped:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
@@ -191,7 +191,7 @@ const Events = {
|
||||
}
|
||||
|
||||
// Add auth token as query param (WebSocket doesn't support headers)
|
||||
const tokens = JSON.parse(localStorage.getItem('sb_auth') || '{}');
|
||||
const tokens = JSON.parse(localStorage.getItem(typeof _storageKey !== 'undefined' ? _storageKey : 'sb_auth') || '{}');
|
||||
if (tokens.access) {
|
||||
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
|
||||
} else {
|
||||
|
||||
185
src/js/ui.js
185
src/js/ui.js
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI (v0.5.4)
|
||||
// Chat Switchboard – UI (v0.6.1)
|
||||
// ==========================================
|
||||
|
||||
const UI = {
|
||||
@@ -337,11 +337,33 @@ const UI = {
|
||||
}
|
||||
|
||||
UI.loadProfileIntoSettings();
|
||||
UI.loadProviderList();
|
||||
UI.switchSettingsTab('general');
|
||||
document.getElementById('settingsModal').classList.add('active');
|
||||
},
|
||||
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
|
||||
|
||||
switchSettingsTab(tab) {
|
||||
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
|
||||
document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none');
|
||||
const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
|
||||
if (panel) panel.style.display = '';
|
||||
|
||||
if (tab === 'providers') {
|
||||
UI.loadProviderList();
|
||||
UI.checkUserProvidersAllowed();
|
||||
}
|
||||
if (tab === 'models') UI.loadUserModels();
|
||||
},
|
||||
|
||||
async checkUserProvidersAllowed() {
|
||||
const notice = document.getElementById('userProvidersDisabled');
|
||||
const addBtn = document.getElementById('providerShowAddBtn');
|
||||
if (!notice) return;
|
||||
const allowed = App.serverSettings?.user_providers_enabled !== false;
|
||||
notice.style.display = allowed ? 'none' : '';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
async loadProfileIntoSettings() {
|
||||
try {
|
||||
const p = await API.getProfile();
|
||||
@@ -398,64 +420,173 @@ const UI = {
|
||||
if (tab === 'stats') await this.loadAdminStats();
|
||||
if (tab === 'providers') await this.loadAdminProviders();
|
||||
if (tab === 'models') await this.loadAdminModels();
|
||||
if (tab === 'settings') await this.loadAdminSettings();
|
||||
},
|
||||
|
||||
async loadAdminUsers() {
|
||||
async loadAdminUsers(quiet) {
|
||||
const el = document.getElementById('adminUserList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.adminListUsers();
|
||||
const users = resp.data || [];
|
||||
el.innerHTML = users.map(u => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-row${!u.is_active ? ' user-inactive' : ''}">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
|
||||
${!u.is_active ? '<span class="badge-inactive">disabled</span>' : ''}</div>
|
||||
${!u.is_active ? '<span class="badge-pending">pending</span>' : ''}</div>
|
||||
<div class="admin-user-email">${esc(u.email)}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button onclick="toggleUserActive('${u.id}', ${!u.is_active})">${u.is_active ? 'Disable' : 'Approve'}</button>
|
||||
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
|
||||
</div>
|
||||
</div>`).join('') || '<div class="empty-hint">No users</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminStats() {
|
||||
const el = document.getElementById('adminStats');
|
||||
try { const s = await API.adminGetStats(); el.innerHTML = `<pre>${esc(JSON.stringify(s, null, 2))}</pre>`; }
|
||||
catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminProviders() {
|
||||
const el = document.getElementById('adminProviderList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListGlobalConfigs();
|
||||
const list = data.configs || data.data || data || [];
|
||||
el.innerHTML = (Array.isArray(list) ? list : []).map(c => `
|
||||
<div class="provider-row"><span class="provider-name">${esc(c.name)}</span><span class="provider-meta">${esc(c.provider)}</span></div>
|
||||
`).join('') || '<div class="empty-hint">No global providers</div>';
|
||||
const s = await API.adminGetStats();
|
||||
const labels = { total_users: 'Users', active_users: 'Active Users', api_configs: 'Providers', total_channels: 'Channels', total_messages: 'Messages' };
|
||||
el.innerHTML = '<div class="stats-grid">' +
|
||||
Object.entries(s).map(([k, v]) => `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
|
||||
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminModels() {
|
||||
async loadAdminProviders(quiet) {
|
||||
const el = document.getElementById('adminProviderList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListGlobalConfigs();
|
||||
const list = data.configs || data.data || data || [];
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
el.innerHTML = arr.map(c => `
|
||||
<div class="admin-provider-row">
|
||||
<span class="provider-name">${esc(c.name)}</span>
|
||||
<span class="provider-meta">${esc(c.provider)}</span>
|
||||
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
|
||||
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
|
||||
</div>`).join('') || '<div class="empty-hint">No global providers — add one above</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminModels(quiet) {
|
||||
const el = document.getElementById('adminModelList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListModels();
|
||||
const list = data.models || data.data || data || [];
|
||||
el.innerHTML = (Array.isArray(list) ? list : []).map(m => {
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
el.innerHTML = arr.map(m => {
|
||||
const caps = m.capabilities || {};
|
||||
const badges = [];
|
||||
if (caps.max_output_tokens > 0) {
|
||||
badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K out</span>`);
|
||||
}
|
||||
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent">🧠</span>');
|
||||
return `<div class="admin-model-row">
|
||||
<span>${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-name">${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-caps-inline">${badges.join('')}</span>
|
||||
<span class="provider-meta">${esc(m.provider_name || '')}</span>
|
||||
<span>${m.is_enabled ? '✅' : '⬜'}</span>
|
||||
<button class="admin-model-toggle ${m.is_enabled ? 'enabled' : ''}" onclick="toggleModel('${m.id}', ${!m.is_enabled})">${m.is_enabled ? '✓ Enabled' : 'Disabled'}</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No models — use Fetch Models to sync from providers</div>';
|
||||
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminSettings() {
|
||||
try {
|
||||
const data = await API.adminGetSettings();
|
||||
const settings = data.settings || data || [];
|
||||
const arr = Array.isArray(settings) ? settings : [];
|
||||
|
||||
// Unwrap {value: X} wrapper from backend storage
|
||||
const get = (key, fallback) => {
|
||||
const s = arr.find(s => s.key === key);
|
||||
if (!s) return fallback;
|
||||
const v = s.value;
|
||||
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||
};
|
||||
|
||||
// Registration
|
||||
document.getElementById('adminRegToggle').checked = get('registration_enabled', true) !== false;
|
||||
|
||||
// Registration default state
|
||||
document.getElementById('adminRegDefaultState').value = get('registration_default_state', 'active') || 'active';
|
||||
|
||||
// User providers
|
||||
document.getElementById('adminUserProvidersToggle').checked = get('user_providers_enabled', true) !== false;
|
||||
|
||||
// Banner
|
||||
const banner = get('banner', {}) || {};
|
||||
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
|
||||
document.getElementById('adminBannerText').value = banner.text || '';
|
||||
document.getElementById('adminBannerPosition').value = banner.position || 'both';
|
||||
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
|
||||
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
|
||||
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
|
||||
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
|
||||
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
|
||||
UI.updateBannerPreview();
|
||||
|
||||
// Load presets
|
||||
const presets = get('banner_presets', {}) || {};
|
||||
const sel = document.getElementById('adminBannerPreset');
|
||||
sel.innerHTML = '<option value="">Custom</option>';
|
||||
Object.entries(presets).forEach(([key, p]) => {
|
||||
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
|
||||
});
|
||||
UI._bannerPresets = presets;
|
||||
} catch (e) { console.debug('Failed to load admin settings:', e); }
|
||||
},
|
||||
|
||||
_bannerPresets: {},
|
||||
|
||||
updateBannerPreview() {
|
||||
const prev = document.getElementById('bannerPreview');
|
||||
if (!prev) return;
|
||||
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
|
||||
const bg = document.getElementById('adminBannerBg').value;
|
||||
const fg = document.getElementById('adminBannerFg').value;
|
||||
prev.textContent = text;
|
||||
prev.style.background = bg;
|
||||
prev.style.color = fg;
|
||||
},
|
||||
|
||||
// ── User Model List ─────────────────────
|
||||
|
||||
async loadUserModels() {
|
||||
const el = document.getElementById('userModelList');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading models...</div>';
|
||||
try {
|
||||
const data = await API.listEnabledModels();
|
||||
const models = data.models || [];
|
||||
if (!models.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No models available — add a provider in the section above</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = models.map(m => {
|
||||
const caps = m.capabilities || {};
|
||||
const badges = [];
|
||||
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||
return `<div class="model-list-item">
|
||||
<span class="model-name">${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-caps-inline">${badges.join('')}</span>
|
||||
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user