From 30d0c1121907233be87da6fece635b0d822c3540 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Thu, 19 Feb 2026 15:03:20 +0000 Subject: [PATCH] Changeset 0.5.0 (#35) --- .gitea/workflows/ci.yaml | 241 ++- .gitignore | 17 +- ARCHITECTURE.md | 1349 +++++++++++++++++ Dockerfile.frontend | 25 +- Dockerfile.unified | 53 - EXTENSIONS.md | 636 ++++++++ ISSUES.md | 388 +---- README.md | 424 ++---- ROADMAP.md | 590 ++----- VERSION | 2 +- build.sh | 88 -- docs/ARCHITECTURE.md | 482 ------ docs/BACKEND_FEATURES.md | 698 --------- docs/CICD_SETUP.md | 235 --- docs/GETTING_STARTED.md | 469 ------ docs/HTML_EXTENSIONS.md | 445 ------ docs/PLUGIN_SPEC.md | 759 ---------- docs/QUICK_START.md | 360 ----- docs/WORKFLOWS.md | 575 ------- frontend/css/styles.css | 829 ---------- frontend/js/api.js | 98 -- frontend/js/storage.js | 108 -- k8s/deployment.yaml | 135 -- k8s/ingress.yaml | 22 +- k8s/lite.yaml | 104 -- nginx.conf | 21 +- scripts/db-migrate.sh | 6 +- scripts/db-validate.sh | 159 ++ scripts/docker-entrypoint.sh | 28 - server/database/migrate.go | 148 ++ .../database/migrations}/001_full_schema.sql | 0 .../migrations}/002_refresh_tokens.sql | 0 .../migrations}/003_global_settings.sql | 0 .../migrations}/004_model_configs.sql | 0 .../migrations/005_provider_capabilities.sql | 60 + server/events/bus.go | 116 ++ server/events/bus_test.go | 123 ++ server/events/types.go | 94 ++ server/events/ws.go | 279 ++++ server/go.mod | 1 + server/handlers/admin.go | 31 +- server/handlers/apiconfigs.go | 145 +- server/handlers/completion.go | 86 +- server/main.go | 31 +- server/middleware/auth.go | 7 + server/providers/anthropic.go | 31 +- server/providers/capabilities.go | 396 +++++ server/providers/capabilities_test.go | 203 +++ server/providers/openai.go | 27 +- server/providers/openrouter.go | 160 ++ server/providers/provider.go | 23 +- server/providers/registry.go | 2 + server/providers/venice.go | 149 ++ src/css/styles.css | 52 +- src/index.html | 29 +- src/js/admin.js | 487 ------ src/js/api.js | 2 + src/js/app.js | 138 +- src/js/backend.js | 456 ------ src/js/debug.js | 61 +- src/js/events.js | 327 ++++ src/js/state.js | 263 ---- src/js/storage.js | 45 - src/js/ui.js | 90 +- src/js/version.js | 7 - 65 files changed, 5345 insertions(+), 8070 deletions(-) create mode 100644 ARCHITECTURE.md delete mode 100644 Dockerfile.unified create mode 100644 EXTENSIONS.md delete mode 100755 build.sh delete mode 100644 docs/ARCHITECTURE.md delete mode 100644 docs/BACKEND_FEATURES.md delete mode 100644 docs/CICD_SETUP.md delete mode 100644 docs/GETTING_STARTED.md delete mode 100644 docs/HTML_EXTENSIONS.md delete mode 100644 docs/PLUGIN_SPEC.md delete mode 100644 docs/QUICK_START.md delete mode 100644 docs/WORKFLOWS.md delete mode 100644 frontend/css/styles.css delete mode 100644 frontend/js/api.js delete mode 100644 frontend/js/storage.js delete mode 100644 k8s/deployment.yaml delete mode 100644 k8s/lite.yaml create mode 100644 scripts/db-validate.sh delete mode 100644 scripts/docker-entrypoint.sh create mode 100644 server/database/migrate.go rename {migrations => server/database/migrations}/001_full_schema.sql (100%) rename {migrations => server/database/migrations}/002_refresh_tokens.sql (100%) rename {migrations => server/database/migrations}/003_global_settings.sql (100%) rename {migrations => server/database/migrations}/004_model_configs.sql (100%) create mode 100644 server/database/migrations/005_provider_capabilities.sql create mode 100644 server/events/bus.go create mode 100644 server/events/bus_test.go create mode 100644 server/events/types.go create mode 100644 server/events/ws.go create mode 100644 server/providers/capabilities.go create mode 100644 server/providers/capabilities_test.go create mode 100644 server/providers/openrouter.go create mode 100644 server/providers/venice.go delete mode 100644 src/js/admin.js delete mode 100644 src/js/backend.js create mode 100644 src/js/events.js delete mode 100644 src/js/state.js delete mode 100644 src/js/storage.js delete mode 100644 src/js/version.js diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 268c2ce..f516661 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,16 +1,30 @@ # .gitea/workflows/ci.yaml # ============================================ -# Chat Switchboard - CI/CD Pipeline +# Chat Switchboard - CI/CD Pipeline (v0.5.4) # ============================================ # Cluster deployments use SEPARATE FE + BE images. -# Unified image is for Docker Hub only (Docker Desktop use). +# Unified image is for Docker Hub only (docker-compose use). +# +# Pipeline: +# 1. Go test (all PRs and pushes) +# 2. Build + Deploy (depends on test passing) # # Deployment mapping: -# PR → FE + BE :dev → wipe/migrate → dev.DOMAIN -# Push to main → FE + BE :test → migrate → test.DOMAIN -# Push to main → FE-only :lite → (no DB) → lite.DOMAIN -# Tag v* → FE + BE :latest → migrate → www.DOMAIN -# → Unified :latest → Docker Hub only (not deployed) +# PR → FE + BE :dev → dev.DOMAIN (DB wipe + fresh schema) +# Push to main → FE + BE :test → test.DOMAIN (migrate only) +# Tag v* → FE + BE :latest → www.DOMAIN (migrate only) +# → Unified :latest → Docker Hub (not deployed) +# +# 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 +# +# Shared PG safety: +# - Bootstrap uses IF NOT EXISTS (idempotent) +# - Dev wipe REFUSES to run on databases not ending in _dev +# - Admin creds never reach the backend pods +# - Each env has its own DB name (chat_switchboard_{dev,test,}) # # Required Gitea Variables: # REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST @@ -46,7 +60,6 @@ env: POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }} FE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-fe BE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-be - UNIFIED_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }} jobs: @@ -157,7 +170,10 @@ jobs: echo "env_label=test (main)" >> "$GITHUB_OUTPUT" fi - # ── Database Bootstrap ─────────────────────── + # ── Database Bootstrap (admin creds) ─────── + # Creates the database and app role if they don't exist. + # Idempotent — safe to run every build. Uses admin creds + # that the backend pods never see. - name: Bootstrap database env: PGHOST: ${{ env.POSTGRES_HOST }} @@ -171,18 +187,114 @@ jobs: chmod +x scripts/db-bootstrap.sh scripts/db-bootstrap.sh - # ── Database Migration ─────────────────────── - - name: Run migrations + # ── Dev: Upgrade Test (dev only) ─────────── + # The dev DB still has the PREVIOUS PR's schema. + # Apply THIS PR's migrations to test the upgrade path. + # If migration fails → CI fails before build. + - name: "Dev: test migration upgrade" + if: steps.env.outputs.DB_WIPE == 'true' env: PGHOST: ${{ env.POSTGRES_HOST }} PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - DB_NAME: ${{ steps.env.outputs.DB_NAME }} - DB_WIPE: ${{ steps.env.outputs.DB_WIPE }} + PGDATABASE: ${{ steps.env.outputs.DB_NAME }} run: | - chmod +x scripts/db-migrate.sh - scripts/db-migrate.sh + echo "━━━ Upgrade test: applying migrations to existing dev DB ━━━" + + # Ensure tracking table exists (first run or after manual wipe) + psql -v ON_ERROR_STOP=1 <<'SQL' + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMPTZ DEFAULT NOW() + ); + SQL + + APPLIED=0 + SKIPPED=0 + FAILED=0 + + for migration in server/database/migrations/*.sql; do + [ -f "${migration}" ] || continue + VERSION=$(basename "${migration}") + + ALREADY=$(psql -tAc "SELECT 1 FROM schema_migrations WHERE version = '${VERSION}';" || echo "0") + if [[ "${ALREADY}" == "1" ]]; then + echo " skip: ${VERSION}" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + echo " apply: ${VERSION}..." + if psql -v ON_ERROR_STOP=1 -f "${migration}"; then + psql -c "INSERT INTO schema_migrations (version) VALUES ('${VERSION}');" + echo " ✓ ${VERSION}" + APPLIED=$((APPLIED + 1)) + else + echo " ✗ ${VERSION} FAILED" + FAILED=$((FAILED + 1)) + fi + done + + echo "" + echo "━━━ Upgrade test: ${APPLIED} applied, ${SKIPPED} skipped, ${FAILED} failed ━━━" + if [[ ${FAILED} -gt 0 ]]; then + echo "❌ Migration upgrade test failed — fix before merge" + exit 1 + fi + + # ── Dev: Validate Schema ─────────────────── + # After upgrade, verify all expected tables/columns exist. + # Catches migrations that apply cleanly but produce wrong schema. + - name: "Dev: validate schema" + if: steps.env.outputs.DB_WIPE == 'true' + env: + PGHOST: ${{ env.POSTGRES_HOST }} + PGPORT: ${{ env.POSTGRES_PORT }} + PGUSER: ${{ secrets.POSTGRES_USER }} + PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + PGDATABASE: ${{ steps.env.outputs.DB_NAME }} + run: | + chmod +x scripts/db-validate.sh + scripts/db-validate.sh + + # ── Dev Wipe (fresh install test) ────────── + # Upgrade test passed. Now wipe so the deploy tests a + # full fresh-install migration via the backend binary. + # SAFETY: hard-refuses on any database not ending in _dev. + - name: "Dev: wipe for fresh install test" + if: steps.env.outputs.DB_WIPE == 'true' + env: + PGHOST: ${{ env.POSTGRES_HOST }} + PGPORT: ${{ env.POSTGRES_PORT }} + PGUSER: ${{ secrets.POSTGRES_USER }} + PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + PGDATABASE: ${{ steps.env.outputs.DB_NAME }} + run: | + DB="${{ steps.env.outputs.DB_NAME }}" + + # ── SAFETY: only wipe databases ending in _dev ── + if [[ "${DB}" != *_dev ]]; then + echo "❌ REFUSING to wipe '${DB}' — only *_dev databases can be wiped" + exit 1 + fi + + echo "⚠ Dev wipe: dropping all tables in ${DB}" + psql <<'SQL' + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + SQL + echo "✓ Dev wipe complete — backend will rebuild schema on startup" + + # Dev exercises BOTH paths: + # 1. Upgrade test above: existing schema → new migrations (psql) + # 2. Fresh install on deploy: empty DB → all migrations (backend binary) + # Test/Prod: backend applies only pending migrations on startup. # ── Build Backend Image ────────────────────── - name: Build backend image @@ -191,7 +303,7 @@ jobs: -t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \ ./server - # ── Build Frontend Image (cluster mode) ────── + # ── Build Frontend Image ───────────────────── - name: Build frontend image run: | docker build -f Dockerfile.frontend \ @@ -215,27 +327,22 @@ jobs: docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} fi - # ── Build + Push Unified (release only) ────── - - name: Build unified image + # ── Build + Push Unified to Docker Hub (release only) ── + - name: Build and push unified image if: steps.env.outputs.is_release == 'true' run: | - docker build -f Dockerfile.unified \ - -t ${UNIFIED_IMAGE}:latest \ - -t ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \ + docker build -f Dockerfile \ + -t ${DOCKERHUB_IMAGE}:latest \ + -t ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \ . - docker push ${UNIFIED_IMAGE}:latest - docker push ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} - - - name: Push unified to Docker Hub - if: steps.env.outputs.is_release == 'true' && secrets.DOCKERHUB_TOKEN != '' - continue-on-error: true - run: | - echo "${{ secrets.DOCKERHUB_TOKEN }}" | \ - docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin - docker tag ${UNIFIED_IMAGE}:latest ${DOCKERHUB_IMAGE}:latest - docker tag ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} - docker push ${DOCKERHUB_IMAGE}:latest - docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + if [[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then + echo "${{ secrets.DOCKERHUB_TOKEN }}" | \ + docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + docker push ${DOCKERHUB_IMAGE}:latest + docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} + else + echo "⚠ Docker Hub credentials not configured, skipping push" + fi # ── Deploy to Kubernetes ───────────────────── - name: Ensure namespace @@ -278,10 +385,10 @@ jobs: kubectl apply -f /tmp/ingress.yaml - name: Rollout and verify + env: + SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }} + ENV: ${{ steps.env.outputs.ENVIRONMENT }} run: | - SUFFIX="${{ steps.env.outputs.DEPLOY_SUFFIX }}" - ENV="${{ steps.env.outputs.ENVIRONMENT }}" - kubectl rollout restart deployment/switchboard-be${SUFFIX} -n ${NAMESPACE} kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE} @@ -303,6 +410,25 @@ jobs: exit 1 fi + # ── Post-deploy: verify schema ───────────── + - name: Verify schema migration + run: | + sleep 5 + HOST="${{ steps.env.outputs.DEPLOY_HOST }}" + HEALTH=$(curl -sf "https://${HOST}/api/v1/health" 2>/dev/null || echo "unreachable") + echo "Health: ${HEALTH}" + + # Extract fields (|| true prevents set -e from killing on no-match) + SCHEMA=$(echo "${HEALTH}" | grep -o '"schema_version":"[^"]*"' | cut -d'"' -f4 || true) + DB_OK=$(echo "${HEALTH}" | grep -o '"database":true' || true) + + if [[ -n "${SCHEMA}" ]] && [[ -n "${DB_OK}" ]]; then + echo "✅ Schema: ${SCHEMA} | DB: connected" + else + echo "⚠ Could not verify schema (backend may still be starting)" + echo " Response: ${HEALTH:0:200}" + fi + - name: Summary run: | HOST="${{ steps.env.outputs.DEPLOY_HOST }}" @@ -314,46 +440,7 @@ jobs: 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 + echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY fi - - # ── Stage 3: FE-Only Lite Deploy (main only) ─ - deploy-lite: - runs-on: ubuntu-latest - if: gitea.ref == 'refs/heads/main' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Add /tools to PATH - run: echo "/tools" >> $GITHUB_PATH - - - name: Install tools - run: | - apt-get update -qq && apt-get install -y -qq gettext-base > /dev/null - - - name: Build lite image - run: | - docker build -f Dockerfile.frontend \ - -t ${FE_IMAGE}:lite \ - . - - - name: Push lite image - run: docker push ${FE_IMAGE}:lite - - - name: Deploy lite - env: - ENVIRONMENT: lite - IMAGE_TAG: lite - DEPLOY_HOST: "lite.${{ vars.DOMAIN || 'gobha.ai' }}" - run: | - export NAMESPACE="${NAMESPACE}" - export FE_IMAGE="${FE_IMAGE}" - export CERT_ISSUER="${CERT_ISSUER}" - envsubst < k8s/lite.yaml > /tmp/lite.yaml - kubectl create namespace ${NAMESPACE} 2>/dev/null || true - kubectl apply -f /tmp/lite.yaml - kubectl rollout restart deployment/switchboard-lite -n ${NAMESPACE} - kubectl rollout status deployment/switchboard-lite -n ${NAMESPACE} --timeout=120s - echo "✅ Lite deployed to https://${DEPLOY_HOST}" diff --git a/.gitignore b/.gitignore index 840adb0..3dc930f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,5 @@ -# Dependencies -node_modules/ -vendor/ - -# Build outputs -standalone/index.html -dist/ -build/ +# Vendor libs (downloaded at build time) +src/vendor/ # Go *.exe @@ -17,6 +11,9 @@ build/ *.out go.work +# Node (if any dev tooling) +node_modules/ + # IDE .idea/ .vscode/ @@ -49,3 +46,7 @@ docker-compose.override.yml *.pem *.key secrets/ + +# Build artifacts +dist/ +build/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..11a32ea --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1349 @@ +# Chat Switchboard — Core Services Architecture + +**Version:** 0.1 draft +**Status:** Design +**Companion to:** EXTENSIONS.md (extension system spec) + +--- + +## Overview + +The extension system (EXTENSIONS.md) defines _how_ new capabilities plug +in. This document defines _what the core provides_ — the backend services +that exist before any extension is loaded. These are the organs; the +extension system is the nervous system. + +Every service listed here is a **primitive** that multiple features +consume. Embeddings serve Knowledge Bases, Notes, and future RAG. Tasks +consume completions, tools, and web search. Channels extend the message +persistence layer. Nothing here is a leaf feature — everything is +foundational. + +--- + +## Terminology + +These terms have specific meanings throughout this document, EXTENSIONS.md, +ROADMAP.md, and the codebase. Using them consistently prevents overloading. + +### Core Primitives + +**Channel** — The universal conversation primitive. Every interaction is +a channel: 1:1 chat, group discussion, editor session, task execution. +A channel has human members, configured models, and a message stream. +What was previously called a "chat" is a channel with `type: 'direct'`. + +**Message** — A single entry in a channel's stream. Has a participant +type (`user`, `model`, `system`) and a participant ID. Replaces the +previous `role: 'user'|'assistant'` model to support multiple humans +and multiple models unambiguously. + +**Provider** — An external LLM API service (OpenAI, Anthropic, Venice, +OpenRouter, Ollama). Configured via `api_configs` with endpoint, API key, +and provider-specific settings. + +**Model** — A specific LLM available through a provider (e.g., +`claude-sonnet-4-20250514` via Anthropic). Has capabilities (tool +calling, vision, thinking) and limits (max output tokens, context window). + +**Extension** — A plugin that adds capabilities. Three tiers: Browser +(client JS), Starlark (server sandbox), Sidecar (container). See +EXTENSIONS.md. + +**Surface** — A UI mode registered by an extension. Chat mode is the +default surface. Editor mode, Article mode, and Cluster Manager mode +are extension-provided surfaces. See EXTENSIONS.md §6. + +**Tool** — A function the LLM can call. Built-in tools (web_search, +note_create, kb_search) ship with core. Extension tools are registered +by extensions at any tier. The LLM doesn't know where a tool executes. + +### People & Access + +**User** — An authenticated human account. Has a username, email, +and one or more Roles. Belongs to zero or more Teams. + +**Role** — What a user is allowed to do. Admin-controlled RBAC. +Governs _permissions_: which models to use, which KBs to read/write, +whether to create tasks, token spending limits, admin delegation. +Roles are _vertical_ — about privilege level. A user can have +multiple roles (permissions are additive). + +Examples: +- `admin` — full system access, provider management, user management +- `developer` — all models, KB read/write, task creation +- `viewer` — read-only access to shared channels and KBs +- Custom roles defined by admin + +**Team** — Who a user works with. Organizational scoping. Governs +_shared context_: which channels are visible, which KBs are shared, +which projects are collaborative. Teams are _horizontal_ — about +visibility and collaboration scope. A user can belong to multiple teams. + +Examples: +- `infrastructure` — sees infra channels, cluster KBs, ops projects +- `frontend` — sees frontend channels, design KBs, UI projects +- `switchboard` — the project team, cross-cutting + +**The distinction:** A Role says "you're allowed to use GPT-4o." +A Team says "you share these channels and this knowledge base with +these people." Two developers on different teams have the same +permissions but different visibility. + +``` +Jeff +├── Roles: [admin, developer] ← what he CAN do +└── Teams: [switchboard, infrastructure] ← who he works WITH + +Alice +├── Roles: [developer] ← same model access, no admin +└── Teams: [switchboard, frontend] ← different project scope +``` + +| Resource | Role controls | Team controls | +|-----------|-----------------------------|--------------------------------| +| Models | Which models you can use | — | +| Channels | — | Which channels you see | +| KBs | Read/write capability | Which KBs are shared with you | +| Projects | — | Which projects you're part of | +| Tasks | Whether you can create them | Which task outputs you see | +| Admin | Admin panel, delegation | — | +| Budgets | Per-role token limits | — | + +### Content & Organization + +**Project** — A workspace that carries configuration: system prompt, +default model, attached KBs, team access. Contains channels. +Non-nestable. A project is _context_ — it defines how work happens. + +**Folder** — A hierarchical organizer for channels. Nestable. Pure +organization with no configuration. A folder is _structure_ — it +defines where things live. + +**Note** — A user-created document with full-text search, folder +organization, and LLM tool integration. Embedded for RAG retrieval. + +**Knowledge Base (KB)** — A named collection of documents that are +chunked, embedded, and queryable via `kb_search`. Attachable to +channels and projects. + +**Task** — A scheduled prompt that runs autonomously. Combines a +cron schedule, a model, tools, and an output target. Executes in +a `type: 'service'` channel with zero human members. + +### Infrastructure + +**EventBus** — Pub/sub message system. Carries events between +browser, server, and WebSocket. Extensions subscribe to events. +Channels are bus rooms. + +**Capability** — A model's feature set: tool calling, vision, +thinking, reasoning, max output tokens, context window. Resolved +from provider API data, known model table, and heuristic detection. + +**Compaction** — Automatic context summarization. When a channel's +history exceeds the model's context window, a utility LLM condenses +older messages. The full history stays in the database. + +**Message Tree** — The actual structure of conversation history. +Messages form a tree via `parent_id`, not a flat list. A linear +conversation is a tree with no branches. Edits, regenerations, and +forks create branches. See §8. + +**Branch** — A path through the message tree from root to a leaf. +Created by edit-and-resubmit, regeneration, or explicit fork. +All branches persist. The UI shows one branch at a time. + +**Active Path** — The branch currently being viewed and used for +context assembly. Tracked per-user per-channel via `channel_cursors`. +The completion handler sends only the active path to the LLM. + +**Cursor** — A per-user pointer to the leaf message of their +active branch in a channel. Stored in `channel_cursors`. + +### Deployment & Auth + +**Auth Mode** — How the backend resolves user identity. Three +strategies, selected by `AUTH_MODE` environment variable: +`builtin` (app-owned JWT), `mtls` (proxy-injected cert headers), +`oidc` (Keycloak/external IdP). The internal user model is the +same regardless of auth mode. + +**Identity Header** — In mTLS mode, the reverse proxy validates +the client certificate and injects identity via HTTP headers +(e.g., `X-SSL-Client-S-DN`, `X-Forwarded-Client-Cert`). The +backend trusts these headers and extracts user identity from them. + +**OIDC (OpenID Connect)** — Standard protocol for delegated +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 designation. Content area is +`100vh - banner_top - banner_bottom`. When no banners are +configured, the full viewport is available. Banner text and color +are admin-configurable. + +--- + +## Layered Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Extensions (Browser / Starlark / Sidecar) │ +│ Editor, Article, Cluster, custom tools │ +├─────────────────────────────────────────────┤ +│ Built-in Tools │ +│ web_search, url_fetch, notes, kb_search, │ +│ task_create │ +├─────────────────────────────────────────────┤ +│ Core Services │ +│ Tasks, Channels, Notes, Knowledge Bases, │ +│ Embeddings, Folders/Projects │ +├─────────────────────────────────────────────┤ +│ Core Infrastructure │ +│ Auth, Provider Router, Completion Handler, │ +│ EventBus, Extension Loader, Persistence │ +├─────────────────────────────────────────────┤ +│ Storage │ +│ PostgreSQL + pgvector │ +└─────────────────────────────────────────────┘ +``` + +--- + +## 1. Summarize / Compaction + +**What:** Utility LLM service that condenses long conversations to +preserve context within model context windows. + +**Why core:** Every mode needs it. Chat, editor, article — any +conversation that exceeds the context window needs automatic compaction. +Tasks that run overnight accumulate context that must be compressed. + +**Design:** +- Backend service, not a user feature. Like garbage collection — it just + happens. +- Admin configures the compaction model (cheap/fast: Haiku, Flash, + Gemini Flash, or a local model via Ollama). +- Auto-triggers when conversation token count exceeds a configurable + threshold (e.g., 80% of model's max_context). +- Produces a compacted summary that replaces older messages in the + context window while preserving the full history in the database. +- Users can optionally trigger manual compaction. +- Admin can delegate compaction controls to users (opt-in via + admin setting). + +**Data model:** +```sql +-- Compaction results stored per-chat +ALTER TABLE chats ADD COLUMN compaction_summary TEXT; +ALTER TABLE chats ADD COLUMN compacted_at TIMESTAMP; +ALTER TABLE chats ADD COLUMN compaction_token_count INTEGER; +``` + +**Admin settings:** +- `compaction_model` — which model handles compaction +- `compaction_threshold` — % of context window that triggers auto-compact +- `compaction_user_enabled` — whether users can trigger manual compaction + +--- + +## 2. Channels (Everything Is a Channel) + +**What:** The universal conversation primitive. Every interaction — +1:1 chat, group discussion, editor session, task execution — is a +channel with participants and a message stream. + +**Why core:** A "chat" is a channel with one human and one model. +A "group chat" is a channel with multiple humans and models. +An "editor session" is a channel where edit events are messages. +A "task run" is a channel with zero humans and one model. +Building these as separate primitives means building context assembly, +message persistence, tool routing, and compaction multiple times. +Building them as one primitive means building it once. + +**The Master Rule:** +> If a channel has exactly 1 human and 1 model: behaves as chat — +> no @mention required, every user message triggers a completion. +> Once additional participants are added (human or model): @mention +> required for LLM to respond. + +This is one `if` in the completion handler. The user never configures +it. Create a chat → it works like a chat. Add a participant → it +works like a channel. Remove them → back to chat behavior. The +transition is invisible. + +```go +func shouldAutoComplete(channel Channel) bool { + return len(channel.Models) == 1 && len(channel.HumanMembers) == 1 +} +``` + +**Design:** + +- **Channel types:** + - `direct` — 1:1, what "chat" is today. Created by "New Chat." + - `group` — multi-participant. Created explicitly or by adding + participants to a direct channel. + - `service` — zero humans. Task runner, scheduled jobs, automations. + +- **Participants are humans. Models are configured resources.** + Humans are members with roles and permissions. Models are configured + per-channel with system prompts, display names, KB access, and tool + permissions. A model is not a "member" — it's a resource that + members can invoke. + +- **@mention routing:** + - `@user` → notification (highlight, badge, push). + - `@model-display-name` → completion trigger. Backend collects + channel history, prepends the model's channel-specific system + prompt, fires completion. Response posts as a message from that + model. + - `@editor` and `@reviewer` can both be Claude Sonnet 4 with + different system prompts. The display name is the identity, + not the model. + +- **Context assembly is channel-scoped.** The completion handler + loads messages from the channel, applies the resolved model's + system prompt and KB context, and fires. Same handler for 1:1 + and group. The only difference is the auto-complete check. + +**Backward compatibility:** The current `/api/v1/chats/*` endpoints +become aliases for channel operations filtered to `type = 'direct' +AND created_by = $user`. Existing frontend code works unchanged. +New channel-aware UI layers on top. + +**Data model:** + +```sql +-- Replaces the current "chats" table +CREATE TABLE channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(500), + type VARCHAR(20) DEFAULT 'direct', -- direct, group, service + created_by UUID REFERENCES users(id), + system_prompt TEXT, + default_model VARCHAR(255), + settings JSONB DEFAULT '{}', + folder_id UUID REFERENCES folders(id), + project_id UUID REFERENCES projects(id), + compaction_summary TEXT, + compacted_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Human participants +CREATE TABLE channel_members ( + channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) DEFAULT 'member', -- owner, admin, member + joined_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (channel_id, user_id) +); + +-- Model configurations per channel +CREATE TABLE channel_models ( + channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, + model_id VARCHAR(255) NOT NULL, + api_config_id UUID REFERENCES api_configs(id), + display_name VARCHAR(100), -- "@editor", "@reviewer" + system_prompt TEXT, -- overrides channel default + kb_ids UUID[] DEFAULT '{}', -- KBs attached to this model + tools TEXT[] DEFAULT '{}', -- enabled tools for this model + attention VARCHAR(20) DEFAULT 'mention', -- mention, passive, auto + settings JSONB DEFAULT '{}', -- temperature, max_tokens, etc. + PRIMARY KEY (channel_id, model_id, COALESCE(display_name, '')) +); + +-- Messages: tree structure with participant attribution +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, + parent_id UUID REFERENCES messages(id), -- tree structure (null = root) + participant_type VARCHAR(20) NOT NULL, -- 'user', 'model', 'system' + participant_id TEXT NOT NULL, -- user UUID or model config ref + content TEXT, + metadata JSONB DEFAULT '{}', -- tool calls, token counts, etc. + deleted_at TIMESTAMP, -- soft delete for branch pruning + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_messages_channel ON messages(channel_id, created_at); +CREATE INDEX idx_messages_parent ON messages(parent_id); +CREATE INDEX idx_channel_members ON channel_members(user_id); +``` + +Each channel tracks which branch the user is currently viewing: + +```sql +-- Per-user active branch tracking +CREATE TABLE channel_cursors ( + channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + active_leaf_id UUID REFERENCES messages(id), + updated_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (channel_id, user_id) +); +``` + +See §8 (Conversation Forking) for the full tree model and operations. +``` + +**Migration from current schema:** + +```sql +-- chats → channels +ALTER TABLE chats RENAME TO channels; +ALTER TABLE channels ADD COLUMN type VARCHAR(20) DEFAULT 'direct'; +ALTER TABLE channels RENAME COLUMN user_id TO created_by; + +-- Every existing chat gets a channel_members row +INSERT INTO channel_members (channel_id, user_id, role) +SELECT id, created_by, 'owner' FROM channels; + +-- Every existing chat gets a channel_models row from its model field +INSERT INTO channel_models (channel_id, model_id, attention) +SELECT id, model, 'auto' FROM channels WHERE model IS NOT NULL; + +-- Messages: add participant columns and tree structure, backfill from role +ALTER TABLE messages RENAME COLUMN chat_id TO channel_id; +ALTER TABLE messages ADD COLUMN parent_id UUID REFERENCES messages(id); +ALTER TABLE messages ADD COLUMN participant_type VARCHAR(20); +ALTER TABLE messages ADD COLUMN participant_id TEXT; +ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMP; +UPDATE messages SET + participant_type = CASE WHEN role = 'user' THEN 'user' + WHEN role = 'assistant' THEN 'model' + ELSE 'system' END, + participant_id = CASE WHEN role = 'user' THEN + (SELECT created_by::text FROM channels WHERE id = messages.channel_id) + ELSE COALESCE(model, 'unknown') END; + +-- Backfill parent_id: chain messages linearly by timestamp +WITH ordered AS ( + SELECT id, channel_id, + LAG(id) OVER (PARTITION BY channel_id ORDER BY created_at) AS prev_id + FROM messages +) +UPDATE messages SET parent_id = ordered.prev_id +FROM ordered WHERE messages.id = ordered.id; + +CREATE INDEX idx_messages_parent ON messages(parent_id); +``` + +--- + +## 3. Notes + +**What:** User-created documents with full-text search, folder +organization, and LLM tool integration. + +**Why core:** Every mode needs persistent, searchable, user-scoped +storage. The editor extension stores files. The article extension +stores drafts. The chat mode stores insights. Notes are the universal +"user's persistent data" primitive. + +**Design:** +- CRUD with folder hierarchy and tags. +- Full-text search via PostgreSQL `tsvector` (zero additional infra). +- Markdown content with optional structured metadata (JSONB). +- Bidirectional links to chats ("this note was created from chat X"). +- Embedded for RAG retrieval (uses the embedding infrastructure from §5). + +**LLM Tools (built-in):** +- `note_create` — create a note with title, content, folder, tags +- `note_search` — full-text search across user's notes +- `note_update` — update note content (append, replace, or patch) +- `note_list` — list notes by folder or tag + +These are built-in tools available to any model in any mode. The LLM +decides when to use them. "Save this as a note" in chat, "Update my +project notes" in editor mode, "Add this source to my research" in +article mode — all the same tools. + +**Data model:** +```sql +CREATE TABLE notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + content TEXT, + folder_path TEXT DEFAULT '/', -- /projects/switchboard/ + tags TEXT[] DEFAULT '{}', + metadata JSONB DEFAULT '{}', + source_chat_id UUID REFERENCES chats(id), + search_vector TSVECTOR, + embedding_id UUID, -- links to embeddings table + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_notes_user ON notes(user_id); +CREATE INDEX idx_notes_search ON notes USING GIN(search_vector); +CREATE INDEX idx_notes_folder ON notes(user_id, folder_path); +CREATE INDEX idx_notes_tags ON notes USING GIN(tags); + +-- Auto-update search vector +CREATE TRIGGER notes_search_update + BEFORE INSERT OR UPDATE ON notes + FOR EACH ROW EXECUTE FUNCTION + tsvector_update_trigger(search_vector, 'pg_catalog.english', title, content); +``` + +--- + +## 4. Knowledge Bases + +**What:** Named collections of documents that get embedded and become +queryable by the LLM via RAG. + +**Why core:** The embedding and retrieval infrastructure isn't just for +KBs — notes get embedded, chat history can be embedded, future features +will need vector search. KBs are the first _consumer_ of the embedding +infrastructure but not the only one. + +**Design:** +- A KB is a named collection with an owner (user or shared). +- Documents are uploaded (PDF, DOCX, TXT, MD, HTML), chunked, and + embedded. +- Admin configures the embedding model globally (OpenAI + text-embedding-3-small, a local model, etc.). +- Chunking strategy is configurable (fixed-size, recursive, + semantic boundaries). +- KBs can be attached to chats, channels, or projects — when attached, + `kb_search` automatically includes that KB's context. + +**LLM Tool (built-in):** +- `kb_search` — semantic search across one or more knowledge bases. + Returns top-k chunks with source attribution. + +**Data model:** +```sql +CREATE TABLE knowledge_bases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + owner_id UUID REFERENCES users(id), + is_shared BOOLEAN DEFAULT false, + chunk_strategy VARCHAR(50) DEFAULT 'recursive', + chunk_size INTEGER DEFAULT 512, + chunk_overlap INTEGER DEFAULT 50, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE kb_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE, + filename VARCHAR(500), + content_type VARCHAR(100), + size_bytes BIGINT, + status VARCHAR(20) DEFAULT 'pending', -- pending, processing, ready, error + chunk_count INTEGER DEFAULT 0, + uploaded_at TIMESTAMP DEFAULT NOW() +); + +-- See §5 for the embeddings table (shared with Notes) +``` + +--- + +## 5. Embeddings Infrastructure + +**What:** The shared embedding pipeline that KBs, Notes, and future +features all use. + +**Why core:** Embedding is a horizontal capability. Restricting it to +just KBs would mean reimplementing it for notes, chat history search, +and anything else that needs semantic retrieval. + +**Design:** +- `embed(text) → vector` — calls the admin-configured embedding model. +- `store(vector, source_type, source_id, metadata)` — stores in pgvector. +- `search(query_vector, filters) → ranked chunks` — similarity search + with source-type filtering. +- Admin configures: embedding model, vector dimensions, distance metric. +- Async pipeline: embeddings are generated in the background via a + worker queue (not blocking the request). + +**Data model:** +```sql +-- Requires: CREATE EXTENSION vector; +CREATE TABLE embeddings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_type VARCHAR(50) NOT NULL, -- 'kb_chunk', 'note', 'chat_summary' + source_id UUID NOT NULL, -- references the source record + chunk_index INTEGER DEFAULT 0, -- position within source + content TEXT NOT NULL, -- the text that was embedded + embedding vector(1536), -- dimension matches model + metadata JSONB DEFAULT '{}', -- source filename, page, etc. + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_embeddings_source ON embeddings(source_type, source_id); +CREATE INDEX idx_embeddings_vector ON embeddings + USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 100); +``` + +**Admin settings:** +- `embedding_model` — which model to use (default: text-embedding-3-small) +- `embedding_dimensions` — vector size (default: 1536) +- `embedding_api_config_id` — which provider config to use for embedding calls + +--- + +## 6. Folders and Projects + +**What:** Organizational containers for conversations and related +resources. + +**Why core:** Every mode needs organization. Without it, users drown in +flat chat lists. Two types serve different needs: + +- **Folders** — hierarchical, nestable. Pure organization. A folder + contains chats (and other folders). Like filesystem directories. +- **Projects** — flat (non-nestable), but carry configuration. A project + has a system prompt, default model, attached KBs, and member access. + A project is a _workspace_ that happens to contain conversations. + +**Design:** +- Folders are lightweight: just a name, parent_id, and user_id. +- Projects carry context: system_prompt, model, KBs, team members. +- Chats belong to at most one folder OR one project (not both). +- Projects can have shared access (team feature, future). + +**Data model:** +```sql +CREATE TABLE folders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + parent_id UUID REFERENCES folders(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + system_prompt TEXT, + default_model VARCHAR(255), + settings JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE project_knowledge_bases ( + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE, + PRIMARY KEY (project_id, kb_id) +); + +-- Chats gain optional organization +ALTER TABLE chats ADD COLUMN folder_id UUID REFERENCES folders(id); +ALTER TABLE chats ADD COLUMN project_id UUID REFERENCES projects(id); +``` + +--- + +## 7. Tasks + +**What:** Scheduled prompts that run autonomously — a prompt + a +schedule + a model + tools + a destination. + +**Why core:** This is what transforms Switchboard from a chat app into +an autonomous agent platform. Tasks consume every other core service: +completions, tools (web search, KB search, notes), channels +(posting results), and embeddings (updating knowledge). + +**Design:** +- A task is: cron schedule + prompt + model + tool set + output target. +- Output targets: note (create/update), channel (post message), + webhook (HTTP POST), chat (append to existing conversation). +- Tasks run in the backend via a scheduler goroutine. +- Each run calls the completion handler with the task's prompt, model, + and enabled tools — identical to a user sending a message, but + automated. +- Task history: every run logged with input, output, tokens used, + duration, status. +- Tasks can be paused, resumed, edited, deleted. +- Admin controls: max concurrent tasks, per-user task limits, + allowed models for tasks. + +**LLM Tool (built-in):** +- `task_create` — the LLM can schedule follow-ups: "I'll check on + this tomorrow" becomes a real task. Parameters: prompt, schedule, + model, output target. + +**Examples:** +- "Check these 5 news sources for AI policy changes every morning, + write a summary, post to #ai-news channel." +- "Review k8s cluster health every hour, alert to #ops if anomalous." +- "Refresh the 'competitor analysis' knowledge base weekly from + these URLs." +- "Every Friday, summarize this week's chat conversations into + project notes." + +**Data model:** +```sql +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + prompt TEXT NOT NULL, + model VARCHAR(255) NOT NULL, + schedule VARCHAR(100) NOT NULL, -- cron expression + tools TEXT[] DEFAULT '{}', -- enabled tool names + output_type VARCHAR(50) NOT NULL, -- note, channel, webhook, chat + output_target TEXT NOT NULL, -- note_id, channel_id, URL, chat_id + settings JSONB DEFAULT '{}', -- max_tokens, temperature, etc. + is_active BOOLEAN DEFAULT true, + last_run_at TIMESTAMP, + next_run_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE task_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID REFERENCES tasks(id) ON DELETE CASCADE, + started_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP, + status VARCHAR(20) DEFAULT 'running', -- running, success, error + input_tokens INTEGER, + output_tokens INTEGER, + result TEXT, + error TEXT, + duration_ms INTEGER +); + +CREATE INDEX idx_tasks_user ON tasks(user_id, is_active); +CREATE INDEX idx_tasks_schedule ON tasks(next_run_at) WHERE is_active = true; +CREATE INDEX idx_task_runs ON task_runs(task_id, started_at DESC); +``` + +--- + +## 8. Conversation Forking + +**What:** Message history is a tree, not a list. Every edit-and-resubmit, +every regeneration, every "let me try a different direction" creates a +branch. All branches persist. The UI shows one branch at a time. + +**Why core:** Every LLM interaction involves exploration. Users +backtrack, retry, rephrase. The current model (linear message list) +forces a choice: keep the old response or destroy it. That's data loss. +The tree model preserves everything and makes exploration a first-class +operation. + +**The model:** + +``` +msg1 (user: "explain TCP") +└── msg2 (model: "TCP is a connection-oriented...") + ├── msg3a (user: "go deeper on handshake") ← branch A + │ └── msg4a (model: "the three-way handshake...") + └── msg3b (user: "actually explain UDP instead") ← branch B (edit) + └── msg4b (model: "UDP is a connectionless...") + └── msg5b (user: "compare their headers") + ├── msg6b-v1 (model: "TCP headers have...") ← regen 1 + └── msg6b-v2 (model: "the key difference...") ← regen 2 +``` + +A linear conversation is just a tree with no branches — every node +has exactly one child. No special cases. + +**Schema:** (defined in §2 Channels) + +```sql +-- Already in the messages table: +parent_id UUID REFERENCES messages(id) -- null = root message +deleted_at TIMESTAMP -- soft delete + +-- Per-user cursor (which branch they're viewing): +channel_cursors (channel_id, user_id, active_leaf_id) +``` + +**Operations:** + +**Edit & resubmit** — User edits message N. The frontend creates a new +message with the same `parent_id` as message N (a sibling). Fires a +completion from the new message. The old branch remains in the tree. +The cursor moves to the new branch's leaf. + +**Regenerate** — Creates a new model message with the same `parent_id` +as the current model response (sibling). Both responses persist. The +user picks which to continue from. + +**Fork** — Explicitly branch from any point. "I want to try a different +direction from message #5." Creates a new child of message #5. Both +branches coexist. This is just "edit & resubmit" but at an arbitrary +point in the tree. + +**Delete a chain** — Soft delete: `SET deleted_at = NOW()` on a message +and all its descendants. The tree structure stays intact for undo. The +UI hides soft-deleted messages. Hard prune is a background job that +removes orphaned subtrees after a retention period. + +**Switch branch** — User navigates to a different branch at a fork +point. The frontend updates `channel_cursors.active_leaf_id` and +redraws the path from root to the new leaf. + +**Context assembly:** + +The completion handler sends the **active path**, not all messages in +the channel. The path is the chain from root to current leaf, following +`parent_id` pointers: + +```go +func getActivePath(channelID, leafID string) []Message { + var path []Message + current := leafID + for current != "" { + msg := loadMessage(current) + if msg.DeletedAt != nil { + break // stop at soft-deleted boundary + } + path = append([]Message{msg}, path...) + current = msg.ParentID + } + return path +} +``` + +Branch A's messages never pollute Branch B's context. If you branched +because the conversation went wrong, the wrong turn doesn't follow +you. + +**Compaction** is per-path, not per-channel. Each active branch can +be compacted independently. Unused branches keep their full history. + +**Frontend requirements:** + +- **Branch indicator** — When a message has siblings (same parent_id), + show navigation: `← 2/3 →`. Displayed at the fork point, not on + every message. + +- **Tree minimap** (optional, power user) — A collapsible outline + showing the full branch structure. Click a branch to switch to it. + Shows branch depth, message count per branch, and which branches + have model responses. + +- **Active path highlight** — The current branch is visually + distinguished. Sibling branches are accessible but dimmed/collapsed. + +**What to implement when:** + +Phase 1 (v0.6.x): Add `parent_id` to messages table. Backfill +existing messages with linear parent chains. All new messages created +with proper `parent_id`. Frontend still renders linearly — no branch +UI yet. This is the "do it now while it's cheap" step. + +Phase 2 (v0.7.x+): Edit-and-resubmit creates siblings instead of +replacing. Regenerate creates sibling model responses. Branch indicator +`← 1/2 →` in the UI. `channel_cursors` table for tracking active +branch. + +Phase 3 (future): Full tree minimap. Explicit fork button. Branch +comparison view (diff two branches). Branch merging (take the best +parts of two branches into a new path). + +--- + +## 9. Built-in Tools + +These are the tools that ship with core because multiple modes and +services depend on them. They are always available when the model +supports tool calling. + +| Tool | Tier | Description | +|---|---|---| +| `web_search` | Sidecar | Search provider abstraction (SearXNG, Brave, DuckDuckGo). Returns ranked results with snippets. | +| `url_fetch` | Server | Retrieve and extract content from a URL. Used by web_search follow-up, article mode, KB ingestion, tasks. | +| `note_create` | Server | Create a note with title, content, folder, tags. | +| `note_search` | Server | Full-text + semantic search across user's notes. | +| `note_update` | Server | Update note content (append, replace, or structured patch). | +| `kb_search` | Server | Semantic search across attached knowledge bases. Returns top-k chunks with source attribution. | +| `task_create` | Server | Schedule a new task. The LLM can create its own follow-ups. | + +**Extension-provided tools** (not core, but expected early extensions): +- `read_file`, `write_file`, `search_replace` (Editor mode) +- `kubectl_get`, `ceph_status`, `node_ssh` (Cluster manager) +- `git_commit`, `git_diff`, `git_push` (Editor + Git) +- `fetch_source`, `check_citation` (Article mode) + +--- + + +## 10. Implementation Sequence + +Priority is based on: dependency depth (what blocks other things), +solo-user value (Jeff is the first user), and complexity. + +### Phase 1: Channel Foundation + Organization (v0.6.x) +**chats → channels migration + message tree + Folders + Projects + banners** +- Rename `chats` → `channels`, add `type`, `channel_members`, + `channel_models`, `participant_type`/`participant_id` on messages. +- Add `parent_id` to messages. Backfill existing messages with + linear parent chains. All new messages created with proper + `parent_id`. This is the "do it now while it's cheap" step — + the tree structure exists in the schema even before the branch + UI is built. +- Add `channel_cursors` table for per-user active branch tracking. +- 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 + in admin, `/api/v1/settings/banners` endpoint, `initBanners()` + in frontend. Small, zero-risk, and required before any + enterprise deployment. +- This is the schema foundation everything else builds on. + Do it now while there's one user and a handful of chats. + +### Phase 2: Notes + Built-in Tools (v0.7.x) +**Notes CRUD + note_* tools + tool execution framework** +- Enables the LLM to be genuinely useful beyond ephemeral chat. +- Requires: tool execution in completion handler (the plumbing for + _all_ tools). +- This phase builds the tool calling infrastructure that everything + else uses. +- **Conversation forking UI (v0.7.x):** Edit-and-resubmit creates + siblings instead of replacing. Regenerate creates sibling model + responses. Branch indicator `← 1/2 →` at fork points. Context + assembly uses active path, not full channel history. + +### Phase 3: Web Search + URL Fetch (v0.7.x) +**web_search + url_fetch tools** +- Sidecar or direct HTTP from backend. +- Paired with Notes: "research X and save findings to my notes." +- Paired with Tasks (future): automated research. + +### Phase 4: @mention Routing + Multi-participant (v0.7.x) +**@mention parsing + multi-model channels** +- The channel schema exists from Phase 1. This phase adds the + routing logic: scan messages for @mentions, resolve against + `channel_models`, fire completions. +- "Add a model" UI: configure a second model in any channel. +- Enables: editor mode (multiple model roles), second opinions, + cross-model conversations. + +### Phase 5: Embeddings + Knowledge Bases (v0.8.x) +**Embedding pipeline + pgvector + KB CRUD + kb_search tool** +- Depends on: tool execution framework (Phase 2). +- Notes get embedded too (once pipeline exists). +- Admin configures embedding model. +- KBs attach to channels via `channel_models.kb_ids`. + +### Phase 6: Compaction (v0.8.x) +**Auto-compaction service** +- Depends on: utility LLM calling (same as task runner). +- Channel-scoped: compacts any channel that exceeds context threshold. +- Can be built alongside or after KB (similar backend pattern: + background job that calls an LLM). + +### Phase 7: Tasks (v0.9.x) +**Scheduler + task runner + task_create tool** +- Creates `type: 'service'` channels with no human members. +- Depends on: completion handler, tool execution, notes, web search. +- The capstone: everything below it combined into autonomous agents. +- Admin controls for resource limits. + +### Phase 8: Auth Strategy + Roles/Teams + Permissions (v0.10.x) +**Enterprise auth modes + RBAC beyond admin/user** +- Auth middleware strategy pattern: `AUTH_MODE` env var selects + `builtin`, `mtls`, or `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. +- Roles: permission grants (model access, KB write, task create, + admin delegation, token budgets). +- Teams: organizational scoping (channel visibility, project + membership, shared KBs). +- Multi-user collaboration features on top of the channel foundation. + +--- + +## 11. Admin Control Surface + +Each core service adds admin settings. The admin settings table +(`global_settings`) is already in place. New settings per service: + +``` +compaction_model — model for auto-compaction +compaction_threshold — context % trigger (default: 80) +compaction_user_enabled — let users trigger manual compaction + +embedding_model — model for embeddings +embedding_api_config_id — which provider config for embeddings +embedding_dimensions — vector size (default: 1536) + +task_max_concurrent — max simultaneous task runs +task_per_user_limit — max tasks per user +task_allowed_models — which models can be used in tasks + +channel_max_members — max users per channel +channel_ai_models — which models can be @mentioned + +websearch_provider — searxng, brave, duckduckgo +websearch_endpoint — SearXNG instance URL +websearch_api_key — Brave/DDG API key + +auth_mode — builtin, mtls, oidc +oidc_issuer_url — Keycloak realm URL +oidc_client_id — OIDC client ID +mtls_identity_header — header containing client DN +mtls_auto_provision — create users on first cert auth + +banner_top_text — top banner text (empty = hidden) +banner_top_color — background color (#007A33, #0033A0, etc.) +banner_top_text_color — text color (#FFFFFF) +banner_bottom_text — bottom banner text (empty = hidden) +banner_bottom_color — background color +banner_bottom_text_color — text color +``` + +--- + +## 12. Authentication Architecture + +**Design goal:** The application consumes identity — it does not own +the auth flow in every deployment. The auth mode is selected by the +`AUTH_MODE` environment variable. The backend's internal model is the +same regardless: a user ID, email, display name, and set of roles. + +### Mode: `builtin` (default) + +What exists today. The app owns the full auth lifecycle. + +- Login form → `POST /api/v1/auth/login` → issues JWT access + refresh tokens. +- Access token: short-lived (15 min), stateless, validated per-request. +- Refresh token: long-lived (7 days), stored in DB, rotated on use. +- Password hashing: bcrypt. +- User management: admin creates users via admin panel. + +No changes needed. This is the personal deployment mode. + +### Mode: `mtls` + +The reverse proxy (Istio sidecar, HAProxy, nginx) terminates mTLS, +validates the client certificate against a trusted CA chain, and +injects identity headers. The backend trusts these headers +unconditionally — the proxy is the trust boundary, not the app. + +**Flow:** +``` +Client (CAC/PIV cert) → Proxy (mTLS termination) → Backend + │ + ├── X-SSL-Client-S-DN: CN=jeff.smith.1234567890,OU=... + ├── X-SSL-Client-Verify: SUCCESS + └── X-SSL-Client-Cert: (optional, full cert PEM) +``` + +**Backend behavior:** +1. Auth middleware reads the identity header (`AUTH_MTLS_HEADER`, + default: `X-SSL-Client-S-DN`). +2. Parses the DN to extract CN (common name) or a configurable field. +3. Looks up user by `external_id` (the DN or CN). +4. If not found and `AUTH_MTLS_AUTO_PROVISION=true`: creates the user + with `auth_source: 'mtls'`, DN as `external_id`, CN as display name. +5. If not found and auto-provision disabled: 403. +6. Sets `user_id` in request context. All downstream handlers are + unchanged. + +**No JWTs issued.** Every request carries the cert. The proxy +validates it every time. The backend has no session state to manage. +No login page, no refresh tokens, no logout. + +**Config:** +``` +AUTH_MODE=mtls +AUTH_MTLS_HEADER=X-SSL-Client-S-DN +AUTH_MTLS_VERIFY_HEADER=X-SSL-Client-Verify +AUTH_MTLS_AUTO_PROVISION=true +AUTH_MTLS_DEFAULT_ROLE=developer +``` + +**Security:** The identity headers MUST only be trusted if the +request came through the proxy. The backend should reject direct +connections that bypass the proxy — either via network policy +(pod only accepts traffic from proxy sidecar) or by requiring the +verify header. + +### Mode: `oidc` + +Keycloak, Okta, Azure AD, or any OIDC-compliant IdP. The user +authenticates with the IdP. The app is a relying party. + +**Flow (Authorization Code):** +``` +Browser → /auth/login → redirect to IdP +IdP → authenticates user → redirect back with code +Browser → /auth/callback?code=... → Backend exchanges code for tokens +Backend → validates ID token via JWKS → extracts claims → issues session +``` + +**Flow (Bearer Token / API):** +``` +Client → Authorization: Bearer +Backend → validates token against IdP JWKS endpoint +Backend → extracts claims (sub, email, groups, roles) +``` + +**Backend behavior:** +1. Auth middleware reads the `Authorization: Bearer` header. +2. Validates the token signature against the IdP's JWKS endpoint + (cached, refreshed periodically). +3. Extracts claims: `sub` (subject), `email`, `preferred_username`, + `realm_access.roles` (Keycloak-specific), or configurable claim + paths. +4. Looks up user by `external_id` (the `sub` claim). +5. If not found and auto-provision enabled: creates user with + `auth_source: 'oidc'`, IdP roles mapped to local roles. +6. Sets `user_id` in request context. + +**Role mapping:** The IdP's roles/groups can be mapped to local +Roles via config. For example: +``` +AUTH_OIDC_ROLE_MAP=idp-admin:admin,idp-developer:developer,idp-viewer:viewer +``` + +Unmapped IdP roles get the default role. + +**Config:** +``` +AUTH_MODE=oidc +AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/switchboard +AUTH_OIDC_CLIENT_ID=chat-switchboard +AUTH_OIDC_CLIENT_SECRET= +AUTH_OIDC_REDIRECT_URI=https://switchboard.example.com/auth/callback +AUTH_OIDC_SCOPES=openid,profile,email +AUTH_OIDC_AUTO_PROVISION=true +AUTH_OIDC_DEFAULT_ROLE=viewer +AUTH_OIDC_ROLE_MAP=admin:admin,developer:developer +AUTH_OIDC_ROLE_CLAIM=realm_access.roles +``` + +### Auth Middleware Strategy Pattern + +```go +func AuthMiddleware(cfg config.Config) gin.HandlerFunc { + switch cfg.AuthMode { + case "mtls": + return mtlsAuth(cfg) + case "oidc": + return oidcAuth(cfg) + default: + return builtinJWTAuth(cfg) + } +} +``` + +Each strategy resolves to the same result: `c.Set("user_id", userID)`. +Everything downstream — handlers, completion, channels, permissions — +is auth-mode agnostic. + +### User Table Changes + +```sql +ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'builtin'; + -- 'builtin', 'mtls', 'oidc' +ALTER TABLE users ADD COLUMN external_id TEXT; + -- DN for mTLS, 'sub' claim for OIDC +ALTER TABLE users ADD COLUMN external_metadata JSONB DEFAULT '{}'; + -- full cert DN fields, IdP claims, etc. +CREATE UNIQUE INDEX idx_users_external ON users(auth_source, external_id) + WHERE external_id IS NOT NULL; +``` + +Users can exist with `auth_source: 'builtin'` and `external_id: NULL` +(current state). Migration is additive — existing users unaffected. + +### WebSocket Auth Per Mode + +- **builtin:** current `?token=` query param approach. +- **mtls:** proxy already validated the cert for the WebSocket upgrade + request. Backend reads the same identity headers. +- **oidc:** `?token=` query param, validated against JWKS + (same as HTTP bearer validation). + +The WebSocket hub doesn't care which mode resolved the identity — it +gets `user_id` from the auth middleware like every other handler. + +--- + +## 13. Classification Banners & Layout + +**What:** Configurable header and footer banners that indicate the +environment designation. When present, the content +area shrinks to accommodate them. When absent, full viewport. + +**Why this matters:** In certain deployment environments, every page +of every application must display an environment designation banner. +This is often a policy requirement. The banners must be consistent, +always visible (no scroll), and not interfere with the application's +layout. Getting this wrong blocks deployment. + +### Design + +Banners are thin, fixed-position bars. The content area is everything +between them. The layout uses CSS custom properties so the math is +always correct: + +```css +:root { + --banner-top-h: 0px; + --banner-bottom-h: 0px; +} + +/* When banners exist, JS sets the actual heights */ +.banner { + position: fixed; + left: 0; + right: 0; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + z-index: 9999; + user-select: none; +} + +.banner-top { + top: 0; +} + +.banner-bottom { + bottom: 0; +} + +/* Content area respects banners */ +.app-container { + position: fixed; + top: var(--banner-top-h); + bottom: var(--banner-bottom-h); + left: 0; + right: 0; + overflow: hidden; +} +``` + +**No banners configured:** `--banner-top-h` and `--banner-bottom-h` +stay at `0px`. The `.app-container` fills the full viewport. The +banner DOM elements don't exist. Zero visual or layout impact. + +**Banners configured:** Backend serves banner config via +`GET /api/v1/settings/banners` (public, no auth). The frontend +injects the banner elements, sets the CSS variables, and the +content area shrinks automatically. + +### Standard Banner Presets + +Admin configures text, background color, and text color. Organizations +typically define their own color standards for environment banners. +The app doesn't know or care about banner semantics — it just +renders the text in the color the admin configured. + +### Banner API + +```json +GET /api/v1/settings/banners + +{ + "top": { + "text": "ENVIRONMENT LABEL", + "background": "#007A33", + "color": "#FFFFFF" + }, + "bottom": { + "text": "ENVIRONMENT LABEL", + "background": "#007A33", + "color": "#FFFFFF" + } +} +``` + +Empty response or `null` top/bottom = no banner. + +### Frontend Init + +```javascript +async function initBanners() { + try { + const resp = await fetch('/api/v1/settings/banners'); + const data = await resp.json(); + if (data.top) injectBanner('top', data.top); + if (data.bottom) injectBanner('bottom', data.bottom); + } catch (e) { + // No banners — silent. Don't block app load. + } +} + +function injectBanner(position, config) { + const el = document.createElement('div'); + el.className = `banner banner-${position}`; + el.textContent = config.text; + el.style.backgroundColor = config.background; + el.style.color = config.color; + document.body.prepend(el); + + const h = el.offsetHeight + 'px'; + document.documentElement.style.setProperty( + `--banner-${position}-h`, h + ); +} +``` + +Banners load before the app initializes. The CSS variable update +causes a single layout reflow — the content area adjusts instantly. + +### Admin Settings + +``` +banner_top_text — empty = no banner +banner_top_color — background color +banner_top_text_color — text color +banner_bottom_text — empty = no banner +banner_bottom_color — background color +banner_bottom_text_color — text color +``` + +Setting these to empty strings removes the banners. No restart +required — next page load picks up the new config. The banner +endpoint is public (no auth required) so it loads before login +in OIDC/mTLS flows where the login redirect hasn't happened yet. + +### Layout Impact on All Surfaces + +Every surface/mode must use `.app-container` as its root. This +ensures banners work automatically across chat mode, editor mode, +article mode, or any future extension surface. Extensions don't +need to know about banners — they render inside the container, +the container respects the CSS variables. + +``` +┌──────────────────────────────────────┐ +│ ██ ENVIRONMENT LABEL ███████████████ │ ← banner-top (28px) +├──────────────────────────────────────┤ +│ │ +│ .app-container │ +│ (all surfaces render here) │ +│ │ +│ │ +├──────────────────────────────────────┤ +│ ██ ENVIRONMENT LABEL ███████████████ │ ← banner-bottom (28px) +└──────────────────────────────────────┘ +``` + diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 04a0557..774f0a8 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -2,19 +2,28 @@ # Chat Switchboard - Frontend Dockerfile # ========================================== # Static SPA served by nginx. No API proxy — -# in cluster mode, the Ingress routes /api/ to +# in k8s, the Ingress routes /api/ and /ws to # the backend service directly. -# Also used for the lite (unmanaged) deployment. +# +# Build context: repo root # ========================================== -# Production stage -FROM nginx:alpine +# Stage 1: Download vendor libs (vendor/ is gitignored) +FROM node:20-alpine AS vendor +WORKDIR /tmp +RUN npm pack marked@16.3.0 dompurify@3.2.4 2>/dev/null && \ + mkdir -p /vendor && \ + tar xzf marked-*.tgz -C /tmp && cp /tmp/package/lib/marked.umd.js /vendor/marked.min.js && \ + rm -rf /tmp/package && \ + 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 +FROM nginx:1-alpine -# Static-only nginx config (no /api/ proxy) COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf - -# Built SPA files -COPY src /usr/share/nginx/html +COPY src/ /usr/share/nginx/html/ +COPY --from=vendor /vendor/ /usr/share/nginx/html/vendor/ EXPOSE 80 diff --git a/Dockerfile.unified b/Dockerfile.unified deleted file mode 100644 index 055de20..0000000 --- a/Dockerfile.unified +++ /dev/null @@ -1,53 +0,0 @@ -# ============================================ -# Chat Switchboard - Unified Dockerfile -# ============================================ -# Stage 1: Build Go backend -# Stage 2: Build standalone frontend -# Stage 3: Production image (nginx + backend) -# -# nginx serves frontend on :80, proxies /api/ to Go on :8080 -# ============================================ - -# ── Stage 1: Go backend build ──────────────── -FROM golang:1.22-bookworm AS backend - -WORKDIR /app - -COPY server/go.mod server/go.sum* ./ - -COPY server/ . - -RUN go mod download && go mod tidy && go mod verify - -RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/switchboard . - -# ── Stage 2: Frontend build ────────────────── -FROM alpine:3.19 AS frontend - -WORKDIR /app -RUN apk add --no-cache bash coreutils - -COPY build.sh ./ -COPY src ./src - -RUN chmod +x build.sh && ./build.sh - -# ── Stage 3: Production ───────────────────── -FROM nginx:1-alpine - -RUN apk add --no-cache bash - -# Copy Go backend -COPY --from=backend /bin/switchboard /usr/local/bin/switchboard - -# Copy built frontend -COPY --from=frontend /app/standalone /usr/share/nginx/html - -# Copy nginx config -COPY nginx.conf /etc/nginx/conf.d/default.conf - -# Startup script: launches Go backend before nginx starts -COPY scripts/docker-entrypoint.sh /docker-entrypoint.d/90-start-backend.sh -RUN chmod +x /docker-entrypoint.d/90-start-backend.sh - -EXPOSE 80 diff --git a/EXTENSIONS.md b/EXTENSIONS.md new file mode 100644 index 0000000..e495603 --- /dev/null +++ b/EXTENSIONS.md @@ -0,0 +1,636 @@ +# Chat Switchboard — Extension System Specification + +**Version:** 0.1 draft +**Status:** Design +**Applies to:** v0.6.x+ +**Companion to:** ARCHITECTURE.md (core services + terminology) + +--- + +## 1. Philosophy + +Chat Switchboard is a substrate, not an application. The core provides: +authentication, provider routing, message persistence, an event bus, and a +rendering surface. Everything else — editing, writing, cluster management, +cost tracking, custom renderers — is an extension. + +The goal is that someone with a problem and some JS (or Go, or Python) can +solve it without forking the project. The "modes" Jeff envisions — Editor, +Article, Chat, Cluster Manager — are just extensions that register surfaces, +tools, and event handlers. This project doesn't have to build all of them. +It just has to make them possible. + +--- + +## 2. Extension Tiers + +| | Tier 0: Browser | Tier 1: Starlark | Tier 2: Sidecar | +|---|---|---|---| +| **Runs in** | User's browser | Go server (embedded) | Separate container | +| **Language** | JavaScript | Starlark (Python subset) | Any | +| **Deployed by** | User or Admin push | Admin | Admin | +| **Latency** | Zero (client-side) | Low (in-process) | Network hop | +| **Can access** | DOM, EventBus, LocalStorage, user context | Message data, DB reads (sandboxed) | Anything (HTTP, filesystem, network) | +| **Cannot access** | Server internals, other users' data | Network, filesystem, raw SQL | N/A (full access) | +| **Trust model** | Same-origin; user-scoped or admin-pushed | Starlark sandbox (no I/O) | Container isolation | +| **Use cases** | UI, rendering, shortcuts, client tools, modes | Routing rules, message transforms, logging | RAG, external APIs, webhooks, heavy compute | + +All three tiers communicate through the EventBus. A browser extension +publishes `tool.result.{callId}` the same way a sidecar does — the bus +doesn't care where the event originated. + +--- + +## 3. Manifest Format + +Every extension, regardless of tier, is described by a manifest: + +```json +{ + "id": "cost-tracker", + "name": "Cost Tracker", + "version": "1.0.0", + "tier": "browser", + "author": "jeff", + "description": "Real-time token counting and cost estimation", + + "permissions": [ + "events:chat.message.*", + "events:model.selected", + "dom:input-area", + "storage:local" + ], + + "entry": "cost-tracker.js", + + "hooks": { + "chat.message.send": { "priority": 10, "async": false }, + "chat.message.received": { "priority": 50, "async": true } + }, + + "tools": [], + + "surfaces": [], + + "settings": { + "showInline": { + "type": "boolean", + "label": "Show cost inline", + "default": true + } + } +} +``` + +### 3.1 Fields + +- **id**: Unique identifier. Namespaced by convention (`jeff.cost-tracker`). +- **tier**: `browser` | `starlark` | `sidecar` +- **permissions**: What the extension needs access to. The loader enforces + these. Undeclared access is blocked (Tier 0 via proxy, Tier 1 via sandbox, + Tier 2 via API scoping). +- **entry**: For browser: JS file path. For starlark: `.star` file. For + sidecar: endpoint URL or Docker image. +- **hooks**: EventBus events this extension subscribes to, with priority + (lower = runs first) and whether the hook is async. +- **tools**: LLM-callable tools this extension provides (see §5). +- **surfaces**: UI surfaces this extension registers (see §6). +- **settings**: User-configurable options, rendered as a form in the + extension settings UI. + +--- + +## 4. Browser Extensions (Tier 0) + +### 4.1 Lifecycle + +``` +Install → Load → Init → Active → Disable → Unload +``` + +**Install**: Admin pushes manifest + JS to server, or user adds from +settings. Stored in `extensions` table with `tier = 'browser'`. + +**Load**: On page load, after `events.js` but before `app.js`, the +extension loader injects ` + + + + + + + + + + +``` + +### 7.2 extensions.js (Core) + +The extension loader and registry. ~200 lines. Responsibilities: + +- Fetch enabled extensions list from `/api/v1/extensions?tier=browser` +- Inject script tags in dependency order +- Provide `Extensions.register()` API +- Build scoped `ctx` objects per extension (permission enforcement) +- Manage surface activation/deactivation +- Collect browser tool schemas for the completion handler +- Route `tool.call.*` events to the correct handler +- Provide `Extensions.list()`, `Extensions.get()`, `Extensions.settings()` + +### 7.3 Backend Support + +New tables (migration 006): + +```sql +CREATE TABLE extensions ( + -- already exists from 001, but needs columns: + tier VARCHAR(20) NOT NULL DEFAULT 'browser', -- browser, starlark, sidecar + manifest JSONB NOT NULL, + assets_path TEXT, -- filesystem path for browser JS + endpoint TEXT, -- URL for sidecar + script TEXT, -- inline Starlark source + installed_by UUID REFERENCES users(id), + is_system BOOLEAN DEFAULT false +); + +CREATE TABLE extension_user_settings ( + extension_id UUID REFERENCES extensions(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + settings JSONB DEFAULT '{}', + is_enabled BOOLEAN DEFAULT true, + PRIMARY KEY (extension_id, user_id) +); +``` + +New endpoints: + +``` +GET /api/v1/extensions — list enabled for current user +GET /api/v1/extensions/:id/manifest — get manifest +GET /api/v1/extensions/:id/assets/*path — serve browser JS +POST /api/v1/extensions/:id/settings — update user settings + +POST /api/v1/admin/extensions — install extension +DELETE /api/v1/admin/extensions/:id — uninstall +PUT /api/v1/admin/extensions/:id — update (enable/disable, config) +``` + +### 7.4 Tool Registry Integration + +The completion handler's tool collection expands: + +```go +func (h *CompletionHandler) collectTools(userID string, configID string) []ToolSchema { + var tools []ToolSchema + + // 1. Model-native tools (if provider supports them) + caps := h.getModelCapabilities(model, configID) + if !caps.ToolCalling { + return nil // Model doesn't support tools, skip everything + } + + // 2. Server-side extension tools (Starlark + Sidecar) + tools = append(tools, h.serverTools(userID)...) + + // 3. Browser tools (only if WebSocket connected) + if h.hub.IsConnected(userID) { + tools = append(tools, h.browserTools(userID)...) + } + + return tools +} +``` + +--- + +## 8. EventBus Integration + +The routing table from events/types.go expands: + +```go +var Routes = map[string]Direction{ + // Core + "chat.message.*": DirBoth, + "user.presence": DirToClient, + + // Tool execution + "tool.call.*": DirToClient, // Server → specific client + "tool.result.*": DirFromClient, // Client → server + + // Surfaces + "surface.activated": DirLocal, // Client-only + "surface.deactivated": DirLocal, + + // Extension lifecycle + "extension.loaded": DirLocal, + "extension.error": DirLocal, + + // Cross-extension (cluster manager example) + "cluster.node.*": DirLocal, // Between browser extensions + "cluster.alert.*": DirBoth, // Could notify server too +} +``` + +Browser extensions use `Events.on()` and `Events.emit()` — the same API +the core app uses. Events with `DirBoth` or `DirFromClient` cross the +WebSocket. `DirLocal` events stay in the browser. This means cluster +manager extensions can coordinate locally at zero latency, and only +publish to the server when something needs persistence or notification. + +--- + +## 9. Implementation Roadmap + +### Phase A: Foundation (v0.6.0) +- [ ] `extensions.js` — loader, registry, scoped context +- [ ] `Extensions.register()` API with permission enforcement +- [ ] Manifest format parser and validator +- [ ] Admin endpoints for extension management +- [ ] Asset serving endpoint +- [ ] Extension settings UI in Settings modal + +### Phase B: Browser Tools (v0.6.1) +- [ ] `ctx.tools.handle()` API for browser tool registration +- [ ] Tool schema collection in completion handler +- [ ] `tool.call.*` / `tool.result.*` WebSocket routing +- [ ] Timeout and error handling for browser tools +- [ ] Tool execution in EventBus `WaitFor` pattern +- [ ] Tool use display in chat messages + +### Phase C: Surfaces (v0.6.2) +- [ ] Surface registration and activation API +- [ ] Mode selector in sidebar +- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management +- [ ] `surface.activated` / `surface.deactivated` events + +### Phase D: First Extensions (v0.7.0) +- [ ] Cost tracker (browser, proof of concept) +- [ ] Slash commands (browser, message transforms) +- [ ] Custom renderers (browser, mermaid/latex/etc) +- [ ] Editor mode (browser, with git provider tools) + +### Phase E: Server-Side Tiers (v0.8.0) +- [ ] Starlark runtime integration +- [ ] Sidecar HTTP tool protocol +- [ ] Server-side tool execution in completion handler +- [ ] Web search as sidecar extension + +--- + +## 10. Design Principles + +1. **Extensions are first-class.** The system is designed so that a mode + or feature implemented as an extension is indistinguishable from one + built into core. No second-class citizens. + +2. **The EventBus is the spine.** Extensions don't import each other. + They publish and subscribe to events. This is how the cluster manager + extensions cooperate without knowing about each other at build time. + +3. **Tools are location-transparent.** The LLM sees a tool schema. It + doesn't know if the tool runs in the browser, in a Starlark sandbox, + or in a container in another data center. The routing is the platform's + problem. + +4. **Permissions are declared, not discovered.** An extension says what it + needs upfront. The loader enforces it. No ambient authority. + +5. **The core stays small.** Auth, provider routing, message persistence, + event bus, extension loader. That's core. Everything else is an + extension — even if it ships with the project. + +6. **Progressive capability.** A browser extension with zero tools and + zero surfaces is just an EventBus subscriber. Add a tool and the LLM + can call it. Add a surface and it becomes a mode. The same manifest + format scales from "show token count" to "full IDE." diff --git a/ISSUES.md b/ISSUES.md index 9e90d5b..170ea20 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -1,371 +1,37 @@ -# Issue Management & Development Workflow +# Development Workflow -This document outlines the development workflow, issue labeling system, and contribution process for Chat Switchboard. +## Branches -## 📋 Issue Lifecycle +- `main` — stable, deployable +- `feature-*` — feature branches, merge to main via PR + +## Issue Format + +Title: `[CATEGORY] Description` + +Categories: `BACKEND`, `FRONTEND`, `DEVOPS`, `TESTING`, `DOCS`, `BUG` + +## Commit Messages ``` -Idea → Issue → Branch → Development → PR → Review → Merge → Close +[Category] Short description + +Longer explanation if needed. Closes #XX ``` -### 1. **Create Issue** -- Use descriptive title with prefix: `[CATEGORY] Description` -- Categories: `BACKEND`, `FRONTEND`, `EXTENSIONS`, `DATABASE`, `DEVOPS`, `TESTING`, `DOCS`, `FEATURE` -- Fill in description template -- Add priority emoji in body -- List dependencies (reference issue numbers) -- Estimate time +## PR Checklist -### 2. **Create Branch** -```bash -git checkout develop -git pull origin develop -git checkout -b issue-XX-short-description -``` +- [ ] Code follows existing style +- [ ] Tested manually +- [ ] No breaking API changes (or documented) +- [ ] Docs updated if user-facing +- [ ] Screenshots if UI changes -Branch naming: `issue-XX-short-kebab-case-description` +## Current Priorities -### 3. **Development** -- Keep commits focused and atomic -- Reference issue in commits: `Fix #XX: Description` -- Update issue with progress comments -- Request help if blocked +1. WebSocket hub (blocks Channels) +2. Channels backend + frontend +3. Plugin architecture design +4. Notes & Knowledge Base -### 4. **Create Pull Request** -- **Base:** `develop` (NOT `main`) -- **Title:** `[CATEGORY] Description (closes #XX)` -- Use PR template checklist -- Link to issue with `Closes #XX` -- Add screenshots/demos if UI changes - -### 5. **Code Review** -- At least 1 approval required -- All CI checks must pass -- Address review comments -- Squash commits if messy - -### 6. **Merge** -- Squash and merge to `develop` -- Delete branch after merge -- Issue auto-closes - -### 7. **Release** -- Periodically merge `develop` → `main` -- Tag with semantic version -- Auto-deploy to production - ---- - -## 🏷️ Labeling System - -### Priority (Use Emoji in Body) -- 🔴🔴🔴 **CRITICAL** - Blocker, must do first -- 🔴🔴 **HIGH** - Important, do soon -- 🔴 **MEDIUM** - Normal priority -- 🟡 **LOW** - Nice to have -- ⚪ **BACKLOG** - Maybe someday - -### Category (Use Prefix in Title) -- `[BACKEND]` - Go server, API, database -- `[FRONTEND]` - JavaScript UI, HTML/CSS -- `[EXTENSIONS]` - Plugin development -- `[DATABASE]` - Schema, migrations, queries -- `[DEVOPS]` - Docker, CI/CD, deployment -- `[TESTING]` - Tests, QA -- `[DOCS]` - Documentation -- `[FEATURE]` - New feature request -- `[BUG]` - Something broken -- `[INFRASTRUCTURE]` - Project setup, tooling - -### Special Tags (Add to Body) -- `🌟 killer-feature` - Unique differentiator -- `🔧 breaking-change` - API/schema breaking -- `🚀 performance` - Performance optimization -- `🔒 security` - Security-related -- `♿ accessibility` - A11y improvements -- `🎨 ui/ux` - Design/UX improvements - ---- - -## 📝 Issue Templates - -### Bug Report -```markdown -## Description -Clear description of the bug. - -## Steps to Reproduce -1. Go to... -2. Click on... -3. See error - -## Expected Behavior -What should happen. - -## Actual Behavior -What actually happens. - -## Environment -- OS: [e.g., Ubuntu 22.04] -- Browser: [e.g., Chrome 120] -- Version: [e.g., v0.2.0] - -## Screenshots -If applicable. - -## Priority -🔴🔴 **HIGH** - Blocks users -``` - -### Feature Request -```markdown -## Description -Clear description of the feature. - -## Problem It Solves -What user pain point does this address? - -## Proposed Solution -How should it work? - -## Alternatives Considered -Other approaches you thought about. - -## Priority -🟡 **LOW** - Nice to have - -## Estimated Time -X hours -``` - -### Extension Submission -```markdown -## Extension Name -`extension-name` - -## Description -What does this extension do? - -## Type -- [ ] Frontend (UI) -- [ ] Backend (Tool/API) -- [ ] Hybrid - -## Tools/Features -List of tools/hooks this provides. - -## Installation -How to install and configure. - -## Dependencies -- Requires: Issue #XX -- Python packages: `requests`, `beautifulsoup4` - -## Checklist -- [ ] Follows extension spec (`docs/PLUGIN_SPEC.md`) -- [ ] Includes `extension.json` manifest -- [ ] Has README with examples -- [ ] Includes tests -- [ ] Security reviewed (no arbitrary code execution) -``` - ---- - -## 🔄 Development Workflow - -### Daily Development -1. **Pick an issue** from the board -2. **Self-assign** the issue -3. **Create branch** from `develop` -4. **Code** and commit frequently -5. **Push** and open PR when ready -6. **Request review** from maintainers -7. **Merge** once approved - -### CI/CD Pipeline -``` -Push → Lint → Test → Build → Deploy (if main) -``` - -All PRs must pass: -- ✅ Go tests -- ✅ Go lint (golangci-lint) -- ✅ Frontend lint (ESLint) -- ✅ Build standalone HTML -- ✅ Docker build - -### Release Process -1. **Develop branch** accumulates features -2. **Create release branch** `release/vX.Y.Z` -3. **Test thoroughly** -4. **Merge to main** with tag -5. **CI auto-deploys** to production -6. **Merge back to develop** - ---- - -## 🎯 Current Priorities - -See [ROADMAP.md](ROADMAP.md) for phases. - -### Phase 1 (MVP Backend) -- Issue #2: Backend init -- Issue #3: Database -- Issue #4: Auth -- Issue #6: Extension Manager - -### Phase 2 (Core Features) -- Issue #8: Chat Engine -- Issue #12: Workflow Engine -- Issue #10: Mode Switching - -### Phase 3 (Killer Features) -- Issue #13: Workflow UI -- Issue #11: Extension UI - ---- - -## 🤝 Contribution Guidelines - -### Before Starting Work -1. **Check existing issues** - don't duplicate -2. **Comment on issue** - let others know you're working on it -3. **Ask questions** - if anything is unclear - -### Code Style -- **Go:** `gofmt`, follow standard Go conventions -- **JavaScript:** ESLint config, 2-space indent -- **Python:** Black formatter, PEP 8 - -### Commit Messages -``` -[Category] Short description (50 chars) - -Longer description if needed. Explain WHY, not WHAT. - -Closes #XX -``` - -Good examples: -- `[Backend] Add JWT refresh token rotation (closes #4)` -- `[Frontend] Implement WebSocket reconnection logic (closes #10)` -- `[Extensions] Create web-search plugin (closes #7)` - -### Testing Requirements -- **Backend:** Unit tests for new handlers/functions -- **Extensions:** Integration tests for tools -- **Frontend:** E2E tests for critical flows - -### Documentation -- Update README if adding user-facing features -- Update API docs if changing endpoints -- Add inline comments for complex logic -- Update ROADMAP if scope changes - ---- - -## 🐛 Bug Triage - -### Severity Levels -1. **Critical:** Production down, data loss, security breach -2. **High:** Major feature broken, affects many users -3. **Medium:** Minor feature broken, affects some users -4. **Low:** Cosmetic, edge case, rare occurrence - -### Response Times -- Critical: Fix immediately -- High: Fix within 1 week -- Medium: Fix within 1 month -- Low: Backlog - ---- - -## 📊 Project Board - -### Columns -1. **Backlog** - Not yet prioritized -2. **Ready** - Prioritized, ready to start -3. **In Progress** - Actively being worked on -4. **Review** - PR open, awaiting review -5. **Done** - Merged and closed - -### Moving Cards -- Assign yourself when moving to "In Progress" -- Link PR when moving to "Review" -- Close issue when merged - ---- - -## 🔍 Finding Issues to Work On - -### Good First Issues -- Labeled with 🟡 **LOW** priority -- Clear description and acceptance criteria -- No complex dependencies - -### Help Wanted -- Blocked issues needing expertise -- Complex problems needing collaboration - -### Current Sprint -- See project board "Ready" column -- Check ROADMAP.md for phase priorities - ---- - -## 📞 Getting Help - -- **Questions:** Comment on the issue -- **Blocked:** Tag maintainers in comment -- **Design decisions:** Open discussion issue -- **Security:** Email privately (don't open public issue) - ---- - -## 🎓 Learning Resources - -### Go -- [Effective Go](https://go.dev/doc/effective_go) -- [Go by Example](https://gobyexample.com/) - -### Extensions -- `docs/PLUGIN_SPEC.md` - Complete spec -- `extensions/_template-python/` - Starter template -- `docs/GETTING_STARTED.md` - Setup guide - -### Architecture -- `docs/ARCHITECTURE.md` - System design -- `docs/WORKFLOWS.md` - Workflow system -- `migrations/002_full_schema.sql` - Database schema - ---- - -## ✅ PR Checklist - -Copy this into PR description: - -```markdown -## Checklist -- [ ] Code follows style guide -- [ ] Tests added/updated -- [ ] Documentation updated -- [ ] No breaking changes (or documented in CHANGELOG) -- [ ] All CI checks pass -- [ ] Manually tested -- [ ] Screenshots/demo included (if UI changes) -- [ ] Closes #XX -``` - ---- - -## 🎉 Recognition - -Contributors will be: -- Listed in CONTRIBUTORS.md -- Mentioned in release notes -- Given credit in extension marketplace (if applicable) - ---- - -**Last Updated:** 2026-02-03 -**Maintained By:** @xcaliber +See [ROADMAP.md](ROADMAP.md) for detail. diff --git a/README.md b/README.md index 5f5df16..7a41d49 100644 --- a/README.md +++ b/README.md @@ -1,360 +1,192 @@ # 🔀 Chat Switchboard -**The Plugin-First, Multi-Model AI Platform** +**Multi-Model AI Chat Platform — Self-Hosted, Extensible, Fast** -Chat Switchboard is a next-generation AI interface that works offline or managed, with a unique plugin architecture and visual workflow builder. +A self-hosted AI chat interface that routes conversations across multiple LLM providers. Built with a Go backend for performance and a clean vanilla JS frontend inspired by Open WebUI. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?logo=go)](https://go.dev/) -[![Python Version](https://img.shields.io/badge/Python-3.9+-3776AB?logo=python)](https://python.org/) +[![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go)](https://go.dev/) --- -## 🎯 What Makes Us Different +## What It Does -| Feature | Chat Switchboard | Others | -|---------|-----------------|--------| -| **Works Offline** | ✅ Full-featured unmanaged mode | ❌ Backend required | -| **Plugin System** | ✅ Core features ARE plugins | 🟡 Limited or none | -| **Visual Workflows** | ✅ Chain multiple AI models | ❌ Single-shot only | -| **Multi-Model Routing** | ✅ Auto-select best/cheapest | 🟡 Manual only | -| **Channels** | ✅ User + AI collaboration | 🟡 Users only | -| **Self-Hosted** | ✅ Easy Docker setup | 🟡 Complex | - -**Unique Selling Points:** -1. **Workflows** - No competitor has visual AI orchestration (like n8n for LLMs) -2. **Dual-Mode** - Privacy-first offline mode OR full collaboration backend -3. **Plugin-First** - Chat, Channels, Notes are ALL plugins (proves extensibility) -4. **Smart Routing** - Automatic model selection for cost/quality optimization +- **Multi-provider routing** — Connect OpenAI, Anthropic, Venice.ai, Ollama, or any OpenAI-compatible API. Switch models per-conversation. +- **Streaming responses** — SSE-based streaming with stop button, thinking block display, and full markdown rendering. +- **Per-user API keys** — Each user manages their own provider keys. Admins can set global keys shared across the instance. +- **Admin panel** — User management, global provider config, model visibility, registration toggle, usage stats. +- **Self-hosted** — Single Docker image, PostgreSQL backend. No external dependencies at runtime. --- -## 🚀 Quick Start +## Quick Start -### Option 1: Offline Mode (No Backend) +### Docker (Recommended) ```bash -# Clone and build git clone https://git.gobha.me/xcaliber/chat-switchboard.git cd chat-switchboard -./build.sh - -# Open in browser -xdg-open standalone/index.html -``` - -**Configure API:** -1. Click ⚙️ Settings -2. Enter API endpoint (OpenAI, OpenRouter, Venice.ai, Ollama, etc.) -3. Add your API key -4. Start chatting! - -### Option 2: Full Backend (Collaboration + Workflows) - -```bash -# Clone repo -git clone https://git.gobha.me/xcaliber/chat-switchboard.git -cd chat-switchboard - -# Configure -cp .env.example .env -# Edit .env with your settings - -# Start with Docker +cp server/.env.example server/.env +# Edit .env: set DATABASE_URL, JWT_SECRET, etc. docker-compose up -d - -# Access at http://localhost:3000 ``` -See [GETTING_STARTED.md](docs/GETTING_STARTED.md) for detailed instructions. +Access at `http://localhost:3000`. First registered user becomes admin. ---- +### Manual -## ✨ Core Features - -### 1. 💬 Chat (User → AI) -- Multi-model support (OpenAI, Anthropic, Ollama, etc.) -- Per-conversation model switching -- Streaming responses with stop button -- Export (Markdown, JSON, Plain Text) -- Auto-routing to best/cheapest model (managed mode) - -### 2. 👥 Channels (User → User + AI) -**Managed mode only** - -Multi-user chat rooms where you can @mention AI models: - -``` -#general - @alice: What do you think about this design? - @claude: I'd suggest a darker color scheme for better contrast... - @bob: Great idea! @gpt4 can you review the implementation? - @gpt4: I found a potential issue in the error handling... -``` - -- Public/private/DM channels -- Real-time updates (WebSocket) -- Threaded conversations -- Reactions and formatting - -### 3. 📝 Notes & Knowledge Bases -- Markdown notes with folders -- Full-text and semantic search -- RAG (Retrieval Augmented Generation) in managed mode -- Link notes to chats - -### 4. 🔄 Workflows ⭐ (UNIQUE FEATURE) -**Managed mode only** - -Visual workflow builder for chaining AI models and tools: - -``` -[User Query] → [Web Search] → [GPT-4 Summarize] → [Claude Verify] → [Save to KB] -``` - -**Use Cases:** -- **Research Assistant:** Search → Summarize → Verify → Save -- **Code Review:** Fetch PR → Find Bugs → Security Check → Report -- **Multi-Model Consensus:** Run through 3 models → Vote → Best answer -- **Content Factory:** Outline → Draft → Edit → SEO → Publish - -See [WORKFLOWS.md](docs/WORKFLOWS.md) for details. - ---- - -## 🔌 Extension System - -### Everything is a Plugin - -Chat Switchboard proves its extensibility by implementing **core features as plugins**: - -``` -Core (Minimal) Plugins (Modular) -├── HTTP Router ├── Chat Engine (Python) -├── WebSocket Hub ├── Channels (Go) -├── Extension Manager ├── RAG Engine (Python) -├── Auth/Users ├── Workflows (Go/Python) -└── PostgreSQL └── Your Custom Plugin... -``` - -### Frontend Plugins (JavaScript) - -```javascript -// Simple UI extension -window.ChatSwitchboard.registerExtension({ - name: 'token-counter', - hooks: { - onMessageSend: (msg) => { - const tokens = estimateTokens(msg); - showToast(`~${tokens} tokens`); - } - } -}); -``` - -### Backend Plugins (Python, Go, Node.js) - -```python -# Full-featured extension -from fastapi import FastAPI - -app = FastAPI() - -@app.post("/tools/web_search") -async def search_web(query: str): - results = duckduckgo_search(query) - return {"results": results} -``` - -**Create a plugin:** ```bash -# Use template -cp -r extensions/_template-python extensions/my-plugin -cd extensions/my-plugin -# Edit extension.json, main.py -python main.py +# Backend +cd server +cp .env.example .env +go build -o switchboard . +./switchboard + +# Frontend — serve src/ with any HTTP server +cd ../src +python3 -m http.server 8080 ``` -See [PLUGIN_SPEC.md](docs/PLUGIN_SPEC.md) for complete guide. +Point the frontend at the backend by setting the API base URL (defaults to same-origin). --- -## 📚 Documentation - -- **[Getting Started](docs/GETTING_STARTED.md)** - Installation and setup -- **[Architecture](docs/ARCHITECTURE.md)** - System design and data flow -- **[Workflows](docs/WORKFLOWS.md)** - Visual workflow builder guide -- **[Plugin Spec](docs/PLUGIN_SPEC.md)** - Extension development -- **[Roadmap](ROADMAP.md)** - Development timeline and features - ---- - -## 🏗️ Architecture +## Architecture ``` -┌─────────────────────────────────────┐ -│ Frontend (Vanilla JS) │ -│ - Works offline (LocalStorage) │ -│ - Switches to backend if available │ -└─────────────┬───────────────────────┘ - │ - ┌─────────┴─────────┐ - │ │ -[Unmanaged] [Managed Mode] -LocalStorage │ - ▼ - ┌────────────────┐ - │ Go Backend │ - │ - Auth/Users │ - │ - WebSocket │ - │ - Extensions │ - └────────┬───────┘ - │ - ┌─────────────┴──────────────┐ - │ │ - PostgreSQL Extensions - - Chats, users - Chat (Python) - - Channels - RAG (Python) - - Knowledge - Workflows (Go) - - pgvector - Custom tools... +┌──────────────────────────┐ +│ Frontend (Vanilla JS) │ +│ 4 files + vendor libs │ +│ No build step required │ +└───────────┬──────────────┘ + │ REST + SSE + ▼ +┌──────────────────────────┐ +│ Go Backend │ +│ ├── Auth (JWT + refresh)│ +│ ├── Chat CRUD │ +│ ├── Completion proxy │ +│ ├── Provider management │ +│ ├── Admin endpoints │ +│ └── Static file server │ +└───────────┬──────────────┘ + │ + PostgreSQL ``` ---- +### Frontend (src/) -## 🛠️ Tech Stack +| File | Size | Purpose | +|------|------|---------| +| `api.js` | 240 lines | HTTP client, token management, auto-refresh on 401 | +| `app.js` | 490 lines | State, init flow, auth, chat CRUD, event wiring | +| `ui.js` | 510 lines | DOM rendering, streaming, modals, formatting | +| `debug.js` | 550 lines | Console/network intercept, state inspector (Ctrl+Shift+L) | +| `vendor/` | 62KB | marked.js + DOMPurify (local, CDN fallback) | -### Frontend -- **Vanilla JavaScript** - No framework bloat -- **LocalStorage** - Offline-first -- **WebSocket** - Real-time updates (managed) +No framework. No build step. No node_modules. Works in disconnected (SCIF) environments with vendor libs baked in. -### Backend (Managed Mode) -- **Go** - Core API, routing, WebSocket -- **PostgreSQL** - Primary storage -- **pgvector** - Vector embeddings for RAG -- **Redis** - WebSocket pub/sub (optional) -- **Python** - AI/ML extensions -- **Docker** - Easy deployment +### Backend (server/) + +Go 1.22, ~26 source files. Key packages: + +- `handlers/` — Auth, chats, messages, completions, API configs, admin +- `middleware/` — JWT auth, admin role check, rate limiting, error handling, logging +- `providers/` — OpenAI and Anthropic adapters with streaming support +- `models/` — Database models +- `database/` — PostgreSQL connection + migration runner +- `config/` — Environment-based configuration + +### API Surface + +| Route | Method | Auth | Description | +|-------|--------|------|-------------| +| `/health` | GET | — | Health check + version | +| `/api/v1/auth/register` | POST | — | Create account | +| `/api/v1/auth/login` | POST | — | Get JWT tokens | +| `/api/v1/auth/refresh` | POST | — | Rotate access token | +| `/api/v1/chats` | GET/POST | ✓ | List / create chats | +| `/api/v1/chats/:id` | GET/PUT/DELETE | ✓ | Chat CRUD | +| `/api/v1/chats/:id/messages` | GET | ✓ | Message history | +| `/api/v1/chat/completions` | POST | ✓ | Streaming SSE or sync JSON | +| `/api/v1/models/enabled` | GET | ✓ | Available models | +| `/api/v1/api-configs` | GET/POST/DELETE | ✓ | User provider management | +| `/api/v1/profile` | GET/PUT | ✓ | User profile | +| `/api/v1/settings` | GET/PUT | ✓ | User settings (persisted) | +| `/api/v1/admin/*` | various | admin | User/provider/model/settings management | --- -## 🎨 Screenshots +## Configuration -_(Coming soon - will add workflow builder, channels, chat interface)_ +All via environment variables (see `server/.env.example`): + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | Backend listen port | +| `DATABASE_URL` | — | PostgreSQL connection string | +| `JWT_SECRET` | — | Token signing key | +| `JWT_EXPIRY` | `15m` | Access token TTL | +| `REFRESH_EXPIRY` | `7d` | Refresh token TTL | +| `CORS_ORIGINS` | `*` | Allowed origins | +| `REGISTRATION_ENABLED` | `true` | Allow new signups | --- -## 🗺️ Roadmap +## Deployment -### Current: Phase 1 - Backend Core ✅ -- [x] Frontend (unmanaged mode) -- [ ] Go backend with PostgreSQL -- [ ] User authentication -- [ ] WebSocket server -- [ ] Extension manager +### Docker Compose (unified) -### Next: Phase 2 - Core Features as Plugins -- [ ] Chat Engine (Python) -- [ ] Channels (Go) -- [ ] Notes (Go) -- [ ] RAG Engine (Python) +The provided `Dockerfile` is a 3-stage build: +1. `golang:1.22-bookworm` — compiles Go backend +2. `node:20-alpine` — downloads vendor JS libs via `npm pack` +3. `nginx:1-alpine` — serves frontend, proxies `/api/` to Go backend -### Future: Phase 3+ -- [ ] Visual Workflow Builder -- [ ] Desktop app (Tauri) -- [ ] Extension marketplace -- [ ] Mobile PWA +Vendor libs are baked into the image at build time — no CDN access needed at runtime (SCIF-safe). -See [ROADMAP.md](ROADMAP.md) for complete timeline. +### Reverse Proxy + +If running behind nginx/Caddy, proxy `/api/` and `/health` to the Go backend. Serve `src/` as static files. --- -## 🤝 Contributing +## Development -We welcome contributions! Here's how: +```bash +# Backend (hot reload with air) +cd server +go install github.com/air-verse/air@latest +air -1. **Pick a task** from [ROADMAP.md](ROADMAP.md) or GitHub Issues -2. **Fork** the repo -3. **Create** a feature branch -4. **Submit** a PR +# Frontend — just edit files, hard-refresh browser +# Debug: Ctrl+Shift+L opens debug modal +``` -**Good first issues:** -- Frontend UI improvements -- Backend handler implementations -- Example extensions -- Documentation +### Database Migrations + +Migrations in `migrations/` run automatically on startup. Current schema: +- `001_full_schema.sql` — users, chats, messages, api_configs +- `002_refresh_tokens.sql` — token rotation +- `003_global_settings.sql` — admin settings table +- `004_model_configs.sql` — per-model enable/disable --- -## 📖 Example Use Cases +## Roadmap -### Personal (Unmanaged) -- Privacy-focused AI assistant -- Offline research tool -- Model comparison testing +See [ROADMAP.md](ROADMAP.md) for the full plan. Next up: -### Team (Managed) -- Collaborative AI workspace -- Shared knowledge bases -- Automated workflows -- Code review pipelines - -### Enterprise -- Self-hosted AI platform -- Custom model routing -- Compliance and audit logs -- SSO/SAML integration +1. **WebSocket hub** — real-time message delivery, typing indicators +2. **Channels** — multi-user + AI chat rooms with @mentions +3. **Plugin system** — Go-native extensions, installable via admin UI +4. **Notes & Knowledge Base** — markdown notes, document upload, RAG via pgvector --- -## 🆚 Comparison +## License -### vs Open WebUI -- ✅ Works offline (unmanaged mode) -- ✅ Visual workflows (they don't have) -- ✅ Plugin-first architecture -- 🟰 Similar RAG features - -### vs ChatGPT/Claude Desktop -- ✅ Multi-model (not locked to one provider) -- ✅ Self-hosted option -- ✅ Open source -- ✅ Extensible (closed systems) -- 🟰 Similar UX quality - -### vs LangChain -- ✅ Visual workflow builder (no-code) -- ✅ Multi-model orchestration -- 🟰 Similar capabilities -- ❌ Less Python ecosystem (for now) - -**Unique Position:** LangChain for non-coders + n8n for LLMs + privacy-first design +MIT — build anything, including commercial products. --- -## 📄 License - -MIT License - build anything, including commercial products. - ---- - -## 🙏 Credits - -Built with inspiration from: -- Open WebUI (knowledge bases, channels) -- Claude Desktop (thinking blocks) -- n8n (workflow concepts) -- VSCode (plugin architecture) - ---- - -## 🔗 Links - -- **Repository:** https://git.gobha.me/xcaliber/chat-switchboard -- **Documentation:** [/docs](docs/) -- **Issues:** [GitHub Issues](https://git.gobha.me/xcaliber/chat-switchboard/issues) -- **Discussions:** (Coming soon) - ---- - -**Ready to build the future of AI interfaces? Star the repo and let's go! 🚀** +**Repository:** https://git.gobha.me/xcaliber/chat-switchboard diff --git a/ROADMAP.md b/ROADMAP.md index 46665c9..f94f21a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,482 +1,180 @@ -# 🗺️ Chat Switchboard Roadmap +# 🗺️ Chat Switchboard — Roadmap -## Vision +**See also:** +- [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) -**Become the "VSCode of AI Interfaces"** - A minimal, extensible core with infinite capabilities through plugins. +## Current State: v0.5.4 -## Competitive Position +### ✅ Done -| Feature | Chat Switchboard | Open WebUI | ChatGPT | Claude Desktop | LibreChat | -|---------|-----------------|------------|---------|----------------|-----------| -| **Multi-Model** | ✅ Auto-routing | ✅ Manual | ❌ | ❌ | ✅ | -| **Plugin System** | ✅⭐ Dogfooded | 🟡 Limited | ❌ | ❌ | 🟡 | -| **Offline Mode** | ✅ Full featured | ❌ | ❌ | 🟡 | ❌ | -| **Workflows** | ✅⭐ Visual DAG | ❌ | ❌ | ❌ | ❌ | -| **Channels** | ✅ With AI | ✅ | ❌ | ❌ | 🟡 | -| **RAG/Knowledge** | ✅ pgvector | ✅ | 🟡 | 🟡 | ✅ | -| **Self-Hosted** | ✅ Easy | ✅ Complex | ❌ | ❌ | ✅ | -| **Open Source** | ✅ MIT | ✅ MIT | ❌ | ❌ | ✅ MIT | -| **No Backend Required** | ✅⭐ Optional | ❌ | ❌ | ❌ | ❌ | -| **Desktop App** | 🟡 Planned | ❌ | ✅ | ✅ | ❌ | -| **Mobile App** | 🟡 PWA | 🟡 | ✅ | ✅ | 🟡 | +**Backend (Go)** +- [x] PostgreSQL schema + auto-migrations (go:embed, startup) +- [x] JWT auth with refresh token rotation +- [x] Chat CRUD + message persistence +- [x] Streaming completion proxy (OpenAI + Anthropic + Venice + OpenRouter) +- [x] Per-user + global API config management +- [x] Admin endpoints (users, providers, models, settings, stats) +- [x] Rate limiting, error middleware, request logging +- [x] Health check endpoint (includes schema_version) +- [x] EventBus with WebSocket hub +- [x] Provider capability system (known models, heuristic detection, resolution chain) +- [x] Dynamic max_tokens resolution (no more hardcoded 4096) +- [x] User + admin provider model listing with capabilities -**Unique Advantages:** -1. **Workflows** - No competitor has visual AI orchestration -2. **Plugin-First** - Core features ARE plugins (proves extensibility) -3. **Dual-Mode** - Works offline or managed (privacy + collaboration) -4. **Smart Routing** - Automatic model selection for cost/quality - -## Development Phases - -### ✅ Phase 0: Foundation (Current) -**Status:** Complete -**Timeline:** Week 1-2 - -- [x] Frontend core (vanilla JS) -- [x] LocalStorage state management -- [x] OpenAI-compatible API client -- [x] Streaming responses -- [x] Chat history +**Frontend (Vanilla JS)** +- [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] Streaming SSE display with smart scroll +- [x] Full markdown rendering (marked.js + DOMPurify) +- [x] Thinking block display (``/`` tags) +- [x] Settings modal (profile, chat prefs, provider management) +- [x] Admin modal (users, providers, models with cap badges, settings, stats) +- [x] Debug modal (console intercept, network log, state inspector) +- [x] EventBus client with exponential backoff + max retries - [x] Export (Markdown, JSON, Text) -- [x] Model switching -- [x] Standalone build +- [x] Vendor libs with CDN fallback (SCIF-safe) +- [x] 3-stage Dockerfile (Go build → vendor download → nginx) -### 🚧 Phase 1: Backend Core -**Status:** In Progress -**Timeline:** Week 3-6 (4 weeks) +**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] Shared PG safety (_dev suffix guard on wipe) +- [x] Post-deploy schema verification via /api/v1/health -**Goals:** -- Full Go backend with PostgreSQL -- User authentication (JWT) -- WebSocket server for real-time -- Extension manager (load/manage plugins) -- Mode switching (unmanaged ↔ managed) +**Architecture Design (v0.5.4)** +- [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) -**Tasks:** -- [ ] Implement auth handlers (register, login, JWT) -- [ ] PostgreSQL models and migrations -- [ ] WebSocket hub for real-time messaging -- [ ] Extension discovery and lifecycle management -- [ ] API endpoints for chats, settings, users -- [ ] Frontend mode detection and switching +### Implementation Phasing -**Deliverables:** -- Users can create accounts -- Backend stores chat history -- WebSocket connections work -- At least 1 extension loads and runs +**See ARCHITECTURE.md §10 for the authoritative implementation sequence.** +Phases 1–8, from channel foundation through RBAC. --- -### 🎯 Phase 2: Core Features as Plugins -**Status:** Not Started -**Timeline:** Week 7-10 (4 weeks) +## Phase 1: Real-Time Foundation -**Goal:** Prove plugin architecture by implementing core features as extensions +### 1.1 WebSocket Hub +**Priority:** HIGH — blocks Channels, typing indicators, live updates -**2.1 Chat Engine Plugin (Python)** -``` -extensions/backend/chat-engine/ -├── extension.json -├── main.py -├── requirements.txt -└── models/ - ├── openai.py - ├── anthropic.py - └── ollama.py -``` +- [ ] 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` -**Tasks:** -- [ ] Multi-provider adapter pattern -- [ ] Streaming response handling -- [ ] Token counting and cost tracking -- [ ] Message history management -- [ ] System prompt injection +### 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 -**2.2 Channels Plugin (Go)** -``` -extensions/backend/channels/ -├── extension.json -├── main.go -├── handlers/ -│ ├── channels.go -│ └── messages.go -└── websocket/ - └── broadcaster.go -``` +--- -**Tasks:** -- [ ] Channel CRUD operations -- [ ] Membership management -- [ ] Real-time message broadcasting -- [ ] @mention parsing (users + AI models) -- [ ] Threading support +## Phase 2: Channels -**2.3 Notes Plugin (Go)** -``` -extensions/backend/notes/ -├── extension.json -├── main.go -└── handlers/ - └── notes.go -``` +### 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 -**Tasks:** -- [ ] Note CRUD +### 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 + +--- + +## Phase 3: Plugin System + +### 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) + +### 3.2 Built-in Plugin: Web Search +- [ ] Tool-use integration in completion flow +- [ ] Search provider abstraction (DuckDuckGo, SearXNG, Brave) +- [ ] Results injected into context + +### 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 -- [ ] Tagging system -- [ ] Markdown rendering -- [ ] Full-text search +- [ ] Full-text search (PostgreSQL `tsvector`) +- [ ] Markdown editor in frontend +- [ ] Link notes to chats -**Deliverables:** -- Chat works via chat-engine extension -- Channels support user + AI conversations -- Notes can be created and organized -- All features disabled if extension not loaded - ---- - -### 🚀 Phase 3: RAG & Knowledge Bases -**Status:** Not Started -**Timeline:** Week 11-13 (3 weeks) - -**Goal:** Advanced knowledge management with vector search - -**3.1 RAG Engine Plugin (Python)** -``` -extensions/backend/rag-engine/ -├── extension.json -├── main.py -├── embeddings.py -├── chunker.py -└── requirements.txt - # Dependencies: langchain, pgvector, tiktoken -``` - -**Features:** +### 4.2 RAG (Retrieval Augmented Generation) - [ ] Document upload (PDF, DOCX, TXT, MD) -- [ ] Smart chunking (recursive, semantic) +- [ ] Chunking strategies (recursive, semantic) - [ ] Embedding generation (OpenAI, local models) -- [ ] Vector storage (pgvector) -- [ ] Semantic search -- [ ] Context injection in chat - -**3.2 Knowledge Base UI** -- [ ] KB management interface -- [ ] Drag-and-drop upload -- [ ] Document viewer -- [ ] Search results preview -- [ ] Chat integration (auto-search when enabled) - -**Deliverables:** -- Upload documents to KB -- Semantic search works -- Chat can reference KB automatically -- Cost tracking for embeddings +- [ ] pgvector storage and similarity search +- [ ] Context injection in completion flow +- [ ] Per-KB toggle in chat settings --- -### ⭐ Phase 4: Workflows (KILLER FEATURE) -**Status:** Not Started -**Timeline:** Week 14-18 (5 weeks) +## Phase 5: Polish -**Goal:** Visual workflow builder for multi-step AI operations - -**4.1 Workflow Engine (Go + Python)** -``` -extensions/backend/workflows/ -├── extension.json -├── executor.go # Go: DAG execution -├── registry.py # Python: Tool registry -└── nodes/ - ├── llm_node.go - ├── tool_node.go - ├── conditional_node.go - └── loop_node.go -``` - -**Features:** -- [ ] DAG execution engine -- [ ] Node type system (LLM, tool, logic, I/O) -- [ ] Variable resolution and templating -- [ ] Conditional branching -- [ ] Loops and iteration -- [ ] Error handling and retries -- [ ] Cost tracking per workflow -- [ ] Execution history and logs - -**4.2 Visual Workflow Builder (Frontend)** -- [ ] Drag-and-drop canvas -- [ ] Node palette (search and add) -- [ ] Connection drawing -- [ ] Node configuration panels -- [ ] Live execution preview -- [ ] Debug mode (step-through) - -**4.3 Workflow Templates** -- [ ] Research Assistant -- [ ] Code Review Pipeline -- [ ] Multi-Model Consensus -- [ ] Content Creation Factory -- [ ] Data Processing Pipeline - -**Deliverables:** -- Users can create workflows visually -- Workflows execute correctly -- Template marketplace basics -- At least 5 working templates +- [ ] 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 --- -### 🔧 Phase 5: Developer Experience -**Status:** Not Started -**Timeline:** Week 19-21 (3 weeks) +## Future -**Goal:** Make extension development effortless - -**5.1 Extension Templates** -- [ ] `_template-python` with FastAPI boilerplate -- [ ] `_template-go` with Gin boilerplate -- [ ] `_template-node` with Express boilerplate -- [ ] CLI tool: `chatswitch create-extension` - -**5.2 Extension Marketplace** -``` -marketplace/ -├── backend/ -│ ├── api.go # Publish, search, install -│ └── storage/ # Extension packages -└── frontend/ - └── marketplace.html # Browse and install UI -``` - -**Features:** -- [ ] Publish extensions (tarball upload) -- [ ] Search and browse -- [ ] One-click install -- [ ] Ratings and reviews -- [ ] Version management -- [ ] Dependency resolution - -**5.3 Documentation & Examples** -- [ ] Complete API documentation -- [ ] 10+ example extensions -- [ ] Video tutorials -- [ ] Plugin development guide -- [ ] Contributing guidelines - -**Deliverables:** -- Easy extension scaffolding -- Working marketplace -- Comprehensive docs +- 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 +- Multi-tenant SaaS mode --- -### 💎 Phase 6: Polish & Launch Prep -**Status:** Not Started -**Timeline:** Week 22-24 (3 weeks) +## Plugin / Extension Architecture -**6.1 Desktop App (Tauri)** -``` -desktop/ -├── src-tauri/ # Rust backend -│ ├── main.rs -│ └── Cargo.toml -└── src/ # Frontend (reuse web) - └── index.html -``` - -**Features:** -- [ ] Native app wrapper -- [ ] System tray integration -- [ ] Keyboard shortcuts -- [ ] Native notifications -- [ ] Auto-updates - -**6.2 Mobile (PWA)** -- [ ] Responsive design -- [ ] Touch gestures -- [ ] Offline support -- [ ] Install prompt - -**6.3 Performance** -- [ ] Frontend bundle optimization -- [ ] Backend query optimization -- [ ] WebSocket connection pooling -- [ ] Redis caching -- [ ] CDN for static assets - -**6.4 Security Audit** -- [ ] SQL injection prevention -- [ ] XSS protection -- [ ] CSRF tokens -- [ ] Rate limiting -- [ ] API key encryption audit -- [ ] Extension sandboxing review - -**Deliverables:** -- Desktop app for Windows, Mac, Linux -- Mobile-friendly PWA -- Performance benchmarks -- Security audit report - ---- - -### 🎉 Phase 7: Launch -**Timeline:** Week 25 - -**7.1 Marketing** -- [ ] Landing page -- [ ] Demo video -- [ ] Blog post -- [ ] HackerNews/Reddit launch -- [ ] ProductHunt submission - -**7.2 Community** -- [ ] Discord server -- [ ] GitHub Discussions -- [ ] Documentation site -- [ ] Example workflows gallery - -**7.3 Infrastructure** -- [ ] Hosted demo instance -- [ ] CI/CD pipeline -- [ ] Monitoring (Prometheus, Grafana) -- [ ] Error tracking (Sentry) - -**Launch Checklist:** -- [ ] All core features working -- [ ] 10+ bundled extensions -- [ ] Desktop app released -- [ ] Documentation complete -- [ ] Demo video published -- [ ] Community channels live - ---- - -## Post-Launch Roadmap - -### v1.1 - Enterprise Features -**Timeline:** Month 2-3 - -- [ ] SSO/SAML authentication -- [ ] Team management -- [ ] Role-based access control (RBAC) -- [ ] Audit logs -- [ ] Usage analytics dashboard -- [ ] Billing integration (Stripe) - -### v1.2 - Advanced Workflows -**Timeline:** Month 4-5 - -- [ ] Workflow version control (Git-like) -- [ ] Collaborative editing -- [ ] A/B testing workflows -- [ ] Scheduled executions (cron) -- [ ] Webhook triggers -- [ ] API-first workflow execution - -### v1.3 - AI Enhancements -**Timeline:** Month 6-7 - -- [ ] Fine-tuned model hosting -- [ ] Custom embedding models -- [ ] Multi-modal support (images, audio) -- [ ] Voice chat -- [ ] Real-time translation - -### v2.0 - Platform Evolution -**Timeline:** Month 8-12 - -- [ ] Plugin marketplace monetization -- [ ] White-label solution -- [ ] Multi-tenant SaaS mode -- [ ] Advanced analytics (cost optimization, usage insights) -- [ ] Mobile native apps (React Native) - ---- - -## Success Metrics - -### User Metrics -- **Week 1:** 100 GitHub stars -- **Month 1:** 1,000 active users -- **Month 3:** 5,000 active users, 50 community extensions -- **Month 6:** 20,000 active users, 200 community extensions - -### Technical Metrics -- **Plugin Load Time:** < 2s -- **API Response Time:** < 100ms (p95) -- **WebSocket Latency:** < 50ms -- **Uptime:** 99.9% - -### Business Metrics (If Applicable) -- **Free Tier:** Unlimited local use -- **Hosted Basic:** $10/user/month -- **Hosted Pro:** $25/user/month (workflows, advanced features) -- **Enterprise:** Custom pricing - ---- - -## Risk Mitigation - -### Technical Risks -| Risk | Mitigation | -|------|-----------| -| Extension security vulnerabilities | Sandboxing, code review, security audit | -| WebSocket scaling issues | Redis pub/sub, horizontal scaling | -| Database performance | Query optimization, indexing, caching | -| Third-party API rate limits | Retry logic, fallback chains, caching | - -### Product Risks -| Risk | Mitigation | -|------|-----------| -| Competitors copy workflows feature | Speed to market, open-source advantage | -| Low plugin adoption | High-quality templates, good DX | -| Users prefer monolithic apps | Prove extensibility with core features | - -### Market Risks -| Risk | Mitigation | -|------|-----------| -| AI providers release similar tools | Self-hosted option, multi-provider support | -| Regulation changes | Privacy-first design, compliance features | - ---- - -## How to Contribute - -### For Developers -1. Pick an issue from GitHub -2. Fork the repo -3. Create a feature branch -4. Submit a PR - -**High Priority:** -- Implement Phase 1 backend tasks -- Create example extensions -- Write documentation - -### For Designers -- UI/UX improvements -- Workflow builder mockups -- Landing page design - -### For Writers -- Blog posts -- Tutorials -- Documentation improvements - -### For Users -- Bug reports -- Feature requests -- Extension ideas - ---- - -## Questions? - -- **GitHub Issues:** Bug reports, feature requests -- **GitHub Discussions:** General questions, ideas -- **Discord:** Real-time chat (coming soon) - -**Let's build the future of AI interfaces together! 🚀** +**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. +- [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. diff --git a/VERSION b/VERSION index cb0c939..7d85683 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.2 +0.5.4 diff --git a/build.sh b/build.sh deleted file mode 100755 index 9260637..0000000 --- a/build.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -# ========================================== -# Chat Switchboard - Build Script -# ========================================== -# Combines src/ files into a single standalone HTML file - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SRC_DIR="$SCRIPT_DIR/src" -OUTPUT_DIR="$SCRIPT_DIR/standalone" -OUTPUT_FILE="$OUTPUT_DIR/index.html" - -echo "🔀 Building Chat Switchboard..." - -# Ensure output directory exists -mkdir -p "$OUTPUT_DIR" - -# Read source files -CSS_CONTENT=$(cat "$SRC_DIR/css/styles.css") -JS_BACKEND=$(cat "$SRC_DIR/js/backend.js") -JS_STORAGE=$(cat "$SRC_DIR/js/storage.js") -JS_STATE=$(cat "$SRC_DIR/js/state.js") -JS_API=$(cat "$SRC_DIR/js/api.js") -JS_UI=$(cat "$SRC_DIR/js/ui.js") -JS_APP=$(cat "$SRC_DIR/js/app.js") - -# Read HTML and extract head/body content -HTML_CONTENT=$(cat "$SRC_DIR/index.html") - -# Create standalone file -cat > "$OUTPUT_FILE" << 'HTMLEOF' - - - - - - Chat Switchboard - - -HTMLEOF - -# Extract body content (between and , excluding script tags) -BODY_CONTENT=$(echo "$HTML_CONTENT" | sed -n '//,/<\/body>/p' | sed '1d;$d' | sed '/ - - -HTMLEOF - -# Get file size -SIZE=$(wc -c < "$OUTPUT_FILE" | tr -d ' ') -SIZE_KB=$((SIZE / 1024)) - -echo "✅ Build complete!" -echo " Output: $OUTPUT_FILE" -echo " Size: ${SIZE_KB}KB ($SIZE bytes)" -echo "" -echo "📦 To use:" -echo " 1. Open standalone/index.html in a browser" -echo " 2. Or serve with: python3 -m http.server 8080" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 0e8f8c2..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,482 +0,0 @@ -# 🏗️ Chat Switchboard Architecture - -## Overview - -Chat Switchboard is a **plugin-first, multi-model AI platform** with dual-mode operation: - -``` -┌─────────────────────────────────────────────────────┐ -│ FRONTEND (Vanilla JS) │ -│ ├─ Managed Mode: Full backend features + auth │ -│ └─ Unmanaged Mode: LocalStorage only, offline │ -└─────────────────────┬───────────────────────────────┘ - │ - ┌────────────┴────────────┐ - │ │ - [HTTP/WS] [LocalStorage Only] - │ -┌────────▼─────────────────────────────────────────────┐ -│ BACKEND (Go + PostgreSQL) │ -│ ├─ Core API (minimal, orchestration only) │ -│ ├─ Extension Manager │ -│ └─ WebSocket Server (real-time features) │ -└────────┬─────────────────────────────────────────────┘ - │ - ├─ PostgreSQL (state, users, data) - │ - └─ Extensions (modular features) - ├─ Chat Engine (Python) - ├─ Channels (Go) - ├─ RAG Engine (Python) - ├─ Workflow Builder (Go/Python) - └─ Custom Extensions... -``` - -## Operating Modes - -### 🏠 Unmanaged Mode (LocalStorage) -**Use Case:** Personal use, offline, privacy-focused - -- ✅ No backend required -- ✅ No account/auth needed -- ✅ Works offline -- ✅ All data in browser LocalStorage -- ✅ Export/import for backup -- ❌ No multi-device sync -- ❌ No collaboration features -- ❌ No server-side extensions - -**Detection:** -```javascript -// src/js/state.js -State.mode = State.settings.backendUrl ? 'managed' : 'unmanaged'; -``` - -### 🌐 Managed Mode (Full Backend) -**Use Case:** Teams, collaboration, advanced features - -- ✅ Multi-user with authentication -- ✅ Real-time collaboration (WebSockets) -- ✅ Server-side extensions (Python, Go) -- ✅ Shared knowledge bases -- ✅ Workflow orchestration -- ✅ Usage analytics -- ❌ Requires backend deployment - -**Switching:** -```javascript -// User configures backend URL in settings -State.settings.backendUrl = 'https://api.chatswitch.example.com'; -State.settings.backendToken = 'jwt_token_here'; -``` - -## Core Features (All Modes) - -### 1. Chat (User → LLM) -- Per-conversation model selection -- Streaming responses -- Message history -- Export (Markdown, JSON, Plain Text) - -**Managed Mode Additions:** -- Multi-model auto-routing -- Cost tracking -- Shared chats -- Server-side tool calling - -### 2. Channels (User → User + AI) -**Managed Mode Only** - -- Public/private channels -- @mentions for users and AI models -- Threaded conversations -- Reactions -- Real-time updates (WebSocket) - -**Example:** -``` -@alice What do you think about this design? -@claude-3.5 Can you review this code? -``` - -### 3. Notes & Knowledge Bases -**Both Modes:** -- Personal notes -- Markdown editing -- Tagging and folders - -**Managed Mode Additions:** -- Shared notes -- Knowledge base collections -- RAG (Retrieval Augmented Generation) -- Vector embeddings (pgvector) -- Semantic search - -### 4. Workflows (🌟 UNIQUE FEATURE) -**Managed Mode Only** - -Visual workflow builder for multi-step AI operations: - -``` -[User Input] → [Model A: Research] → [Model B: Summarize] → [Tool: Save to KB] → [Output] -``` - -**Use Cases:** -- Research pipelines (search → summarize → extract → store) -- Multi-model consensus (run prompt through 3 models, compare) -- Data processing (extract → transform → analyze → report) -- Content creation (outline → draft → edit → format) - -**Why It's Unique:** -- Competitors have single-shot chat -- This enables **AI composition** - chain multiple models and tools -- Shareable templates (marketplace potential) -- Visual no-code builder - -## Extension Architecture - -### Extension Types - -#### 1. Frontend Extensions (UI Only) -**Both Modes** - Pure JavaScript, no backend needed - -```javascript -// extensions/token-counter/main.js -window.ChatSwitchboard.registerExtension({ - name: 'token-counter', - hooks: { - onMessageSend: (message) => { - const tokens = estimateTokens(message); - showToast(`~${tokens} tokens`); - } - } -}); -``` - -#### 2. Backend Extensions (Full Power) -**Managed Mode Only** - Python, Go, Node.js - -``` -extensions/ -├── web-search/ -│ ├── extension.json # Manifest -│ ├── main.py # Python service -│ └── requirements.txt -``` - -**Communication:** HTTP/gRPC between Go core and extension processes - -### Extension Protocol - -```json -// extension.json -{ - "name": "web-search", - "version": "1.0.0", - "runtime": "python", - "entry": "main.py", - "port": 9001, - "capabilities": ["tool"], - "tools": [ - { - "name": "search_web", - "description": "Search the web for information", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string"} - }, - "required": ["query"] - } - } - ] -} -``` - -## Technology Stack - -### Frontend -- **Vanilla JavaScript** - No framework bloat -- **CSS3** - Custom dark theme -- **LocalStorage** - Offline-first -- **WebSocket** - Real-time (managed mode) - -### Backend (Managed Mode) -- **Go** - Core API, routing, orchestration -- **PostgreSQL** - Primary data store -- **pgvector** - Vector embeddings for RAG -- **Redis** - Session cache, WebSocket pub/sub (optional) -- **Python** - AI/ML extensions (LangChain, embeddings) - -### Infrastructure -- **Docker** - Easy deployment -- **nginx** - Reverse proxy -- **Let's Encrypt** - TLS certificates - -## Data Flow - -### Unmanaged Mode -``` -User Input → Frontend State → LocalStorage - ↓ - API Provider (OpenAI, etc) - ↓ - Frontend State → LocalStorage -``` - -### Managed Mode -``` -User Input → Frontend → WebSocket → Backend - ↓ - PostgreSQL - ↓ - Extension Manager - ↓ ↓ - [Chat Engine] [RAG Engine] - ↓ ↓ - Model Router → API Provider - ↓ - WebSocket → Frontend -``` - -## WebSocket Protocol - -### Events (Managed Mode) - -```javascript -// Client → Server -{ - type: 'chat.send', - chatId: 'uuid', - message: 'Hello', - model: 'gpt-4' -} - -// Server → Client (streaming) -{ - type: 'chat.stream', - chatId: 'uuid', - delta: 'Hello! ', - done: false -} - -// Channel messages -{ - type: 'channel.message', - channelId: 'uuid', - content: '@claude What do you think?', - mentions: {users: [], models: ['claude-3.5']} -} - -// AI response in channel -{ - type: 'channel.ai_response', - channelId: 'uuid', - model: 'claude-3.5', - content: 'I think...', - inReplyTo: 'message_uuid' -} -``` - -### Connection Management -```go -// server/websocket/hub.go -type Hub struct { - clients map[*Client]bool - broadcast chan Message - register chan *Client - unregister chan *Client -} -``` - -## Security - -### Unmanaged Mode -- All sensitive data (API keys) in browser localStorage -- User responsible for backups -- No server-side attack surface - -### Managed Mode -- **Authentication:** JWT tokens -- **Authorization:** RBAC (roles: user, admin, moderator) -- **API Keys:** Encrypted at rest (pgcrypto) -- **Rate Limiting:** Per-user, per-endpoint -- **CORS:** Strict origin checking -- **Webhooks:** HMAC signature verification -- **Extensions:** Sandboxed execution - -## Deployment - -### Unmanaged Mode -```bash -# Build standalone -./build.sh - -# Serve -python3 -m http.server 8080 --directory standalone -# or upload index.html to any static host -``` - -### Managed Mode -```bash -# Docker Compose -docker-compose up -d - -# Services: -# - postgres:5432 -# - backend:8080 -# - redis:6379 (optional) -# - nginx:443 -``` - -## Scaling - -### Horizontal Scaling (Managed) -``` - ┌─ Load Balancer (nginx) - ├─ Backend Instance 1 ─┐ - ├─ Backend Instance 2 ─┼─ PostgreSQL (primary) - └─ Backend Instance 3 ─┘ - │ - Redis (WebSocket sync) -``` - -### Extension Scaling -- Extensions are separate processes -- Can run on different machines -- Service discovery via extension registry - -## Plugin Ecosystem (🌟 DIFFERENTIATOR) - -### Core Principle -**Everything is a plugin** (except minimal routing core) - -| Feature | Implementation | -|---------|---------------| -| Chat | `extensions/chat-engine/` | -| Channels | `extensions/channels/` | -| Notes | `extensions/notes/` | -| Knowledge Bases | `extensions/rag-engine/` | -| Workflows | `extensions/workflows/` | - -### Why This Matters -1. **Proof of Extensibility** - If core features work as plugins, any feature can -2. **Community Innovation** - Users build missing features -3. **Customization** - Disable unwanted features -4. **Marketplace Potential** - Monetize premium plugins - -### Plugin Types - -#### Official Plugins (Bundled) -- Chat Engine -- Channels -- RAG/Knowledge -- Web Search -- Code Execution - -#### Community Plugins (Marketplace) -- Specialized tools -- Industry-specific workflows -- Integration plugins (Slack, Discord, etc) -- Custom AI models - -#### Enterprise Plugins (Premium) -- SSO/SAML -- Advanced analytics -- Compliance logging -- Custom model hosting - -## Unique Features Summary - -### 1. Workflow Builder (⭐⭐⭐) -**What:** Visual DAG builder for multi-step AI operations -**Why Unique:** No competitor has AI orchestration at this level -**Use Case:** Research assistant = [Search] → [GPT-4 summarize] → [Claude verify] → [Save to KB] - -### 2. Dual-Mode Operation (⭐⭐) -**What:** Works offline (LocalStorage) or managed (full backend) -**Why Unique:** Privacy-first with optional collaboration -**Use Case:** Personal use → upgrade to team without migration - -### 3. Plugin-First Architecture (⭐⭐⭐) -**What:** Core features ARE plugins (dogfooding) -**Why Unique:** Proves extensibility, enables marketplace -**Use Case:** Build custom enterprise workflows as plugins - -### 4. Multi-Model Auto-Routing (⭐⭐) -**What:** Automatically route to best/cheapest model -**Why Unique:** Most tools lock you to one provider -**Use Case:** Simple questions → GPT-3.5 ($), complex → GPT-4 ($$$) - -### 5. Channels with AI Participants (⭐) -**What:** Slack-like channels where you @mention AI models -**Why Unique:** Collaborative AI + human conversations -**Use Case:** Team discusses design, @claude gives input, @gpt4 suggests alternatives - -## Comparison Matrix - -| Feature | Chat Switchboard | Open WebUI | ChatGPT | Claude Desktop | -|---------|-----------------|------------|---------|----------------| -| Multi-Model | ✅ | ✅ | ❌ | ❌ | -| Plugin System | ✅⭐ | Limited | ❌ | ❌ | -| Offline Mode | ✅ | ❌ | ❌ | Limited | -| Workflows | ✅⭐ | ❌ | ❌ | ❌ | -| Channels | ✅ | ✅ | ❌ | ❌ | -| RAG/Knowledge | ✅ | ✅ | ❌ | Limited | -| Self-Hosted | ✅ | ✅ | ❌ | ❌ | -| Open Source | ✅ | ✅ | ❌ | ❌ | - -**Competitive Advantage:** Workflows + Plugin Architecture + Dual-Mode - -## Roadmap - -### Phase 1: Foundation (Weeks 1-4) -- ✅ Frontend core -- ⬜ Backend API skeleton -- ⬜ PostgreSQL integration -- ⬜ WebSocket server -- ⬜ Extension manager - -### Phase 2: Core Features (Weeks 5-8) -- ⬜ Chat engine (as plugin) -- ⬜ Channels (as plugin) -- ⬜ Notes (as plugin) -- ⬜ Basic auth/users - -### Phase 3: Unique Features (Weeks 9-12) -- ⬜ RAG/Knowledge bases -- ⬜ Workflow builder (visual editor) -- ⬜ Model auto-routing -- ⬜ Extension marketplace basics - -### Phase 4: Polish (Weeks 13-16) -- ⬜ Desktop app (Tauri) -- ⬜ Mobile-responsive -- ⬜ Documentation -- ⬜ Example plugins (10+) -- ⬜ Launch 🚀 - -## Contributing - -Since core features are plugins, contributors can: -1. Build new extensions (any language) -2. Improve existing plugins (PRs welcome) -3. Share workflows (template marketplace) -4. Report bugs, suggest features - -**Plugin Development:** -```bash -# Use template -cp -r extensions/_template-python extensions/my-plugin -cd extensions/my-plugin -# Edit extension.json, main.py -python main.py # Runs on port from manifest -``` - -## License - -MIT - Build whatever you want, including commercial - ---- - -**Questions?** See `/docs` for detailed guides or join discussions. diff --git a/docs/BACKEND_FEATURES.md b/docs/BACKEND_FEATURES.md deleted file mode 100644 index d075251..0000000 --- a/docs/BACKEND_FEATURES.md +++ /dev/null @@ -1,698 +0,0 @@ -# ⚙️ Backend Features & Extensions Architecture - -## Overview -This document defines the **server-side architecture** for Chat Switchboard, focusing on persistent storage, multi-model routing, function/tool calling, and backend extension capabilities. - ---- - -## 🏗️ Core Backend Features - -### 1. **Multi-Model Router** -Route requests to different LLM providers based on model, capabilities, cost, or custom logic. - -#### Architecture -``` -┌─────────────────┐ -│ Frontend │ -└────────┬────────┘ - │ OpenAI-compatible API - │ -┌────────▼────────────────────────────────────────┐ -│ Chat Switchboard Server │ -│ ┌──────────────────────────────────────────┐ │ -│ │ Model Router │ │ -│ │ - Route selection logic │ │ -│ │ - Fallback handling │ │ -│ │ - Load balancing │ │ -│ └──────────┬───────────────────────────────┘ │ -│ │ │ -│ ┌──────────▼───────────────────────────────┐ │ -│ │ Provider Adapters │ │ -│ ├──────────────────────────────────────────┤ │ -│ │ OpenAI │ Anthropic │ Local │ Custom │ │ -│ └──────┬─────┴─────┬─────┴───┬───┴────┬────┘ │ -└─────────┼───────────┼─────────┼────────┼───────┘ - │ │ │ │ - OpenAI API Claude API Ollama Custom -``` - -#### Implementation (`server/router/model_router.go`) -```go -type ModelRouter struct { - providers map[string]Provider - config *RouterConfig -} - -type Provider interface { - Name() string - Models() []string - ChatCompletion(ctx context.Context, req *ChatRequest) (*ChatResponse, error) - StreamCompletion(ctx context.Context, req *ChatRequest) (<-chan *ChatChunk, error) - SupportsTools() bool -} - -type RouterConfig struct { - DefaultProvider string - FallbackChain []string - ModelMapping map[string]string // model -> provider - LoadBalancing bool - CostOptimization bool -} - -func (r *ModelRouter) Route(model string, req *ChatRequest) (Provider, error) { - // 1. Check explicit mapping - if provider, ok := r.config.ModelMapping[model]; ok { - return r.providers[provider], nil - } - - // 2. Query each provider for model support - for _, provider := range r.providers { - if provider.SupportsModel(model) { - return provider, nil - } - } - - // 3. Fallback chain - if len(r.config.FallbackChain) > 0 { - return r.providers[r.config.FallbackChain[0]], nil - } - - return nil, ErrModelNotFound -} -``` - ---- - -### 2. **Tool/Function Calling System** -Enable LLMs to call external functions and tools. - -#### Tool Registry (`server/tools/registry.go`) -```go -type Tool struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` - Handler ToolHandler -} - -type ToolHandler func(ctx context.Context, args map[string]interface{}) (interface{}, error) - -type ToolRegistry struct { - tools map[string]*Tool - mu sync.RWMutex -} - -func (r *ToolRegistry) Register(tool *Tool) { - r.mu.Lock() - defer r.mu.Unlock() - r.tools[tool.Name] = tool -} - -func (r *ToolRegistry) Execute(name string, args map[string]interface{}) (interface{}, error) { - r.mu.RLock() - tool, ok := r.tools[name] - r.mu.RUnlock() - - if !ok { - return nil, ErrToolNotFound - } - - return tool.Handler(context.Background(), args) -} - -func (r *ToolRegistry) GetOpenAIFunctions() []map[string]interface{} { - // Convert tools to OpenAI function calling format - functions := []map[string]interface{}{} - for _, tool := range r.tools { - functions = append(functions, map[string]interface{}{ - "name": tool.Name, - "description": tool.Description, - "parameters": tool.Parameters, - }) - } - return functions -} -``` - -#### Built-in Tools -```go -// Web Search Tool -func init() { - toolRegistry.Register(&Tool{ - Name: "web_search", - Description: "Search the web for current information", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ - "type": "string", - "description": "Search query", - }, - }, - "required": []string{"query"}, - }, - Handler: func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - query := args["query"].(string) - // Implement web search (DuckDuckGo, Google, etc.) - results := performWebSearch(query) - return results, nil - }, - }) -} - -// Code Execution Tool -func init() { - toolRegistry.Register(&Tool{ - Name: "execute_code", - Description: "Execute code in a sandboxed environment", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "language": map[string]string{ - "type": "string", - "enum": []string{"python", "javascript", "bash"}, - }, - "code": map[string]string{ - "type": "string", - }, - }, - "required": []string{"language", "code"}, - }, - Handler: func(ctx context.Context, args map[string]interface{}) (interface{}, error) { - lang := args["language"].(string) - code := args["code"].(string) - - // Execute in Docker container or isolated runtime - result := executeSandboxed(lang, code) - return result, nil - }, - }) -} -``` - -#### Function Calling Flow -```go -func (h *ChatHandler) HandleFunctionCalling(req *ChatRequest) (*ChatResponse, error) { - // 1. Send initial request with tools - req.Tools = h.toolRegistry.GetOpenAIFunctions() - resp, err := h.provider.ChatCompletion(req) - - // 2. Check if model wants to call a function - if resp.FunctionCall != nil { - // 3. Execute the function - result, err := h.toolRegistry.Execute( - resp.FunctionCall.Name, - resp.FunctionCall.Arguments, - ) - - // 4. Send function result back to model - req.Messages = append(req.Messages, Message{ - Role: "function", - Name: resp.FunctionCall.Name, - Content: jsonStringify(result), - }) - - // 5. Get final response - return h.provider.ChatCompletion(req) - } - - return resp, nil -} -``` - ---- - -### 3. **Backend Extension System** -Plugin architecture for server-side extensions. - -#### Extension Interface (`server/extensions/extension.go`) -```go -type Extension interface { - ID() string - Name() string - Version() string - - // Lifecycle - Initialize(ctx context.Context, config map[string]interface{}) error - Shutdown() error - - // Hooks - OnChatRequest(ctx context.Context, req *ChatRequest) error - OnChatResponse(ctx context.Context, resp *ChatResponse) error - OnToolCall(ctx context.Context, tool string, args map[string]interface{}) error - - // Optional: Provide custom tools - RegisterTools() []*Tool - - // Optional: Provide custom routes - RegisterRoutes(router *gin.Engine) -} - -type BaseExtension struct { - id string - name string - version string -} - -func (e *BaseExtension) ID() string { return e.id } -func (e *BaseExtension) Name() string { return e.name } -func (e *BaseExtension) Version() string { return e.version } - -// Default no-op implementations -func (e *BaseExtension) Initialize(ctx context.Context, config map[string]interface{}) error { - return nil -} -func (e *BaseExtension) Shutdown() error { return nil } -func (e *BaseExtension) OnChatRequest(ctx context.Context, req *ChatRequest) error { - return nil -} -func (e *BaseExtension) OnChatResponse(ctx context.Context, resp *ChatResponse) error { - return nil -} -func (e *BaseExtension) OnToolCall(ctx context.Context, tool string, args map[string]interface{}) error { - return nil -} -func (e *BaseExtension) RegisterTools() []*Tool { return nil } -func (e *BaseExtension) RegisterRoutes(router *gin.Engine) {} -``` - -#### Extension Manager -```go -type ExtensionManager struct { - extensions map[string]Extension - mu sync.RWMutex -} - -func (m *ExtensionManager) Load(ext Extension, config map[string]interface{}) error { - if err := ext.Initialize(context.Background(), config); err != nil { - return err - } - - m.mu.Lock() - m.extensions[ext.ID()] = ext - m.mu.Unlock() - - // Register any tools provided by extension - for _, tool := range ext.RegisterTools() { - toolRegistry.Register(tool) - } - - return nil -} - -func (m *ExtensionManager) ExecuteHook(hookName string, args ...interface{}) error { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, ext := range m.extensions { - switch hookName { - case "OnChatRequest": - if err := ext.OnChatRequest(args[0].(context.Context), args[1].(*ChatRequest)); err != nil { - return err - } - case "OnChatResponse": - if err := ext.OnChatResponse(args[0].(context.Context), args[1].(*ChatResponse)); err != nil { - return err - } - } - } - return nil -} -``` - ---- - -### 4. **Example Backend Extensions** - -#### A. Logging Extension -```go -type LoggingExtension struct { - BaseExtension - db *sql.DB -} - -func NewLoggingExtension() *LoggingExtension { - return &LoggingExtension{ - BaseExtension: BaseExtension{ - id: "logging", - name: "Request Logging", - version: "1.0.0", - }, - } -} - -func (e *LoggingExtension) OnChatRequest(ctx context.Context, req *ChatRequest) error { - log.Printf("[CHAT] Model: %s, Messages: %d", req.Model, len(req.Messages)) - - // Store in database - _, err := e.db.Exec(` - INSERT INTO request_logs (user_id, model, message_count, timestamp) - VALUES ($1, $2, $3, NOW()) - `, getUserID(ctx), req.Model, len(req.Messages)) - - return err -} -``` - -#### B. Rate Limiting Extension -```go -type RateLimitExtension struct { - BaseExtension - limiter *rate.Limiter -} - -func (e *RateLimitExtension) OnChatRequest(ctx context.Context, req *ChatRequest) error { - if !e.limiter.Allow() { - return errors.New("rate limit exceeded") - } - return nil -} -``` - -#### C. Cost Tracking Extension -```go -type CostTrackingExtension struct { - BaseExtension - pricing map[string]float64 // model -> price per 1K tokens -} - -func (e *CostTrackingExtension) OnChatResponse(ctx context.Context, resp *ChatResponse) error { - model := resp.Model - tokens := resp.Usage.TotalTokens - - cost := (float64(tokens) / 1000.0) * e.pricing[model] - - log.Printf("[COST] Model: %s, Tokens: %d, Cost: $%.4f", model, tokens, cost) - - // Store in user account - return e.updateUserBalance(getUserID(ctx), cost) -} -``` - -#### D. Content Moderation Extension -```go -type ModerationExtension struct { - BaseExtension - moderationAPI string -} - -func (e *ModerationExtension) OnChatRequest(ctx context.Context, req *ChatRequest) error { - lastMsg := req.Messages[len(req.Messages)-1] - - flagged, err := e.checkModeration(lastMsg.Content) - if err != nil { - return err - } - - if flagged { - return errors.New("content violates policy") - } - - return nil -} -``` - ---- - -### 5. **Database Schema** - -#### Core Tables (`migrations/001_initial.sql`) -```sql --- Users -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- API Configurations (multiple API keys per user) -CREATE TABLE api_configs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - name VARCHAR(100) NOT NULL, - endpoint VARCHAR(255) NOT NULL, - api_key_encrypted TEXT NOT NULL, - provider VARCHAR(50) NOT NULL, -- 'openai', 'anthropic', 'local', etc. - is_default BOOLEAN DEFAULT false, - created_at TIMESTAMP DEFAULT NOW() -); - --- Chats -CREATE TABLE chats ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - title VARCHAR(500), - model VARCHAR(100), - api_config_id UUID REFERENCES api_configs(id), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- Messages -CREATE TABLE messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - chat_id UUID REFERENCES chats(id) ON DELETE CASCADE, - role VARCHAR(20) NOT NULL, -- 'system', 'user', 'assistant', 'function' - content TEXT NOT NULL, - name VARCHAR(100), -- for function messages - function_call JSONB, -- { "name": "...", "arguments": "..." } - metadata JSONB, -- extensible metadata - created_at TIMESTAMP DEFAULT NOW() -); - --- User Settings -CREATE TABLE user_settings ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - settings JSONB NOT NULL DEFAULT '{}', - updated_at TIMESTAMP DEFAULT NOW() -); - --- Extension Data (for extensions to store persistent data) -CREATE TABLE extension_data ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - extension_id VARCHAR(100) NOT NULL, - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - key VARCHAR(255) NOT NULL, - value JSONB NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - UNIQUE(extension_id, user_id, key) -); - --- Request Logs (for analytics) -CREATE TABLE request_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - model VARCHAR(100), - message_count INT, - tokens_used INT, - cost DECIMAL(10, 6), - duration_ms INT, - timestamp TIMESTAMP DEFAULT NOW() -); - --- Indexes -CREATE INDEX idx_chats_user_id ON chats(user_id); -CREATE INDEX idx_messages_chat_id ON messages(chat_id); -CREATE INDEX idx_request_logs_user_id ON request_logs(user_id); -CREATE INDEX idx_request_logs_timestamp ON request_logs(timestamp); -CREATE INDEX idx_extension_data_lookup ON extension_data(extension_id, user_id, key); -``` - ---- - -### 6. **Provider Adapters** - -#### OpenAI Provider (`server/providers/openai.go`) -```go -type OpenAIProvider struct { - apiKey string - endpoint string - client *http.Client -} - -func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { - // Standard OpenAI API implementation -} - -func (p *OpenAIProvider) StreamCompletion(ctx context.Context, req *ChatRequest) (<-chan *ChatChunk, error) { - // SSE streaming implementation -} - -func (p *OpenAIProvider) SupportsTools() bool { - return true -} -``` - -#### Anthropic Provider (`server/providers/anthropic.go`) -```go -type AnthropicProvider struct { - apiKey string - client *http.Client -} - -func (p *AnthropicProvider) ChatCompletion(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { - // Convert to Anthropic message format - anthropicReq := p.convertRequest(req) - - // Call Anthropic API - resp := p.callAPI(anthropicReq) - - // Convert back to standard format - return p.convertResponse(resp), nil -} -``` - -#### Local/Ollama Provider (`server/providers/ollama.go`) -```go -type OllamaProvider struct { - endpoint string // http://localhost:11434 -} - -func (p *OllamaProvider) ChatCompletion(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { - // Call local Ollama instance -} -``` - ---- - -### 7. **API Endpoints** - -#### Unified Chat Endpoint -```go -// POST /api/v1/chat/completions -// OpenAI-compatible endpoint -func (h *ChatHandler) CreateCompletion(c *gin.Context) { - var req ChatRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - // Execute extension hooks - if err := extensionManager.ExecuteHook("OnChatRequest", c.Request.Context(), &req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - // Route to appropriate provider - provider, err := modelRouter.Route(req.Model, &req) - if err != nil { - c.JSON(404, gin.H{"error": "model not found"}) - return - } - - // Handle streaming vs non-streaming - if req.Stream { - h.streamResponse(c, provider, &req) - } else { - resp, err := provider.ChatCompletion(c.Request.Context(), &req) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - // Execute response hooks - extensionManager.ExecuteHook("OnChatResponse", c.Request.Context(), resp) - - c.JSON(200, resp) - } -} -``` - -#### Tool Execution Endpoint -```go -// POST /api/v1/tools/execute -func (h *ToolHandler) Execute(c *gin.Context) { - var req struct { - Tool string `json:"tool"` - Args map[string]interface{} `json:"args"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - result, err := toolRegistry.Execute(req.Tool, req.Args) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.JSON(200, gin.H{"result": result}) -} -``` - ---- - -### 8. **Configuration** - -#### Server Config (`server/config.yaml`) -```yaml -server: - port: 8080 - cors: - allowed_origins: ["*"] - -database: - host: localhost - port: 5432 - name: chat_switchboard - user: postgres - password: ${DB_PASSWORD} - -providers: - openai: - endpoint: https://api.openai.com/v1 - api_key: ${OPENAI_API_KEY} - anthropic: - endpoint: https://api.anthropic.com/v1 - api_key: ${ANTHROPIC_API_KEY} - ollama: - endpoint: http://localhost:11434 - -router: - default_provider: openai - fallback_chain: - - openai - - anthropic - - ollama - model_mapping: - gpt-4: openai - claude-3-opus: anthropic - llama2: ollama - -extensions: - enabled: - - logging - - cost_tracking - - rate_limiting - logging: - level: info - cost_tracking: - pricing: - gpt-4: 0.03 - gpt-3.5-turbo: 0.002 - claude-3-opus: 0.015 - rate_limiting: - requests_per_minute: 60 -``` - ---- - -## 🚀 Next Steps - -1. **Implement provider adapters** (`server/providers/`) -2. **Build tool registry** (`server/tools/`) -3. **Create extension manager** (`server/extensions/`) -4. **Implement database layer** (`server/models/`) -5. **Add authentication/JWT** (`server/middleware/`) -6. **Build admin panel** for extension management -7. **Create Docker setup** for easy deployment - ---- - -**This backend architecture gives you a production-ready, infinitely extensible multi-model LLM router with function calling.** \ No newline at end of file diff --git a/docs/CICD_SETUP.md b/docs/CICD_SETUP.md deleted file mode 100644 index a0641e8..0000000 --- a/docs/CICD_SETUP.md +++ /dev/null @@ -1,235 +0,0 @@ -# CI/CD Infrastructure Setup - Chat Switchboard - -This document outlines the CI/CD infrastructure that has been set up for the Chat Switchboard project. - -## Overview - -The project uses Gitea Actions for CI/CD, configured with three primary workflows: - -1. **Backend CI** - Go backend testing, linting, and building -2. **Frontend CI** - JavaScript/CSS linting and standalone build -3. **Docker CI** - Container image building and publishing - -## Workflows - -### Backend CI (`.gitea/workflows/backend.yml`) - -**Triggers:** -- Push to `server/**` or `go.mod` on `main` or `develop` -- Pull requests targeting `main` or `develop` - -**Jobs:** -1. **test** - Runs Go tests with coverage -2. **lint** - Runs golangci-lint -3. **build** - Compiles Go binary (requires test & lint to pass) - -**Features:** -- Auto-initializes Go module if missing -- Uploads coverage reports to Codecov -- Builds with version tags from git -- Caches Go modules for faster builds - -### Frontend CI (`.gitea/workflows/frontend.yml`) - -**Triggers:** -- Push to `frontend/**`, `src/**`, or `build.sh` on `main` or `develop` -- Pull requests targeting `main` or `develop` - -**Jobs:** -1. **lint** - ESLint for JavaScript, Prettier for CSS -2. **build** - Generates standalone HTML via build.sh -3. **validate** - Validates HTML structure - -**Features:** -- Auto-generates eslint config if missing -- Validates build output structure -- Uploads standalone build as artifact - -### Docker CI (`.gitea/workflows/docker.yml`) - -**Triggers:** -- Push to `main` or `develop` with Dockerfile changes -- Pull requests targeting `main` or `develop` -- Tag creation (`v*` pattern) - -**Jobs:** -1. **build** - Builds and tests containers -2. **metadata** - Generates image tags -3. **push** - Pushes images to registry -4. **manifest** - Creates multi-arch manifests (main branch only) - -**Features:** -- Multi-stage Docker builds -- Cache from GitHub Actions cache -- Auto-tagging based on git refs -- Security headers in nginx config -- Health checks for containers - -## Docker Images - -### Backend Image - -**Location:** `server/Dockerfile` - -**Build Process:** -1. Multi-stage build (builder → runtime) -2. Auto-generates version from git tags -3. Non-root user for security -4. Health check endpoint - -**Usage:** -```bash -docker build -t chat-switchboard-backend ./server -docker run -p 8080:8080 chat-switchboard-backend -``` - -### Frontend Image - -**Location:** `Dockerfile.frontend` - -**Build Process:** -1. Builds standalone HTML in builder stage -2. Serves via nginx in production stage -3. Gzip compression enabled -4. Security headers added - -**Usage:** -```bash -docker build -t chat-switchboard-frontend -f Dockerfile.frontend . -docker run -p 80:80 chat-switchboard-frontend -``` - -## Branch Strategy - -**Branches:** -- `main` - Production branch -- `develop` - Development branch (to be created) -- Feature branches - Created from `develop` - -**Workflow:** -1. Create feature branch from `develop` -2. Make changes and push -3. Create PR to `develop` -4. CI runs automatically on PR -5. Merge to `develop` after review -6. Create PR from `develop` to `main` for releases - -## Getting Started - -### For Developers - -1. **Clone the repository** - ```bash - git clone https://git.gobha.me/xcaliber/chat-switchboard.git - cd chat-switchboard - ``` - -2. **Create feature branch** - ```bash - git checkout -b feature/my-feature develop - ``` - -3. **Make changes and test locally** - ```bash - # Backend - cd server && go test ./... - - # Frontend - ./build.sh - ``` - -4. **Push and create PR** - ```bash - git push origin feature/my-feature - # Create PR via web interface - ``` - -5. **CI will automatically run on your PR** - -### For Maintainers - -1. **Branch Protection (requires admin access)** - - Go to Repository Settings → Branches - - Add protection rule for `main` and `develop` - - Require pull request reviews - - Require status checks to pass - - Select required CI checks - -2. **Creating a Release** - ```bash - # Create tag - git tag -a v1.0.0 -m "Release v1.0.0" - git push origin v1.0.0 - ``` - - This will: - - Trigger Docker CI - - Build and push images with version tag - - Update `latest` tag on main branch - -## Environment Variables - -### Docker Registry - -Images are pushed to GitHub Container Registry: -- Backend: `ghcr.io/xcaliber/chat-switchboard-backend` -- Frontend: `ghcr.io/xcaliber/chat-switchboard-frontend` - -Authentication is handled via `GITHUB_TOKEN`. - -## Monitoring & Logs - -### Docker Health Checks - -Both containers include health checks: -- Backend: `/health` endpoint -- Frontend: HTTP check on port 80 - -### CI Status - -Check CI status in: -- Gitea repository → Actions tab -- Pull request checks section - -## Troubleshooting - -### Common Issues - -**1. Go module initialization fails** -```bash -cd server -go mod init git.gobha.me/xcaliber/chat-switchboard -go mod tidy -``` - -**2. Frontend build fails** -```bash -chmod +x build.sh -./build.sh -``` - -**3. Docker build fails** -```bash -# Check if Dockerfile exists -ls -la server/Dockerfile -ls -la Dockerfile.frontend - -# Build manually -docker build -t test ./server -``` - -### Viewing CI Logs - -1. Go to Repository → Actions -2. Select the workflow run -3. View job logs for details - -## Future Enhancements - -Planned improvements: -- [ ] Semantic versioning automation -- [ ] Automatic changelog generation -- [ ] Integration tests in CI -- [ ] Security scanning (Trivy, Snyk) -- [ ] Performance benchmarks -- [ ] Multi-arch builds (arm64/amd64) \ No newline at end of file diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md deleted file mode 100644 index 22070b1..0000000 --- a/docs/GETTING_STARTED.md +++ /dev/null @@ -1,469 +0,0 @@ -# 🚀 Getting Started with Chat Switchboard - -## What is Chat Switchboard? - -Chat Switchboard is a **plugin-first, multi-model AI platform** that works in two modes: - -- **🏠 Unmanaged Mode:** Runs entirely in your browser (like the current version) -- **🌐 Managed Mode:** Full backend with collaboration, workflows, and advanced features - -## Quick Start (Unmanaged Mode) - -### 1. Download & Run - -```bash -# Clone the repo -git clone https://git.gobha.me/xcaliber/chat-switchboard.git -cd chat-switchboard - -# Build standalone version -./build.sh - -# Open in browser -xdg-open standalone/index.html -# or serve it -python3 -m http.server 8080 --directory standalone -``` - -### 2. Configure Your API - -1. Click ⚙️ Settings -2. Enter your API details: - - **API Endpoint:** `https://api.openai.com/v1` (or OpenRouter, Venice.ai, etc.) - - **API Key:** Your API key - - **Model:** Select or type model name -3. Click "Save Settings" - -### 3. Start Chatting - -- Type a message and press Enter -- Switch models per-chat using the dropdown -- Export conversations as Markdown/JSON - -**That's it!** No backend needed, all data stays in your browser. - ---- - -## Full Installation (Managed Mode) - -For teams, collaboration, and advanced features like Workflows and Channels. - -### Prerequisites - -- Docker & Docker Compose -- PostgreSQL 15+ -- Go 1.21+ (for development) -- Python 3.9+ (for AI extensions) - -### 1. Clone & Configure - -```bash -git clone https://git.gobha.me/xcaliber/chat-switchboard.git -cd chat-switchboard - -# Copy environment template -cp .env.example .env - -# Edit configuration -nano .env -``` - -```bash -# .env -DATABASE_URL=postgres://user:pass@localhost:5432/chatswitch -JWT_SECRET=your-secret-key-here -PORT=8080 -FRONTEND_URL=http://localhost:3000 -REDIS_URL=redis://localhost:6379 -``` - -### 2. Start Services - -```bash -# Start everything with Docker Compose -docker-compose up -d - -# Services: -# - PostgreSQL (5432) -# - Backend API (8080) -# - Redis (6379) -# - Frontend (3000) -``` - -### 3. Run Migrations - -```bash -# Apply database schema -docker-compose exec backend ./migrate up -``` - -### 4. Create Admin User - -```bash -curl -X POST http://localhost:8080/api/auth/register \ - -H "Content-Type: application/json" \ - -d '{ - "username": "admin", - "email": "admin@example.com", - "password": "secure-password", - "role": "admin" - }' -``` - -### 5. Access Frontend - -Open http://localhost:3000 - -1. Click "Connect to Backend" -2. Enter backend URL: `http://localhost:8080` -3. Login with your credentials - ---- - -## Architecture Overview - -``` -┌─────────────────────────────────────┐ -│ Frontend (Vanilla JS) │ -│ - Manages UI state │ -│ - Switches modes (managed/local) │ -└─────────────┬───────────────────────┘ - │ - ┌─────────┴─────────┐ - │ │ -[LocalStorage] [Backend API] - │ │ - │ ┌─────────▼──────────┐ - │ │ Go Core │ - │ │ - Auth │ - │ │ - WebSocket │ - │ │ - Extension Mgr │ - │ └─────────┬──────────┘ - │ │ - │ ┌─────────┴──────────┐ - │ │ PostgreSQL │ - │ │ - Users, chats │ - │ │ - Channels, notes │ - │ │ - Vector search │ - │ └────────────────────┘ - │ - └─── Extensions (Python/Go) ───┐ - ├─ Chat Engine │ - ├─ RAG Engine │ - ├─ Workflows │ - └─ Custom Tools │ -``` - -## Core Features - -### 1. Chat (User → AI) - -**Available in:** Both modes - -- Multi-model support (OpenAI, Anthropic, Ollama, etc.) -- Per-conversation model switching -- Streaming responses -- Message history -- Export (Markdown, JSON, Plain Text) - -**Managed mode additions:** -- Auto-routing (smart model selection) -- Cost tracking -- Shared conversations -- Server-side tool calling - -### 2. Channels (User → User + AI) - -**Available in:** Managed mode only - -Multi-user chat rooms with AI participants: - -``` -#general - @alice: What do you think about this design? - @claude: I'd suggest using a darker color scheme... - @bob: Agreed! Also, @gpt4 can you review the code? - @gpt4: I see a potential issue in line 42... -``` - -- Public/private/DM channels -- @mention users and AI models -- Threaded conversations -- Real-time updates (WebSocket) -- Reactions and formatting - -### 3. Notes & Knowledge Bases - -**Available in:** Both modes (limited in unmanaged) - -- Markdown notes with folders -- Tagging and search -- Linked notes (wiki-style) - -**Managed mode additions:** -- Shared notes -- Knowledge base collections -- RAG (vector search) -- Semantic search with embeddings - -### 4. Workflows 🌟 (UNIQUE FEATURE) - -**Available in:** Managed mode only - -Chain multiple AI models and tools into automated pipelines: - -``` -[User Query] → [Web Search] → [GPT-4 Summarize] - ↓ ↓ - [Save to KB] [Claude Verify] - ↓ - [Generate Report] -``` - -**Use cases:** -- Research assistants -- Code review pipelines -- Content creation factories -- Data processing -- Multi-model consensus - -See `docs/WORKFLOWS.md` for details. - -## Extension System - -### Frontend Extensions (Both Modes) - -Simple JavaScript plugins that run in the browser: - -```javascript -// extensions/frontend/my-plugin/main.js -window.ChatSwitchboard.registerExtension({ - name: 'my-plugin', - hooks: { - onMessageSend: (msg) => { - console.log('Sending:', msg); - return msg; // Can modify - } - } -}); -``` - -### Backend Extensions (Managed Only) - -Full-featured plugins in any language: - -```python -# extensions/backend/my-tool/main.py -from fastapi import FastAPI - -app = FastAPI() - -@app.post("/tools/my_tool") -async def my_tool(input: str): - result = process(input) - return {"result": result} -``` - -See `docs/PLUGIN_SPEC.md` for complete guide. - -## Development - -### Frontend Development - -```bash -# Serve source files -python3 -m http.server 8080 --directory src - -# Build standalone -./build.sh -``` - -### Backend Development - -```bash -# Install Go dependencies -go mod download - -# Run backend -go run server/main.go - -# Or with air (hot reload) -air -``` - -### Extension Development - -```bash -# Create from template -cp -r extensions/_template-python extensions/my-extension - -# Edit files -cd extensions/my-extension -nano extension.json -nano main.py - -# Test locally -python main.py -``` - -## Configuration - -### Frontend Settings - -Stored in localStorage (unmanaged) or user profile (managed): - -- API endpoints and keys -- Default model -- Stream responses -- System prompt -- Temperature, max tokens, etc. - -### Backend Configuration - -```yaml -# config.yaml -server: - port: 8080 - host: 0.0.0.0 - -database: - url: ${DATABASE_URL} - max_connections: 20 - -redis: - url: ${REDIS_URL} - enabled: true - -extensions: - directory: ./extensions/backend - auto_load: true - -security: - jwt_secret: ${JWT_SECRET} - jwt_expiry: 24h - rate_limit: 100/minute -``` - -## Switching Modes - -### From Unmanaged → Managed - -1. Deploy backend (see Full Installation above) -2. In Settings, enter Backend URL -3. Login/register -4. Your local data will be **uploaded** on first sync - -### From Managed → Unmanaged - -1. Export your data (Settings → Export) -2. Remove backend URL from settings -3. Continue using locally - -**Note:** Managed-only features (Channels, Workflows) won't work in unmanaged mode. - -## Common Workflows - -### Personal Use (Unmanaged) -``` -Open standalone/index.html → Configure API → Chat -``` - -### Team Collaboration (Managed) -``` -Deploy backend → Create team channels → @mention AI models -``` - -### Development (Contributing) -``` -Fork repo → Create extension → Test locally → Submit PR -``` - -### Self-Hosted (Privacy) -``` -Deploy on your server → Configure domains → Invite team -``` - -## Troubleshooting - -### Frontend Issues - -**"No settings found"** -- Click Settings ⚙️ and configure API endpoint + key - -**"API request failed"** -- Check API endpoint URL (trailing slash?) -- Verify API key is correct -- Check browser console for errors - -**"LocalStorage full"** -- Export old chats -- Delete unused chats -- Clear browser data - -### Backend Issues - -**"Connection refused"** -- Is backend running? `docker-compose ps` -- Check firewall rules -- Verify port 8080 is open - -**"Database migration failed"** -- Check PostgreSQL is running -- Verify DATABASE_URL is correct -- Run migrations manually: `./migrate up` - -**"Extension not loading"** -- Check extension.json is valid -- Verify runtime is installed (python3, go, node) -- Check logs: `docker-compose logs backend` - -### WebSocket Issues - -**"Real-time updates not working"** -- Check WebSocket connection in browser console -- Verify Redis is running (for multi-instance setups) -- Check nginx WebSocket proxy config - -## Next Steps - -1. **Explore Features:** Try Chat, Notes, and (if managed) Channels -2. **Read Docs:** - - `docs/ARCHITECTURE.md` - System design - - `docs/WORKFLOWS.md` - Workflow system - - `docs/PLUGIN_SPEC.md` - Extension development -3. **Build Extensions:** Use templates in `extensions/` -4. **Join Community:** Discord, GitHub Discussions -5. **Contribute:** Submit PRs, report bugs, suggest features - -## Resources - -- **GitHub:** https://git.gobha.me/xcaliber/chat-switchboard -- **Documentation:** `/docs` directory -- **Examples:** `/examples` directory -- **Extensions:** `/extensions` directory - ---- - -## FAQ - -**Q: Is my data private?** -A: In unmanaged mode, everything stays in your browser. In managed mode, you control the backend. - -**Q: Can I use my own models?** -A: Yes! Configure any OpenAI-compatible API (Ollama, LM Studio, etc.) - -**Q: How do I migrate from ChatGPT/Claude?** -A: Export your conversations, then import via our import tool (coming soon). - -**Q: Can I run this offline?** -A: Unmanaged mode works offline if you pre-load the page. For LLM calls, you need internet or local models (Ollama). - -**Q: How much does it cost?** -A: Chat Switchboard is free and open-source. You pay for API usage (OpenAI, etc.) or use free models (Ollama). - -**Q: Can I sell extensions?** -A: Yes! The marketplace (coming soon) will support paid extensions. - ---- - -**Ready to switch? Start building! 🚀** diff --git a/docs/HTML_EXTENSIONS.md b/docs/HTML_EXTENSIONS.md deleted file mode 100644 index 9bf5be0..0000000 --- a/docs/HTML_EXTENSIONS.md +++ /dev/null @@ -1,445 +0,0 @@ -# 🎨 HTML/Frontend Extensions Architecture - -## Overview -This document defines the **client-side extension system** for Chat Switchboard. Extensions are HTML/JS/CSS modules that can hook into the application lifecycle, add UI elements, intercept messages, and extend functionality. - ---- - -## 🔌 Extension Interface - -### Extension Manifest Structure -```javascript -{ - "id": "example-extension", - "name": "Example Extension", - "version": "1.0.0", - "author": "Your Name", - "description": "Does something cool", - "type": "frontend", // or "hybrid" if it has backend components - "permissions": ["ui", "messages", "storage"], - "entrypoint": "extension.js", - "styles": "extension.css", - "hooks": { - "onLoad": true, - "onMessageSend": true, - "onMessageReceive": true, - "onModelSwitch": true - }, - "ui": { - "toolbar": true, - "sidebar": false, - "settings": true - } -} -``` - ---- - -## 📦 Extension API - -### Core Extension Class -```javascript -class ChatSwitchboardExtension { - constructor(manifest) { - this.manifest = manifest; - this.enabled = true; - } - - // Lifecycle hooks - onLoad() {} - onUnload() {} - onEnable() {} - onDisable() {} - - // Message hooks - async onMessageSend(message, context) { - // Modify message before sending - // return modified message or null to cancel - return message; - } - - async onMessageReceive(message, context) { - // Process received message - // return modified message or original - return message; - } - - async onMessageDisplay(message, element) { - // Modify message DOM before display - return element; - } - - // UI hooks - onModelSwitch(oldModel, newModel) {} - onChatSwitch(oldChatId, newChatId) {} - onSettingsOpen() {} - - // Render hooks - renderToolbarButton() { - // Return HTML string or DOM element - return null; - } - - renderSidebarPanel() { - return null; - } - - renderSettingsPanel() { - return null; - } - - renderMessageAction(message) { - // Add custom actions to message bubbles - return null; - } -} -``` - ---- - -## 🎯 Extension Categories - -### 1. **UI Extensions** -Add visual elements and interface enhancements. - -**Examples:** -- Syntax highlighter themes -- Message templates/snippets -- Custom emoji pickers -- Voice input buttons -- Image/file attachments - -**Hooks:** `renderToolbarButton`, `renderSidebarPanel`, `renderMessageAction` - ---- - -### 2. **Message Processors** -Transform messages before/after sending. - -**Examples:** -- Markdown preprocessor -- Code formatter -- Translation layer -- Prompt templates -- Token counter - -**Hooks:** `onMessageSend`, `onMessageReceive`, `onMessageDisplay` - ---- - -### 3. **Model Extensions** -Enhance model selection and routing. - -**Examples:** -- Model performance stats -- Cost calculator -- Context window manager -- Model recommendation engine -- Fallback routing (if model fails, try another) - -**Hooks:** `onModelSwitch`, access to `State.settings.model` - ---- - -### 4. **Storage Extensions** -Extend data persistence capabilities. - -**Examples:** -- Cloud sync (S3, Dropbox) -- Export formats (PDF, DOCX) -- Search indexing -- Tagging system -- Chat organization - -**Permissions:** `storage` - ---- - -### 5. **Tool/Function Extensions** -Enable LLM function calling (critical for your use case). - -**Examples:** -- Web search tool -- Calculator -- Code execution sandbox -- API caller -- File system access - -**Hooks:** `onFunctionCall`, `registerFunction` - ---- - -## 🛠️ Extension Manager - -### Core API -```javascript -// Global extension registry -window.ExtensionManager = { - extensions: new Map(), - - // Register an extension - register(manifest, ExtensionClass) { - const ext = new ExtensionClass(manifest); - this.extensions.set(manifest.id, ext); - ext.onLoad(); - return ext; - }, - - // Enable/disable - enable(id) {}, - disable(id) {}, - unload(id) {}, - - // Hook execution - async executeHook(hookName, ...args) { - const results = []; - for (const [id, ext] of this.extensions) { - if (ext.enabled && ext[hookName]) { - const result = await ext[hookName](...args); - results.push({ id, result }); - } - } - return results; - }, - - // Get all extensions with specific capability - getByPermission(permission) { - return Array.from(this.extensions.values()) - .filter(ext => ext.manifest.permissions.includes(permission)); - } -}; -``` - ---- - -## 📂 Extension File Structure - -``` -extensions/ -├── example-extension/ -│ ├── manifest.json -│ ├── extension.js -│ ├── extension.css (optional) -│ ├── README.md -│ └── assets/ -│ └── icon.svg -``` - -### Loading Extensions -```javascript -async function loadExtension(path) { - const manifest = await fetch(`${path}/manifest.json`).then(r => r.json()); - - // Load JS - const module = await import(`${path}/${manifest.entrypoint}`); - - // Load CSS if present - if (manifest.styles) { - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = `${path}/${manifest.styles}`; - document.head.appendChild(link); - } - - // Register - ExtensionManager.register(manifest, module.default); -} -``` - ---- - -## 🔐 Security & Permissions - -### Permission System -```javascript -const PERMISSIONS = { - ui: "Modify user interface", - messages: "Read and modify messages", - storage: "Access local storage", - network: "Make network requests", - settings: "Access user settings", - clipboard: "Access clipboard", - notifications: "Show notifications" -}; -``` - -### Sandboxing (Future) -- Use iframes for untrusted extensions -- Content Security Policy restrictions -- API key scoping (extensions can't access main API key) - ---- - -## 🎨 Example Extensions - -### 1. Token Counter -```javascript -class TokenCounterExtension extends ChatSwitchboardExtension { - onLoad() { - this.tokenCount = 0; - } - - renderToolbarButton() { - return ` - - `; - } - - async onMessageSend(message, context) { - // Rough estimation: 1 token ≈ 4 chars - this.tokenCount = Math.ceil(message.content.length / 4); - document.getElementById('tokenCount').textContent = this.tokenCount; - return message; - } -} -``` - -### 2. Code Formatter -```javascript -class CodeFormatterExtension extends ChatSwitchboardExtension { - async onMessageDisplay(message, element) { - if (message.role === 'assistant') { - // Find code blocks and apply Prism.js highlighting - element.querySelectorAll('pre code').forEach(block => { - Prism.highlightElement(block); - }); - } - return element; - } - - renderMessageAction(message) { - if (message.content.includes('```')) { - return ` - - `; - } - return null; - } -} -``` - -### 3. Model Router (Smart Fallback) -```javascript -class ModelRouterExtension extends ChatSwitchboardExtension { - async onMessageSend(message, context) { - // If message is long, use high-context model - if (message.content.length > 5000) { - const originalModel = State.settings.model; - State.settings.model = 'claude-3-opus-20240229'; // High context - - // Restore after response - context.onComplete = () => { - State.settings.model = originalModel; - }; - } - return message; - } -} -``` - ---- - -## 🚀 Integration with App - -### Modify `app.js` to support hooks: -```javascript -async function sendMessage() { - // ... existing code ... - - // Execute hook before sending - const hookResults = await ExtensionManager.executeHook( - 'onMessageSend', - message, - { chatId: State.currentChatId } - ); - - // Check if any extension cancelled the send - if (hookResults.some(r => r.result === null)) { - return; - } - - // Apply transformations - for (const { result } of hookResults) { - if (result && result !== message) { - message = result; - } - } - - // ... continue with API call ... -} -``` - ---- - -## 📋 Extension Discovery - -### Extension Store (Future) -```javascript -{ - "extensions": [ - { - "id": "web-search", - "name": "Web Search Tool", - "description": "Adds web search capability via function calling", - "author": "Chat Switchboard Team", - "version": "1.0.0", - "downloadUrl": "https://extensions.switchboard/web-search.zip", - "verified": true, - "rating": 4.8, - "downloads": 1250 - } - ] -} -``` - ---- - -## 🎯 Next Steps - -1. **Implement ExtensionManager** in `src/js/extensions.js` -2. **Add hook points** to existing code (app.js, ui.js, api.js) -3. **Create example extensions** to validate API -4. **Build extension settings UI** for enable/disable/configure -5. **Document extension development** with starter template - ---- - -## 🤝 Extension Development Workflow - -```bash -# Create new extension -mkdir extensions/my-extension -cd extensions/my-extension - -# Generate manifest -cat > manifest.json << EOF -{ - "id": "my-extension", - "name": "My Extension", - "version": "1.0.0", - "entrypoint": "extension.js", - "permissions": ["messages"] -} -EOF - -# Create extension class -cat > extension.js << EOF -class MyExtension extends ChatSwitchboardExtension { - onLoad() { - console.log('My extension loaded!'); - } -} -export default MyExtension; -EOF - -# Test in dev mode -# Extensions auto-load from /extensions/ directory -``` - ---- - -**This architecture makes Chat Switchboard infinitely extensible while keeping the core lean.** \ No newline at end of file diff --git a/docs/PLUGIN_SPEC.md b/docs/PLUGIN_SPEC.md deleted file mode 100644 index e10304e..0000000 --- a/docs/PLUGIN_SPEC.md +++ /dev/null @@ -1,759 +0,0 @@ -# 🔌 Plugin Specification - Extension Development Guide - -## Philosophy: Everything is a Plugin - -**Core Principle:** If core features (Chat, Channels, Notes) are implemented as plugins, we prove that ANY feature can be a plugin. - -``` -Minimal Core (Go) Extensions (Any Language) -├── HTTP Router ├── Chat Engine (Python) -├── WebSocket Hub ├── Channels (Go) -├── Extension Manager ├── RAG Engine (Python) -├── Auth/Users ├── Workflows (Go + Python) -└── PostgreSQL └── Your Custom Plugin... -``` - -## Plugin Types - -### 1. Frontend Plugins (UI Only) -**Language:** JavaScript -**Runs:** In browser -**Capabilities:** UI modifications, client-side logic -**Modes:** Both managed and unmanaged - -### 2. Backend Plugins (Full Power) -**Language:** Python, Go, Node.js, Rust, etc. -**Runs:** As separate process -**Capabilities:** Tools, models, data processing, external APIs -**Modes:** Managed only - -## Frontend Plugin API - -### Plugin Structure - -``` -extensions/frontend/token-counter/ -├── manifest.json # Plugin metadata -├── main.js # Entry point -├── styles.css # Optional styles -└── README.md -``` - -### manifest.json - -```json -{ - "name": "token-counter", - "version": "1.0.0", - "author": "Your Name", - "description": "Display token count for messages", - "type": "frontend", - "entry": "main.js", - "permissions": [ - "message:read", - "ui:inject" - ], - "hooks": [ - "onMessageSend", - "onMessageReceive", - "onUIRender" - ] -} -``` - -### main.js Example - -```javascript -// Frontend plugin template -(function() { - 'use strict'; - - // Plugin initialization - window.ChatSwitchboard = window.ChatSwitchboard || {}; - window.ChatSwitchboard.plugins = window.ChatSwitchboard.plugins || []; - - const TokenCounter = { - name: 'token-counter', - version: '1.0.0', - - // Called when plugin loads - init: function() { - console.log('Token Counter plugin loaded'); - this.addUI(); - }, - - // Hook: Before message is sent - hooks: { - onMessageSend: function(message) { - const tokens = this.estimateTokens(message.content); - console.log(`Message has ~${tokens} tokens`); - - // Show warning if too long - if (tokens > 4000) { - window.ChatSwitchboard.showToast( - `⚠️ Long message: ${tokens} tokens`, - 'warning' - ); - } - - // Can modify message before sending - return message; - }, - - onMessageReceive: function(message) { - // Process incoming messages - return message; - }, - - onUIRender: function() { - // Inject UI elements - this.updateTokenDisplay(); - } - }, - - // Add token counter to UI - addUI: function() { - const inputArea = document.querySelector('.input-area'); - const counter = document.createElement('div'); - counter.id = 'token-counter'; - counter.className = 'plugin-token-counter'; - counter.textContent = '0 tokens'; - inputArea.appendChild(counter); - - // Update on input - const textarea = document.getElementById('messageInput'); - textarea.addEventListener('input', () => { - const tokens = this.estimateTokens(textarea.value); - counter.textContent = `${tokens} tokens`; - }); - }, - - // Token estimation (rough approximation) - estimateTokens: function(text) { - return Math.ceil(text.length / 4); - }, - - updateTokenDisplay: function() { - const counter = document.getElementById('token-counter'); - const input = document.getElementById('messageInput'); - if (counter && input) { - const tokens = this.estimateTokens(input.value); - counter.textContent = `${tokens} tokens`; - } - } - }; - - // Register plugin - window.ChatSwitchboard.plugins.push(TokenCounter); - - // Auto-init when DOM ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => TokenCounter.init()); - } else { - TokenCounter.init(); - } -})(); -``` - -### Available Hooks - -```javascript -// Message lifecycle -onMessageSend(message) // Before sending to API -onMessageReceive(message) // After receiving from API -onMessageRender(element, message) // When rendering to DOM - -// UI lifecycle -onUIRender() // After UI updates -onChatSwitch(chatId) // When switching chats -onModelChange(model) // When model changes - -// Extension points -onToolCall(tool, params) // Before calling tool -onToolResult(tool, result) // After tool returns - -// Settings -onSettingsOpen() // Settings modal opened -onSettingsSave(settings) // Settings saved - -// Files -onFileUpload(file) // File uploaded -onFileSelect(file) // File selected for chat -``` - -### Plugin API Methods - -```javascript -// Toast notifications -ChatSwitchboard.showToast(message, type); // type: success, error, warning, info - -// Storage (scoped to plugin) -ChatSwitchboard.storage.set(key, value); -ChatSwitchboard.storage.get(key); -ChatSwitchboard.storage.remove(key); - -// UI manipulation -ChatSwitchboard.ui.addButton(location, config); -ChatSwitchboard.ui.addPanel(config); -ChatSwitchboard.ui.addMenuItem(config); - -// State access (read-only) -ChatSwitchboard.state.getCurrentChat(); -ChatSwitchboard.state.getCurrentModel(); -ChatSwitchboard.state.getSettings(); - -// Events -ChatSwitchboard.events.on(event, callback); -ChatSwitchboard.events.off(event, callback); -ChatSwitchboard.events.emit(event, data); -``` - -## Backend Plugin API - -### Plugin Structure - -``` -extensions/backend/web-search/ -├── extension.json # Manifest -├── main.py # Entry point (or main.go, server.js, etc) -├── requirements.txt # Dependencies (Python) -├── config.yaml # Configuration -└── README.md -``` - -### extension.json - -```json -{ - "name": "web-search", - "version": "1.0.0", - "author": "Chat Switchboard Team", - "description": "Search the web using DuckDuckGo API", - "type": "backend", - "runtime": "python", - "entry": "main.py", - "port": 9001, - "capabilities": ["tool"], - "dependencies": { - "python": ">=3.9", - "packages": ["fastapi", "uvicorn", "duckduckgo-search"] - }, - "tools": [ - { - "name": "search_web", - "description": "Search the web for information", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query" - }, - "max_results": { - "type": "integer", - "description": "Maximum results to return", - "default": 5 - } - }, - "required": ["query"] - }, - "returns": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "type": "object", - "properties": { - "title": {"type": "string"}, - "url": {"type": "string"}, - "snippet": {"type": "string"} - } - } - } - } - } - } - ], - "config": { - "max_requests_per_minute": 30, - "timeout_seconds": 10 - } -} -``` - -### Python Plugin Template - -```python -# extensions/backend/web-search/main.py -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from duckduckgo_search import DDGS -import uvicorn -import os - -app = FastAPI(title="Web Search Plugin") - -# Load config -PORT = int(os.getenv("PLUGIN_PORT", 9001)) -MAX_RESULTS = int(os.getenv("MAX_RESULTS", 5)) - -# Request/response models -class SearchRequest(BaseModel): - query: str - max_results: int = 5 - -class SearchResult(BaseModel): - title: str - url: str - snippet: str - -class SearchResponse(BaseModel): - results: list[SearchResult] - -# Tool endpoint -@app.post("/tools/search_web", response_model=SearchResponse) -async def search_web(request: SearchRequest): - """Search the web using DuckDuckGo""" - try: - ddgs = DDGS() - results = ddgs.text( - request.query, - max_results=min(request.max_results, MAX_RESULTS) - ) - - return SearchResponse( - results=[ - SearchResult( - title=r.get("title", ""), - url=r.get("href", ""), - snippet=r.get("body", "") - ) - for r in results - ] - ) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -# Health check -@app.get("/health") -async def health(): - return {"status": "ok", "plugin": "web-search"} - -# Plugin info -@app.get("/info") -async def info(): - return { - "name": "web-search", - "version": "1.0.0", - "tools": ["search_web"] - } - -if __name__ == "__main__": - uvicorn.run(app, host="127.0.0.1", port=PORT) -``` - -### Go Plugin Template - -```go -// extensions/backend/file-ops/main.go -package main - -import ( - "github.com/gin-gonic/gin" - "os" - "io/ioutil" -) - -type ReadFileRequest struct { - Path string `json:"path" binding:"required"` -} - -type ReadFileResponse struct { - Content string `json:"content"` - Size int64 `json:"size"` -} - -func main() { - r := gin.Default() - - // Tool endpoint - r.POST("/tools/read_file", func(c *gin.Context) { - var req ReadFileRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - content, err := ioutil.ReadFile(req.Path) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - info, _ := os.Stat(req.Path) - - c.JSON(200, ReadFileResponse{ - Content: string(content), - Size: info.Size(), - }) - }) - - // Health check - r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) - }) - - // Start server - port := os.Getenv("PLUGIN_PORT") - if port == "" { - port = "9002" - } - r.Run(":" + port) -} -``` - -## Extension Manager (Backend Core) - -### Go Extension Manager - -```go -// server/extensions/manager.go -package extensions - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "os" - "os/exec" - "path/filepath" - "sync" -) - -type Extension struct { - Name string `json:"name"` - Version string `json:"version"` - Runtime string `json:"runtime"` - Entry string `json:"entry"` - Port int `json:"port"` - Tools []Tool `json:"tools"` - Process *os.Process - BaseURL string -} - -type Tool struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -} - -type Manager struct { - extensions map[string]*Extension - tools map[string]*Tool - mu sync.RWMutex -} - -func NewManager() *Manager { - return &Manager{ - extensions: make(map[string]*Extension), - tools: make(map[string]*Tool), - } -} - -// Load all extensions from directory -func (m *Manager) LoadExtensions(dir string) error { - entries, err := ioutil.ReadDir(dir) - if err != nil { - return err - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - extPath := filepath.Join(dir, entry.Name()) - if err := m.LoadExtension(extPath); err != nil { - log.Printf("Failed to load extension %s: %v", entry.Name(), err) - } - } - - return nil -} - -// Load single extension -func (m *Manager) LoadExtension(path string) error { - // Read manifest - manifestPath := filepath.Join(path, "extension.json") - data, err := ioutil.ReadFile(manifestPath) - if err != nil { - return err - } - - var ext Extension - if err := json.Unmarshal(data, &ext); err != nil { - return err - } - - // Start extension process - if err := m.startExtension(&ext, path); err != nil { - return err - } - - // Register tools - for i := range ext.Tools { - ext.Tools[i].ExtensionName = ext.Name - m.tools[ext.Tools[i].Name] = &ext.Tools[i] - } - - m.mu.Lock() - m.extensions[ext.Name] = &ext - m.mu.Unlock() - - log.Printf("✅ Loaded extension: %s v%s", ext.Name, ext.Version) - return nil -} - -// Start extension process -func (m *Manager) startExtension(ext *Extension, path string) error { - var cmd *exec.Cmd - - switch ext.Runtime { - case "python": - cmd = exec.Command("python3", ext.Entry) - case "go": - cmd = exec.Command("go", "run", ext.Entry) - case "node": - cmd = exec.Command("node", ext.Entry) - default: - return fmt.Errorf("unsupported runtime: %s", ext.Runtime) - } - - cmd.Dir = path - cmd.Env = append(os.Environ(), fmt.Sprintf("PLUGIN_PORT=%d", ext.Port)) - - if err := cmd.Start(); err != nil { - return err - } - - ext.Process = cmd.Process - ext.BaseURL = fmt.Sprintf("http://localhost:%d", ext.Port) - - // Wait for extension to be ready - time.Sleep(2 * time.Second) - - return nil -} - -// Call tool -func (m *Manager) CallTool(name string, params map[string]interface{}) (interface{}, error) { - m.mu.RLock() - tool, exists := m.tools[name] - m.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("tool not found: %s", name) - } - - ext := m.extensions[tool.ExtensionName] - url := fmt.Sprintf("%s/tools/%s", ext.BaseURL, name) - - // HTTP POST to extension - body, _ := json.Marshal(params) - resp, err := http.Post(url, "application/json", bytes.NewBuffer(body)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result interface{} - json.NewDecoder(resp.Body).Decode(&result) - return result, nil -} - -// List all tools -func (m *Manager) ListTools() []Tool { - m.mu.RLock() - defer m.mu.RUnlock() - - tools := make([]Tool, 0, len(m.tools)) - for _, tool := range m.tools { - tools = append(tools, *tool) - } - return tools -} - -// Shutdown all extensions -func (m *Manager) Shutdown() { - m.mu.Lock() - defer m.mu.Unlock() - - for _, ext := range m.extensions { - if ext.Process != nil { - ext.Process.Kill() - } - } -} -``` - -### Extension Discovery - -```go -// server/main.go -func main() { - // ... existing setup ... - - // Load extensions - extManager := extensions.NewManager() - if err := extManager.LoadExtensions("./extensions/backend"); err != nil { - log.Fatal("Failed to load extensions:", err) - } - defer extManager.Shutdown() - - // Expose extension tools via API - r.GET("/api/tools", func(c *gin.Context) { - tools := extManager.ListTools() - c.JSON(200, tools) - }) - - r.POST("/api/tools/:name", func(c *gin.Context) { - toolName := c.Param("name") - var params map[string]interface{} - c.BindJSON(¶ms) - - result, err := extManager.CallTool(toolName, params) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, result) - }) -} -``` - -## Plugin Development Workflow - -### 1. Create from Template - -```bash -# Frontend plugin -cp -r extensions/frontend/_template extensions/frontend/my-plugin -cd extensions/frontend/my-plugin -# Edit manifest.json, main.js - -# Backend plugin (Python) -cp -r extensions/backend/_template-python extensions/backend/my-plugin -cd extensions/backend/my-plugin -# Edit extension.json, main.py -pip install -r requirements.txt -``` - -### 2. Test Locally - -```bash -# Backend plugin -python main.py # Runs on port from extension.json - -# Test tool -curl -X POST http://localhost:9001/tools/my_tool \ - -H "Content-Type: application/json" \ - -d '{"param": "value"}' -``` - -### 3. Install in Chat Switchboard - -```bash -# Move to extensions directory -mv my-plugin ../chat-switchboard/extensions/backend/ - -# Restart backend -cd ../chat-switchboard -docker-compose restart backend -``` - -### 4. Publish to Marketplace - -```bash -# Package extension -tar -czf my-plugin-1.0.0.tar.gz my-plugin/ - -# Upload to marketplace -curl -X POST https://marketplace.chatswitch.io/api/publish \ - -F "file=@my-plugin-1.0.0.tar.gz" \ - -F "category=tools" \ - -H "Authorization: Bearer $TOKEN" -``` - -## Security & Sandboxing - -### Backend Plugin Isolation - -```yaml -# docker-compose.yml - Run plugins in containers -services: - plugin-web-search: - build: ./extensions/backend/web-search - ports: - - "9001:9001" - environment: - - PLUGIN_PORT=9001 - networks: - - extension-network - restart: always -``` - -### Permission System - -```json -// Plugins declare required permissions -{ - "permissions": [ - "network:outbound", // Make external HTTP requests - "storage:read", // Read from DB - "storage:write", // Write to DB - "file:read", // Read files - "tool:call:*" // Call other tools - ] -} -``` - -## Official Plugin List - -### Bundled with Core - -1. **chat-engine** (Python) - LLM conversation handling -2. **channels** (Go) - Multi-user chat rooms -3. **rag-engine** (Python) - Vector search, embeddings -4. **workflows** (Go/Python) - Workflow orchestration -5. **notes** (Go) - Note management - -### Official Extensions - -6. **web-search** (Python) - DuckDuckGo search -7. **code-runner** (Python) - Sandboxed code execution -8. **image-gen** (Python) - DALL-E/Stable Diffusion -9. **file-ops** (Go) - File system operations -10. **calculator** (Python) - Math evaluation - -### Community Extensions (Example Ideas) - -- **slack-integration** - Post to Slack channels -- **github-integration** - Create issues, PRs -- **email-sender** - Send emails via SMTP -- **pdf-parser** - Extract text from PDFs -- **scraper** - Web scraping tool -- **translate** - Multi-language translation -- **tts** - Text-to-speech -- **stt** - Speech-to-text - ---- - -## Next Steps - -1. Read `docs/ARCHITECTURE.md` for system overview -2. Check `extensions/_template-python/` for starter code -3. Browse `extensions/backend/` for examples -4. Join developer Discord for help - -**Build something awesome! 🚀** diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md deleted file mode 100644 index 3143548..0000000 --- a/docs/QUICK_START.md +++ /dev/null @@ -1,360 +0,0 @@ -# 🚀 Quick Start for Developers - -Get up and running with Chat Switchboard development in under 10 minutes. - -## Prerequisites - -- **Go 1.21+** - Backend -- **Python 3.11+** - Extensions -- **PostgreSQL 15+** - Database -- **Redis 7+** - WebSocket scaling -- **Node.js 18+** (optional) - For frontend tooling -- **Docker** (optional) - For containerized development - -## 🏃 Quick Setup - -### 1. Clone and Enter -```bash -git clone https://git.gobha.me/xcaliber/chat-switchboard.git -cd chat-switchboard -``` - -### 2. Start Services (Docker) -```bash -docker-compose up -d -``` - -This starts: -- PostgreSQL on `localhost:5432` -- Redis on `localhost:6379` - -### 3. Backend Setup -```bash -cd server -go mod download -cp .env.example .env -# Edit .env with database credentials - -# Run migrations -go run cmd/migrate/main.go up - -# Start server -go run main.go -``` - -Backend runs on `http://localhost:8080` - -### 4. Frontend Setup -```bash -cd src -python3 -m http.server 3000 -``` - -Frontend runs on `http://localhost:3000` - -### 5. Open Browser -``` -http://localhost:3000 -``` - -Configure API settings to point to `http://localhost:8080` - ---- - -## 🎯 Your First Contribution - -### Step 1: Pick an Issue -Browse [issues](https://git.gobha.me/xcaliber/chat-switchboard/issues) and pick one labeled: -- 🟡 **LOW** priority -- `good-first-issue` - -### Step 2: Create Branch -```bash -git checkout develop -git pull origin develop -git checkout -b issue-XX-description -``` - -### Step 3: Make Changes -Edit files, test locally: -```bash -# Backend tests -cd server -go test ./... - -# Frontend lint (if ESLint configured) -cd src -npm run lint -``` - -### Step 4: Commit and Push -```bash -git add . -git commit -m "[Category] Description (refs #XX)" -git push origin issue-XX-description -``` - -### Step 5: Open PR -Go to GitHub/Gitea and open PR: -- **Base:** `develop` -- **Title:** `[Category] Description (closes #XX)` -- Fill in PR template - -### Step 6: Wait for Review -Maintainers will review and provide feedback. - ---- - -## 🔧 Development Commands - -### Backend -```bash -# Run server -go run main.go - -# Run tests -go test ./... - -# Run tests with coverage -go test -coverprofile=coverage.out ./... -go tool cover -html=coverage.out - -# Lint -golangci-lint run - -# Build -go build -o bin/server main.go - -# Run migrations -go run cmd/migrate/main.go up -go run cmd/migrate/main.go down -``` - -### Frontend -```bash -# Serve for development -python3 -m http.server 3000 - -# Build standalone -./build.sh - -# Lint (if configured) -npm run lint -``` - -### Docker -```bash -# Start all services -docker-compose up -d - -# View logs -docker-compose logs -f - -# Restart a service -docker-compose restart backend - -# Stop all -docker-compose down -``` - ---- - -## 📁 Project Structure Quick Reference - -``` -chat-switchboard/ -├── server/ # Go backend -│ ├── main.go # Entry point -│ ├── handlers/ # API handlers -│ ├── middleware/ # Auth, CORS, etc. -│ ├── models/ # Database models -│ ├── extensions/ # Extension manager -│ ├── websocket/ # WebSocket hub -│ └── workflows/ # Workflow engine -├── src/ # Frontend -│ ├── index.html -│ ├── js/ -│ │ ├── app.js # Main app logic -│ │ ├── state.js # State management -│ │ ├── api.js # API calls -│ │ └── ui.js # UI rendering -│ └── css/ -├── extensions/ # Backend extensions -│ ├── _template-python/ -│ ├── chat-engine/ -│ ├── web-search/ -│ └── calculator/ -├── migrations/ # Database migrations -├── docs/ # Documentation -│ ├── ARCHITECTURE.md -│ ├── WORKFLOWS.md -│ ├── PLUGIN_SPEC.md -│ └── GETTING_STARTED.md -├── ROADMAP.md # Development roadmap -└── ISSUES.md # Issue workflow -``` - ---- - -## 🐍 Creating Your First Extension - -### 1. Copy Template -```bash -cp -r extensions/_template-python extensions/my-extension -cd extensions/my-extension -``` - -### 2. Edit Manifest -```json -{ - "name": "my-extension", - "version": "1.0.0", - "runtime": "python", - "entry": "main.py", - "port": 9001, - "tools": [ - { - "name": "my_tool", - "description": "Does something cool", - "parameters": { - "type": "object", - "properties": { - "input": { - "type": "string", - "description": "Input text" - } - } - } - } - ] -} -``` - -### 3. Implement Tool -```python -# main.py -from fastapi import FastAPI -import uvicorn - -app = FastAPI() - -@app.post("/tools/my_tool") -async def my_tool(input: str): - # Your logic here - result = input.upper() # Example - return {"result": result} - -if __name__ == "__main__": - uvicorn.run(app, host="127.0.0.1", port=9001) -``` - -### 4. Test -```bash -python main.py -# In another terminal: -curl -X POST http://localhost:9001/tools/my_tool \ - -H "Content-Type: application/json" \ - -d '{"input": "hello"}' -``` - -### 5. Register with Backend -Backend auto-discovers extensions in `/extensions` folder on startup. - ---- - -## 🧪 Running Tests - -### Backend Unit Tests -```bash -cd server -go test ./handlers -v -go test ./extensions -v -go test ./workflows -v -``` - -### Integration Tests -```bash -# Start services -docker-compose up -d - -# Run tests -go test ./tests/integration -v -``` - -### E2E Tests -```bash -# Install Playwright (one-time) -npm install -D @playwright/test -npx playwright install - -# Run E2E tests -npx playwright test -``` - ---- - -## 🔍 Debugging - -### Backend Debugging -```bash -# Run with delve debugger -dlv debug main.go - -# Or use VS Code launch.json -# F5 to start debugging -``` - -### Frontend Debugging -- Open browser DevTools (F12) -- Check Console for errors -- Use Network tab for API calls - -### Extension Debugging -```bash -# Run extension standalone -cd extensions/my-extension -python main.py - -# Check logs -tail -f logs/extension.log -``` - ---- - -## 📚 Essential Reading - -Before you start coding: - -1. **[ARCHITECTURE.md](ARCHITECTURE.md)** - System design -2. **[PLUGIN_SPEC.md](PLUGIN_SPEC.md)** - Extension development -3. **[ISSUES.md](../ISSUES.md)** - Contribution workflow -4. **[ROADMAP.md](../ROADMAP.md)** - Project direction - ---- - -## 💬 Getting Help - -- **Issues:** [GitHub Issues](https://git.gobha.me/xcaliber/chat-switchboard/issues) -- **Discussions:** (Coming soon) -- **Discord:** (Coming soon) - ---- - -## ✨ Pro Tips - -1. **Use `develop` branch** - Never commit directly to `main` -2. **Small PRs** - Easier to review, faster to merge -3. **Test locally** - Don't rely on CI to catch errors -4. **Ask questions** - Better to ask than assume -5. **Read existing code** - Learn patterns from implemented features - ---- - -## 🎉 You're Ready! - -Pick an issue and start coding. Welcome to the team! 🚀 - -**Next Steps:** -1. Browse [open issues](https://git.gobha.me/xcaliber/chat-switchboard/issues) -2. Read the [ROADMAP](../ROADMAP.md) -3. Make your first PR! diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md deleted file mode 100644 index 621669d..0000000 --- a/docs/WORKFLOWS.md +++ /dev/null @@ -1,575 +0,0 @@ -# 🔄 Workflow System - The Killer Feature - -## What Are Workflows? - -Workflows let you **chain multiple AI models, tools, and data sources** into automated pipelines. Think Zapier/n8n but for LLMs. - -### Why This Matters - -**Current State (Competitors):** -``` -User → Single Prompt → One Model → Response -``` - -**Chat Switchboard Workflows:** -``` -User Input → [Model A] → [Tool] → [Model B] → [Conditional Logic] → Final Output - ↓ logs ↓ saves ↓ verifies ↓ branches -``` - -## Real-World Examples - -### 1. Research Assistant -**Problem:** Researching a topic requires multiple steps and models - -``` -┌─────────────┐ -│ User Query │ -└──────┬──────┘ - │ - ↓ -┌─────────────────────┐ -│ 1. Web Search Tool │ ← DuckDuckGo API -└──────┬──────────────┘ - │ (top 5 results) - ↓ -┌─────────────────────────┐ -│ 2. GPT-4: Extract Facts │ ← Fast, cheap summarization -└──────┬──────────────────┘ - │ (structured data) - ↓ -┌──────────────────────────┐ -│ 3. Claude: Verify Claims │ ← Accurate fact-checking -└──────┬───────────────────┘ - │ (verified facts) - ↓ -┌──────────────────────────┐ -│ 4. Gemini: Write Report │ ← Long-form generation -└──────┬───────────────────┘ - │ (final report) - ↓ -┌──────────────────────┐ -│ 5. Save to KB Tool │ ← Store for future reference -└──────────────────────┘ -``` - -**Time Saved:** 30 minutes → 2 minutes -**Cost Optimization:** Uses cheap models where possible - -### 2. Code Review Pipeline -``` -[GitHub PR] → [Fetch Code Tool] → [GPT-4: Find Bugs] → [Claude: Security Check] - ↓ ↓ - [Save Issues] [Create Report] - ↓ - [Post to Channel Tool] -``` - -### 3. Multi-Model Consensus -**Problem:** Get the "best" answer by combining multiple AI perspectives - -``` - ┌─[GPT-4]─┐ - │ │ -[User Question] ────┼─[Claude]─┼──→ [Voting Logic] ──→ [Best Answer] - │ │ - └─[Gemini]┘ -``` - -**Use Case:** Medical advice, legal analysis, important decisions - -### 4. Content Creation Factory -``` -[Topic] → [GPT-4: Outline] → [Claude: Draft] → [Grammar Tool] → [SEO Tool] → [Publish] - ↓ ↓ ↓ ↓ - [Save Draft] [Version 1] [Version 2] [Final] -``` - -### 5. Data Processing Pipeline -``` -[CSV Upload] → [Python Tool: Parse] → [GPT-3.5: Categorize] → [Save to PostgreSQL] - ↓ - [Generate Report Tool] -``` - -## Workflow Builder UI - -### Visual Editor (Drag & Drop) - -``` -+------------------+ +------------------+ +------------------+ -| [Web Search] |---->| [GPT-4 Node] |---->| [Save Note] | -| | | | | | -| Query: {input} | | Prompt: Summary | | Title: Research | -+------------------+ +------------------+ +------------------+ - ↓ ↓ ↓ - [5 results] [Markdown text] [Note ID] -``` - -### Node Types - -#### 1. Input Nodes -- **User Input** - Text, file, form -- **Trigger** - Schedule, webhook, event -- **Variable** - Constants, config - -#### 2. Model Nodes -- **LLM Call** - Any configured model -- **Model Router** - Auto-select best model -- **Multi-Model** - Run parallel, compare - -#### 3. Tool Nodes -- **Web Search** -- **Code Execution** -- **API Call** -- **Database Query** -- **File Operations** -- **Custom Extension Tools** - -#### 4. Logic Nodes -- **Conditional** - If/else branching -- **Loop** - Iterate over data -- **Merge** - Combine results -- **Filter** - Data transformation - -#### 5. Output Nodes -- **Response** - Return to user -- **Save** - Store in DB/KB -- **Webhook** - External notification -- **Channel Message** - Post to channel - -## Workflow Definition (JSON) - -```json -{ - "id": "research-assistant", - "name": "Research Assistant", - "version": "1.0.0", - "description": "Search, summarize, verify, and save research", - "nodes": [ - { - "id": "input", - "type": "input", - "config": { - "schema": { - "query": {"type": "string", "required": true} - } - } - }, - { - "id": "search", - "type": "tool", - "tool": "web_search", - "config": { - "query": "{{input.query}}", - "max_results": 5 - } - }, - { - "id": "summarize", - "type": "llm", - "model": "gpt-4o-mini", - "config": { - "prompt": "Summarize these search results:\n{{search.results}}", - "max_tokens": 500 - } - }, - { - "id": "verify", - "type": "llm", - "model": "claude-3.5-sonnet", - "config": { - "prompt": "Verify the accuracy of:\n{{summarize.content}}", - "max_tokens": 300 - } - }, - { - "id": "save", - "type": "tool", - "tool": "save_to_kb", - "config": { - "kb_id": "research", - "title": "{{input.query}}", - "content": "{{verify.content}}" - } - }, - { - "id": "output", - "type": "output", - "config": { - "response": "{{verify.content}}" - } - } - ], - "edges": [ - {"from": "input", "to": "search"}, - {"from": "search", "to": "summarize"}, - {"from": "summarize", "to": "verify"}, - {"from": "verify", "to": "save"}, - {"from": "save", "to": "output"} - ] -} -``` - -## Execution Engine - -### Go Workflow Runner - -```go -// server/workflows/executor.go -package workflows - -type Executor struct { - workflow *Workflow - context map[string]interface{} - llmClient *LLMClient - toolRegistry *ToolRegistry -} - -func (e *Executor) Run(input map[string]interface{}) (*Result, error) { - e.context["input"] = input - - // Topological sort to execute DAG in order - sorted := e.workflow.TopologicalSort() - - for _, node := range sorted { - result, err := e.executeNode(node) - if err != nil { - return nil, err - } - e.context[node.ID] = result - } - - return e.context["output"], nil -} - -func (e *Executor) executeNode(node *Node) (interface{}, error) { - switch node.Type { - case "llm": - return e.executeLLM(node) - case "tool": - return e.executeTool(node) - case "conditional": - return e.executeConditional(node) - // ... other types - } -} - -func (e *Executor) executeLLM(node *Node) (interface{}, error) { - // Resolve template variables - prompt := e.resolveTemplate(node.Config["prompt"]) - model := node.Config["model"] - - // Call LLM - response, err := e.llmClient.Chat(model, prompt) - return response, err -} -``` - -### Variable Resolution - -```go -// Template: "Summarize: {{search.results}}" -// Context: {"search": {"results": "..."}} -// Result: "Summarize: ..." - -func (e *Executor) resolveTemplate(template string) string { - re := regexp.MustCompile(`\{\{([^}]+)\}\}`) - return re.ReplaceAllStringFunc(template, func(match string) string { - path := match[2:len(match)-2] // Remove {{}} - return e.getFromContext(path) - }) -} -``` - -## Database Schema (Workflows) - -```sql --- Workflow definitions -CREATE TABLE workflows ( - id UUID PRIMARY KEY, - user_id UUID REFERENCES users(id), - name VARCHAR(200), - graph JSONB NOT NULL, -- Node/edge structure - input_schema JSONB, - output_schema JSONB, - is_public BOOLEAN DEFAULT false, - tags TEXT[] -); - --- Workflow executions (audit trail) -CREATE TABLE workflow_executions ( - id UUID PRIMARY KEY, - workflow_id UUID REFERENCES workflows(id), - input_data JSONB, - output_data JSONB, - status VARCHAR(20), -- running, completed, failed - steps JSONB, -- [{node_id, input, output, duration}] - total_cost_usd NUMERIC(10, 6), - total_time_ms INTEGER, - started_at TIMESTAMP, - completed_at TIMESTAMP -); -``` - -## Workflow Marketplace - -### Shareable Templates - -```bash -# Export workflow -POST /api/workflows/{id}/export -→ workflow.json file - -# Import workflow -POST /api/workflows/import -← Upload workflow.json - -# Publish to marketplace -POST /api/marketplace/publish -{ - "workflow_id": "uuid", - "category": "research", - "price": 0, // Free or paid - "license": "MIT" -} -``` - -### Categories -- 📊 Data Analysis -- 🔍 Research -- 📝 Content Creation -- 💻 Code Review -- 🎓 Education -- 💼 Business Intelligence -- 🔐 Security Audits - -## Cost Optimization - -### Smart Model Routing in Workflows - -```javascript -// Workflow node: Auto-select cheapest capable model -{ - "id": "summarize", - "type": "llm_auto", - "config": { - "task": "summarization", - "max_cost": 0.01, // $0.01 max - "min_quality": 0.8, // 80% quality threshold - "prompt": "..." - } -} - -// Engine selects: -// GPT-4o-mini ($0.0001/token) ✅ -// Not GPT-4 ($0.03/token) - too expensive -``` - -### Execution Cost Tracking - -```sql --- Track workflow costs -SELECT - w.name, - COUNT(we.id) as executions, - AVG(we.total_cost_usd) as avg_cost, - SUM(we.total_cost_usd) as total_cost -FROM workflows w -JOIN workflow_executions we ON w.id = we.workflow_id -GROUP BY w.id -ORDER BY total_cost DESC; -``` - -## Advanced Features - -### 1. Conditional Branching - -```json -{ - "id": "quality_check", - "type": "conditional", - "config": { - "condition": "{{verify.confidence}} > 0.8", - "true_branch": "save", - "false_branch": "human_review" - } -} -``` - -### 2. Loops - -```json -{ - "id": "process_batch", - "type": "loop", - "config": { - "items": "{{input.files}}", - "body": "extract_data_node", - "merge": "combine_results_node" - } -} -``` - -### 3. Parallel Execution - -```json -{ - "id": "parallel_models", - "type": "parallel", - "config": { - "branches": ["gpt4_node", "claude_node", "gemini_node"], - "merge_strategy": "voting" // or "first", "all", "custom" - } -} -``` - -### 4. Error Handling - -```json -{ - "id": "api_call", - "type": "tool", - "config": { - "tool": "external_api", - "retry": 3, - "timeout": 5000, - "on_error": "fallback_node" - } -} -``` - -## Security & Limits - -### Sandboxing -- Code execution in isolated containers -- API rate limiting per workflow -- Cost caps per execution - -### Permissions -```sql --- Workflow ACL -CREATE TABLE workflow_permissions ( - workflow_id UUID REFERENCES workflows(id), - user_id UUID REFERENCES users(id), - permission VARCHAR(20), -- read, execute, edit, admin - UNIQUE(workflow_id, user_id) -); -``` - -## API Endpoints - -``` -POST /api/workflows # Create workflow -GET /api/workflows # List user's workflows -GET /api/workflows/{id} # Get workflow -PUT /api/workflows/{id} # Update workflow -DELETE /api/workflows/{id} # Delete workflow - -POST /api/workflows/{id}/execute # Run workflow -GET /api/workflows/{id}/executions # Execution history -GET /api/executions/{id} # Execution details -POST /api/executions/{id}/stop # Cancel execution - -GET /api/marketplace/workflows # Browse public workflows -POST /api/marketplace/install/{id} # Install from marketplace -``` - -## Frontend UI (React/Vue Later, Start Simple) - -### Workflow Editor Component - -```javascript -// src/js/workflow-editor.js -class WorkflowEditor { - constructor(canvas) { - this.canvas = canvas; - this.nodes = []; - this.edges = []; - } - - addNode(type, x, y) { - const node = { - id: generateId(), - type: type, - position: {x, y}, - config: {} - }; - this.nodes.push(node); - this.render(); - } - - connectNodes(from, to) { - this.edges.push({from, to}); - this.render(); - } - - execute() { - const workflow = this.toJSON(); - fetch('/api/workflows/execute', { - method: 'POST', - body: JSON.stringify(workflow) - }).then(r => r.json()).then(result => { - console.log('Workflow result:', result); - }); - } -} -``` - -## Comparison with Alternatives - -| Feature | Chat Switchboard | LangChain | n8n | Zapier | -|---------|-----------------|-----------|-----|--------| -| Visual Builder | ✅ | ❌ | ✅ | ✅ | -| Multi-Model | ✅ | Limited | ❌ | ❌ | -| Open Source | ✅ | ✅ | ✅ | ❌ | -| Self-Hosted | ✅ | N/A | ✅ | ❌ | -| Cost Tracking | ✅ | ❌ | ❌ | ✅ | -| LLM-First | ✅ | ✅ | ❌ | ❌ | -| No-Code | ✅ | ❌ | ✅ | ✅ | - -**Unique Position:** LangChain for non-coders + n8n for LLMs - -## Roadmap - -### MVP (v1.0) -- ✅ Basic DAG execution -- ✅ LLM nodes (single model) -- ✅ Tool nodes (web search, code exec) -- ✅ Input/output nodes -- ✅ Simple UI (form-based, not visual yet) - -### v1.1 -- ⬜ Visual drag-drop editor -- ⬜ Conditional logic -- ⬜ Loops -- ⬜ Cost tracking - -### v1.2 -- ⬜ Template marketplace -- ⬜ Workflow sharing -- ⬜ Scheduled executions -- ⬜ Webhook triggers - -### v2.0 -- ⬜ Collaborative editing -- ⬜ Version control (Git-like) -- ⬜ A/B testing workflows -- ⬜ Analytics dashboard - ---- - -## Get Started - -```bash -# Create your first workflow -curl -X POST https://api.chatswitch.example.com/api/workflows \ - -H "Authorization: Bearer $TOKEN" \ - -d @examples/research-assistant.json - -# Execute it -curl -X POST https://api.chatswitch.example.com/api/workflows/{id}/execute \ - -d '{"query": "Latest AI research"}' -``` - -**This is the feature that sets Chat Switchboard apart.** diff --git a/frontend/css/styles.css b/frontend/css/styles.css deleted file mode 100644 index eaefcd5..0000000 --- a/frontend/css/styles.css +++ /dev/null @@ -1,829 +0,0 @@ -/* ========================================== - Chat Switchboard - Styles - ========================================== */ - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --bg-primary: #212121; - --bg-secondary: #171717; - --bg-tertiary: #2f2f2f; - --text-primary: #ececec; - --text-secondary: #b4b4b4; - --accent: #10a37f; - --accent-hover: #0d8a6a; - --border: #3a3a3a; - --code-bg: #1e1e1e; - --error: #ff6b6b; - --success: #10a37f; - --warning: #ffd93d; - --thinking-bg: #2a2a3a; - --thinking-border: #4a4a6a; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - background: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; - display: flex; - flex-direction: column; -} - -/* ========================================== - Header - ========================================== */ - -.header { - background: var(--bg-secondary); - padding: 1rem 2rem; - border-bottom: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 1rem; -} - -.header h1 { - font-size: 1.5rem; - font-weight: 600; - color: var(--accent); -} - -.header-actions { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - align-items: center; -} - -/* ========================================== - Buttons - ========================================== */ - -.btn { - padding: 0.5rem 1rem; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: all 0.2s ease; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.btn-primary { background: var(--accent); color: white; } -.btn-primary:hover { background: var(--accent-hover); } -.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); } -.btn-secondary:hover { background: var(--border); } -.btn-danger { background: transparent; color: var(--error); border: 1px solid var(--error); } -.btn-danger:hover { background: var(--error); color: white; } -.btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } - - -/* ========================================== - Layout - ========================================== */ - -.main-container { - display: flex; - flex: 1; - overflow: hidden; -} - -.sidebar { - width: 260px; - background: var(--bg-secondary); - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - transition: width 0.3s ease; -} - -.sidebar.collapsed { width: 0; overflow: hidden; } - -.sidebar-header { - padding: 1rem; - border-bottom: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; -} - -.sidebar-title { - font-weight: 600; - font-size: 0.875rem; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.new-chat-btn { margin: 1rem; width: calc(100% - 2rem); } - -.chat-history { flex: 1; overflow-y: auto; padding: 0.5rem; } - -.chat-history-item { - padding: 0.75rem 1rem; - border-radius: 6px; - cursor: pointer; - margin-bottom: 0.25rem; - transition: background 0.2s ease; - display: flex; - justify-content: space-between; - align-items: center; - gap: 0.5rem; -} - -.chat-history-item:hover { background: var(--bg-tertiary); } -.chat-history-item.active { background: var(--bg-tertiary); border-left: 3px solid var(--accent); } - -.chat-history-item .title { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 0.875rem; -} - -.chat-history-item .item-actions { - display: flex; - gap: 0.25rem; - opacity: 0; - transition: opacity 0.2s; -} - -.chat-history-item:hover .item-actions { opacity: 1; } - -.chat-history-item .item-btn { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 0.25rem; - border-radius: 4px; - transition: all 0.2s; -} - -.chat-history-item .item-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.1); } -.chat-history-item .item-btn.delete:hover { color: var(--error); background: rgba(255, 107, 107, 0.1); } - -.chat-area { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1rem 2rem; -} - - -/* ========================================== - Messages - ========================================== */ - -.message { - max-width: 800px; - margin: 0 auto 1.5rem; - animation: fadeIn 0.3s ease; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -.message-content { - display: flex; - gap: 1rem; - align-items: flex-start; -} - -.message-avatar { - width: 36px; - height: 36px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; - flex-shrink: 0; -} - -.message.user .message-avatar { background: var(--accent); } -.message.assistant .message-avatar { background: linear-gradient(135deg, #6366f1, #8b5cf6); } - -.message-body { flex: 1; min-width: 0; } - -.message-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; -} - -.message-role { - font-weight: 600; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.message-time { - font-size: 0.7rem; - color: var(--text-secondary); -} - -.message-actions { - display: flex; - gap: 0.25rem; - opacity: 0; - transition: opacity 0.2s; -} - -.message:hover .message-actions { opacity: 1; } - -.message-action-btn { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.7rem; - transition: all 0.2s; -} - -.message-action-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); } - -.message-text { - line-height: 1.6; - word-wrap: break-word; -} - -.message-text p { margin-bottom: 0.75rem; } -.message-text p:last-child { margin-bottom: 0; } - -.message-text code { - background: var(--code-bg); - padding: 0.2rem 0.4rem; - border-radius: 4px; - font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; - font-size: 0.875em; -} - -.message-text pre { - background: var(--code-bg); - padding: 1rem; - border-radius: 8px; - overflow-x: auto; - margin: 0.75rem 0; - position: relative; -} - -.message-text pre code { background: none; padding: 0; } - -.copy-code-btn { - position: absolute; - top: 0.5rem; - right: 0.5rem; - background: var(--bg-tertiary); - border: 1px solid var(--border); - color: var(--text-secondary); - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.7rem; - cursor: pointer; - opacity: 0; - transition: opacity 0.2s; -} - -.message-text pre:hover .copy-code-btn { opacity: 1; } -.copy-code-btn:hover { background: var(--border); color: var(--text-primary); } - - -/* ========================================== - Thinking Blocks (Collapsible) - ========================================== */ - -.thinking-block { - background: var(--thinking-bg); - border: 1px solid var(--thinking-border); - border-radius: 8px; - margin: 0.75rem 0; - overflow: hidden; -} - -.thinking-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1rem; - background: rgba(74, 74, 106, 0.3); - cursor: pointer; - user-select: none; - transition: background 0.2s; -} - -.thinking-header:hover { - background: rgba(74, 74, 106, 0.5); -} - -.thinking-toggle { - font-size: 0.75rem; - transition: transform 0.2s; -} - -.thinking-block.collapsed .thinking-toggle { - transform: rotate(-90deg); -} - -.thinking-label { - font-size: 0.8rem; - font-weight: 600; - color: #a0a0d0; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.thinking-content { - padding: 1rem; - font-size: 0.9rem; - color: var(--text-secondary); - border-top: 1px solid var(--thinking-border); - max-height: 300px; - overflow-y: auto; - transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease; -} - -.thinking-block.collapsed .thinking-content { - max-height: 0; - padding: 0 1rem; - opacity: 0; - border-top: none; -} - -/* ========================================== - Typing Indicator - ========================================== */ - -.typing-indicator { - display: flex; - gap: 4px; - padding: 0.5rem 0; -} - -.typing-indicator span { - width: 8px; - height: 8px; - background: var(--text-secondary); - border-radius: 50%; - animation: bounce 1.4s infinite ease-in-out; -} - -.typing-indicator span:nth-child(1) { animation-delay: -0.32s; } -.typing-indicator span:nth-child(2) { animation-delay: -0.16s; } - -@keyframes bounce { - 0%, 80%, 100% { transform: scale(0); } - 40% { transform: scale(1); } -} - - -/* ========================================== - Input Area - ========================================== */ - -.input-area { - background: var(--bg-secondary); - border-top: 1px solid var(--border); - padding: 1rem 2rem; -} - -.input-container { max-width: 800px; margin: 0 auto; } - -.input-top-bar { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; - gap: 1rem; - flex-wrap: wrap; -} - -.model-selector { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.model-selector label { - font-size: 0.75rem; - color: var(--text-secondary); -} - -.model-selector select { - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 6px; - color: var(--text-primary); - padding: 0.35rem 0.75rem; - font-size: 0.8rem; - cursor: pointer; - max-width: 200px; -} - -.model-selector select:focus { outline: none; border-color: var(--accent); } - -.input-shortcuts { - font-size: 0.7rem; - color: var(--text-secondary); -} - -.input-shortcuts kbd { - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 3px; - padding: 0.1rem 0.3rem; - font-family: inherit; -} - -.input-wrapper { - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 12px; - padding: 1rem; - transition: border-color 0.2s ease; -} - -.input-wrapper:focus-within { border-color: var(--accent); } - -.input-wrapper textarea { - width: 100%; - background: none; - border: none; - color: var(--text-primary); - font-size: 1rem; - font-family: inherit; - resize: none; - outline: none; - line-height: 1.5; - max-height: 200px; - min-height: 60px; -} - -.input-wrapper textarea::placeholder { color: var(--text-secondary); } - -.input-actions { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--border); -} - -.input-left { display: flex; gap: 0.5rem; align-items: center; } - -.send-btn { - padding: 0.5rem 1rem; - background: var(--accent); - color: white; - border: none; - border-radius: 6px; - cursor: pointer; - font-weight: 500; - transition: all 0.2s ease; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.send-btn:hover:not(:disabled) { background: var(--accent-hover); } -.send-btn:disabled { opacity: 0.5; cursor: not-allowed; } - -.stop-btn { - padding: 0.5rem 1rem; - background: var(--error); - color: white; - border: none; - border-radius: 6px; - cursor: pointer; - font-weight: 500; - display: none; - align-items: center; - gap: 0.5rem; -} - -.stop-btn:hover { background: #ff5252; } -.stop-btn.visible { display: flex; } - - -/* ========================================== - Modal - ========================================== */ - -.modal-overlay { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.7); - display: none; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal-overlay.active { display: flex; } - -.modal { - background: var(--bg-secondary); - border-radius: 12px; - width: 90%; - max-width: 600px; - max-height: 90vh; - overflow-y: auto; - animation: modalSlideIn 0.3s ease; -} - -@keyframes modalSlideIn { - from { opacity: 0; transform: scale(0.9); } - to { opacity: 1; transform: scale(1); } -} - -.modal-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; -} - -.modal-header h2 { font-size: 1.25rem; font-weight: 600; } - -.modal-close { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 0.5rem; - border-radius: 6px; - transition: all 0.2s; - font-size: 1.5rem; - line-height: 1; -} - -.modal-close:hover { color: var(--text-primary); background: var(--bg-tertiary); } - -.modal-body { padding: 1.5rem; } - -.modal-footer { - padding: 1rem 1.5rem; - border-top: 1px solid var(--border); - display: flex; - justify-content: flex-end; - gap: 0.75rem; -} - -/* ========================================== - Forms - ========================================== */ - -.form-group { margin-bottom: 1.5rem; } - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: var(--text-secondary); - font-size: 0.875rem; -} - -.form-group input, -.form-group select, -.form-group textarea { - width: 100%; - padding: 0.75rem 1rem; - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text-primary); - font-size: 1rem; - font-family: inherit; - transition: border-color 0.2s ease; -} - -.form-group input:focus, -.form-group select:focus, -.form-group textarea:focus { - outline: none; - border-color: var(--accent); -} - -.form-group small { - display: block; - margin-top: 0.5rem; - color: var(--text-secondary); - font-size: 0.75rem; -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; -} - -.toggle-group { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem; - background: var(--bg-tertiary); - border-radius: 8px; - margin-bottom: 1rem; -} - -.toggle-label { font-weight: 500; } - -.toggle-switch { - position: relative; - width: 50px; - height: 26px; -} - -.toggle-switch input { opacity: 0; width: 0; height: 0; } - -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; left: 0; right: 0; bottom: 0; - background: var(--border); - border-radius: 26px; - transition: 0.3s; -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 20px; - width: 20px; - left: 3px; - bottom: 3px; - background: white; - border-radius: 50%; - transition: 0.3s; -} - -.toggle-switch input:checked + .toggle-slider { background: var(--accent); } -.toggle-switch input:checked + .toggle-slider:before { transform: translateX(24px); } - - -/* ========================================== - Toast Notifications - ========================================== */ - -.toast-container { - position: fixed; - bottom: 2rem; - right: 2rem; - z-index: 2000; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.toast { - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem 1.5rem; - display: flex; - align-items: center; - gap: 0.75rem; - animation: slideIn 0.3s ease; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -@keyframes slideIn { - from { opacity: 0; transform: translateX(100%); } - to { opacity: 1; transform: translateX(0); } -} - -.toast.success { border-left: 3px solid var(--success); } -.toast.error { border-left: 3px solid var(--error); } -.toast.warning { border-left: 3px solid var(--warning); } - -.toast-close { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - margin-left: auto; -} - -/* ========================================== - Dropdown - ========================================== */ - -.dropdown { - position: relative; - display: inline-block; -} - -.dropdown-content { - display: none; - position: absolute; - right: 0; - top: 100%; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 8px; - min-width: 160px; - box-shadow: 0 4px 12px rgba(0,0,0,0.3); - z-index: 100; -} - -.dropdown-content.show { display: block; } - -.dropdown-item { - display: block; - width: 100%; - padding: 0.75rem 1rem; - background: none; - border: none; - color: var(--text-primary); - text-align: left; - cursor: pointer; - font-size: 0.875rem; - transition: background 0.2s; -} - -.dropdown-item:hover { background: var(--bg-tertiary); } -.dropdown-item:first-child { border-radius: 8px 8px 0 0; } -.dropdown-item:last-child { border-radius: 0 0 8px 8px; } - -/* ========================================== - Empty State - ========================================== */ - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - text-align: center; - padding: 2rem; -} - -.empty-state-icon { - width: 80px; - height: 80px; - background: var(--bg-tertiary); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 1.5rem; -} - -.empty-state-icon svg { width: 40px; height: 40px; stroke: var(--accent); } -.empty-state h2 { font-size: 1.5rem; margin-bottom: 0.5rem; } -.empty-state p { color: var(--text-secondary); max-width: 400px; } - -/* ========================================== - Responsive - ========================================== */ - -@media (max-width: 768px) { - .sidebar { - position: fixed; - left: 0; top: 0; bottom: 0; - z-index: 100; - transform: translateX(-100%); - } - .sidebar.open { transform: translateX(0); } - .header { padding: 1rem; } - .chat-messages { padding: 1rem; } - .input-area { padding: 1rem; } - .form-row { grid-template-columns: 1fr; } - .input-top-bar { flex-direction: column; align-items: flex-start; } -} - -/* ========================================== - Scrollbar - ========================================== */ - -::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-track { background: var(--bg-secondary); } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } -::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } diff --git a/frontend/js/api.js b/frontend/js/api.js deleted file mode 100644 index 9f857a6..0000000 --- a/frontend/js/api.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Chat Switchboard - API Module - * Handles communication with LLM backends - */ - -const API = { - /** - * Fetch available models from endpoint - */ - async fetchModels(endpoint, apiKey) { - const response = await fetch(endpoint.replace(/\/$/, '') + '/models', { - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`API Error: ${response.status}`); - } - - const data = await response.json(); - const models = data.data || data.models || data || []; - - return Array.isArray(models) - ? models.map(m => ({ - id: m.id || m.name || m, - owned_by: m.owned_by || null - })).sort((a, b) => a.id.localeCompare(b.id)) - : []; - }, - - /** - * Send chat completion request - */ - async sendMessage(settings, messages, onChunk = null) { - const endpoint = settings.apiEndpoint.replace(/\/$/, '') + '/chat/completions'; - - const controller = new AbortController(); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${settings.apiKey}` - }, - body: JSON.stringify({ - model: settings.model, - messages: messages.map(m => ({ role: m.role, content: m.content })), - max_tokens: settings.maxTokens, - temperature: settings.temperature, - top_p: settings.topP, - presence_penalty: settings.presencePenalty, - stream: settings.stream - }), - signal: controller.signal - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`API Error: ${response.status} - ${error}`); - } - - let content = ''; - - if (settings.stream && onChunk) { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6); - if (data === '[DONE]') continue; - - try { - const parsed = JSON.parse(data); - const delta = parsed.choices?.[0]?.delta?.content || ''; - content += delta; - onChunk(content, delta); - } catch (e) {} - } - } - } - } else { - const data = await response.json(); - content = data.choices?.[0]?.message?.content || 'No response'; - } - - return { content, controller }; - } -}; diff --git a/frontend/js/storage.js b/frontend/js/storage.js deleted file mode 100644 index 9d197d3..0000000 --- a/frontend/js/storage.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Chat Switchboard - Storage Module - * Abstraction layer for storage (localStorage now, API later) - */ - -const Storage = { - // Backend mode: 'local' or 'api' - mode: 'local', - apiBase: '/api', - - async get(key, defaultValue = null) { - if (this.mode === 'api') { - try { - const response = await fetch(`${this.apiBase}/storage/${key}`); - if (response.ok) { - return await response.json(); - } - return defaultValue; - } catch (e) { - console.error('Storage API get error:', e); - return defaultValue; - } - } - - // Local storage - try { - const item = localStorage.getItem(key); - return item ? JSON.parse(item) : defaultValue; - } catch (e) { - console.error('Storage get error:', e); - return defaultValue; - } - }, - - async set(key, value) { - if (this.mode === 'api') { - try { - await fetch(`${this.apiBase}/storage/${key}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(value) - }); - } catch (e) { - console.error('Storage API set error:', e); - } - return; - } - - // Local storage - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch (e) { - console.error('Storage set error:', e); - } - }, - - async remove(key) { - if (this.mode === 'api') { - try { - await fetch(`${this.apiBase}/storage/${key}`, { method: 'DELETE' }); - } catch (e) { - console.error('Storage API remove error:', e); - } - return; - } - - try { - localStorage.removeItem(key); - } catch (e) { - console.error('Storage remove error:', e); - } - }, - - // Switch to API mode - useApi(baseUrl = '/api') { - this.mode = 'api'; - this.apiBase = baseUrl; - }, - - // Switch to local mode - useLocal() { - this.mode = 'local'; - } -}; - -// For standalone HTML, use sync versions -const StorageSync = { - get(key, defaultValue = null) { - try { - const item = localStorage.getItem(key); - return item ? JSON.parse(item) : defaultValue; - } catch (e) { - return defaultValue; - } - }, - set(key, value) { - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch (e) { - console.error('Storage set error:', e); - } - }, - remove(key) { - try { - localStorage.removeItem(key); - } catch (e) {} - } -}; diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml deleted file mode 100644 index e1f8980..0000000 --- a/k8s/deployment.yaml +++ /dev/null @@ -1,135 +0,0 @@ -# k8s/deployment.yaml -# ============================================ -# Chat Switchboard - Kubernetes Deployment Template -# ============================================ -# Variables substituted by CI/CD via envsubst: -# DEPLOY_NAME, ENVIRONMENT, IMAGE_TAG, BASE_PATH, -# DB_NAME, REPLICAS, NAMESPACE, REGISTRY, IMAGE_NAME, -# DOMAIN, CERT_ISSUER, POSTGRES_HOST, POSTGRES_PORT, -# MEMORY_REQUEST, MEMORY_LIMIT, CPU_REQUEST, CPU_LIMIT -# ============================================ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ${DEPLOY_NAME} - namespace: ${NAMESPACE} - labels: - app: chat-switchboard - env: ${ENVIRONMENT} - service: chat-switchboard -spec: - replicas: ${REPLICAS} - selector: - matchLabels: - app: chat-switchboard - env: ${ENVIRONMENT} - template: - metadata: - labels: - app: chat-switchboard - env: ${ENVIRONMENT} - service: chat-switchboard - spec: - containers: - - name: switchboard - image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} - imagePullPolicy: Always - ports: - - containerPort: 80 - name: http - env: - - name: ENVIRONMENT - value: "${ENVIRONMENT}" - - name: BASE_PATH - value: "${BASE_PATH}" - - name: PORT - value: "8080" - # ── Database connection ── - - name: POSTGRES_HOST - value: "${POSTGRES_HOST}" - - name: POSTGRES_PORT - value: "${POSTGRES_PORT}" - - name: POSTGRES_DB - value: "${DB_NAME}" - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: switchboard-db-credentials - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: switchboard-db-credentials - key: POSTGRES_PASSWORD - - name: DATABASE_URL - value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@${POSTGRES_HOST}:${POSTGRES_PORT}/${DB_NAME}?sslmode=disable" - resources: - requests: - memory: "${MEMORY_REQUEST}" - cpu: "${CPU_REQUEST}" - limits: - memory: "${MEMORY_LIMIT}" - cpu: "${CPU_LIMIT}" - livenessProbe: - httpGet: - path: / - port: 80 - initialDelaySeconds: 10 - periodSeconds: 30 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 5 - periodSeconds: 10 - timeoutSeconds: 3 - failureThreshold: 3 ---- -apiVersion: v1 -kind: Service -metadata: - name: ${DEPLOY_NAME} - namespace: ${NAMESPACE} - labels: - app: chat-switchboard - env: ${ENVIRONMENT} -spec: - selector: - app: chat-switchboard - env: ${ENVIRONMENT} - ports: - - protocol: TCP - port: 80 - targetPort: 80 - name: http ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ${DEPLOY_NAME} - namespace: ${NAMESPACE} - annotations: - cert-manager.io/cluster-issuer: "${CERT_ISSUER}" - traefik.ingress.kubernetes.io/router.entrypoints: websecure - labels: - app: chat-switchboard - env: ${ENVIRONMENT} -spec: - ingressClassName: "traefik" - tls: - - hosts: - - ${DOMAIN} - secretName: chat-switchboard-tls - rules: - - host: "${DOMAIN}" - http: - paths: - - path: ${BASE_PATH} - pathType: Prefix - backend: - service: - name: ${DEPLOY_NAME} - port: - number: 80 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index 8b57d61..edf0e88 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -3,8 +3,10 @@ # Chat Switchboard - Ingress # ============================================ # Routes for a single subdomain: -# /api/* → backend service (Go API on :8080) -# /* → frontend service (nginx SPA on :80) +# /api/* → backend service (Go API on :8080) +# /health → backend service (health check) +# /ws → backend service (WebSocket, upgraded) +# /* → frontend service (nginx SPA on :80) # ============================================ apiVersion: networking.k8s.io/v1 kind: Ingress @@ -35,6 +37,22 @@ spec: name: switchboard-be${DEPLOY_SUFFIX} port: number: 8080 + # Health check → backend + - path: /health + pathType: Exact + backend: + service: + name: switchboard-be${DEPLOY_SUFFIX} + port: + number: 8080 + # WebSocket → backend (traefik handles upgrade automatically) + - path: /ws + pathType: Exact + backend: + service: + name: switchboard-be${DEPLOY_SUFFIX} + port: + number: 8080 # Everything else → frontend - path: / pathType: Prefix diff --git a/k8s/lite.yaml b/k8s/lite.yaml deleted file mode 100644 index acbdc29..0000000 --- a/k8s/lite.yaml +++ /dev/null @@ -1,104 +0,0 @@ -# k8s/lite.yaml -# ============================================ -# Chat Switchboard - Lite (FE-Only) -# ============================================ -# Static SPA with no backend. All data in localStorage. -# Deployed to lite.DOMAIN on push to main. -# ============================================ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: switchboard-lite - namespace: ${NAMESPACE} - labels: - app: switchboard - component: frontend - env: lite -spec: - replicas: 1 - selector: - matchLabels: - app: switchboard - component: frontend - env: lite - template: - metadata: - labels: - app: switchboard - component: frontend - env: lite - spec: - containers: - - name: frontend - image: ${FE_IMAGE}:${IMAGE_TAG} - imagePullPolicy: Always - ports: - - containerPort: 80 - name: http - resources: - requests: - memory: "32Mi" - cpu: "10m" - limits: - memory: "64Mi" - cpu: "50m" - readinessProbe: - httpGet: - path: / - port: 80 - initialDelaySeconds: 3 - periodSeconds: 10 - livenessProbe: - httpGet: - path: / - port: 80 - initialDelaySeconds: 5 - periodSeconds: 30 ---- -apiVersion: v1 -kind: Service -metadata: - name: switchboard-lite - namespace: ${NAMESPACE} - labels: - app: switchboard - env: lite -spec: - selector: - app: switchboard - component: frontend - env: lite - ports: - - protocol: TCP - port: 80 - targetPort: 80 - name: http ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: switchboard-lite - namespace: ${NAMESPACE} - annotations: - cert-manager.io/cluster-issuer: "${CERT_ISSUER}" - traefik.ingress.kubernetes.io/router.entrypoints: websecure - labels: - app: switchboard - env: lite -spec: - ingressClassName: "traefik" - tls: - - hosts: - - ${DEPLOY_HOST} - secretName: switchboard-lite-tls - rules: - - host: "${DEPLOY_HOST}" - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: switchboard-lite - port: - number: 80 diff --git a/nginx.conf b/nginx.conf index 7796e1e..ad41aa1 100644 --- a/nginx.conf +++ b/nginx.conf @@ -8,13 +8,28 @@ server { location /api/ { proxy_pass http://localhost:8080; proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; + } + + # Health check passthrough + location = /health { + proxy_pass http://localhost:8080; + } + + # WebSocket proxy + location = /ws { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; } # Gzip compression diff --git a/scripts/db-migrate.sh b/scripts/db-migrate.sh index 5586945..83024bf 100644 --- a/scripts/db-migrate.sh +++ b/scripts/db-migrate.sh @@ -2,6 +2,10 @@ # ============================================ # Chat Switchboard - Database Migration Runner # ============================================ +# NOTE: The Go backend auto-migrates on startup. +# This script is for manual/emergency use only. +# Canonical migration files: server/database/migrations/ +# ============================================ # Tracks applied migrations in schema_migrations. # In dev (DB_WIPE=true): drops all tables, re-runs # all migrations for a clean slate every PR build. @@ -14,7 +18,7 @@ set -euo pipefail -MIGRATIONS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../migrations" && pwd)" +MIGRATIONS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../server/database/migrations" && pwd)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Migration runner: ${DB_NAME}" diff --git a/scripts/db-validate.sh b/scripts/db-validate.sh new file mode 100644 index 0000000..da64f4f --- /dev/null +++ b/scripts/db-validate.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# ============================================ +# Chat Switchboard - Schema Validation +# ============================================ +# Verifies the database schema is correct after +# migration. Checks expected tables, key columns, +# and constraints. Exits non-zero on any failure. +# +# Required env vars: +# PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE +# +# Usage: +# scripts/db-validate.sh +# ============================================ + +set -euo pipefail + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Schema validation: ${PGDATABASE}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +ERRORS=0 + +# ── Helper: check table exists ─────────────── +check_table() { + local table="$1" + local exists + exists=$(psql -tAc "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}';") + if [[ "${exists}" == "1" ]]; then + echo " ✓ table: ${table}" + else + echo " ✗ MISSING table: ${table}" + ERRORS=$((ERRORS + 1)) + fi +} + +# ── Helper: check column exists ────────────── +check_column() { + local table="$1" + local column="$2" + local exists + exists=$(psql -tAc "SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='${table}' AND column_name='${column}';") + if [[ "${exists}" == "1" ]]; then + echo " ✓ column: ${table}.${column}" + else + echo " ✗ MISSING column: ${table}.${column}" + ERRORS=$((ERRORS + 1)) + fi +} + +# ── Helper: check index exists ─────────────── +check_index() { + local index="$1" + local exists + exists=$(psql -tAc "SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='${index}';") + if [[ "${exists}" == "1" ]]; then + echo " ✓ index: ${index}" + else + echo " ✗ MISSING index: ${index}" + ERRORS=$((ERRORS + 1)) + fi +} + +# ── Helper: check extension ────────────────── +check_extension() { + local ext="$1" + local exists + exists=$(psql -tAc "SELECT 1 FROM pg_extension WHERE extname='${ext}';") + if [[ "${exists}" == "1" ]]; then + echo " ✓ extension: ${ext}" + else + echo " ✗ MISSING extension: ${ext}" + ERRORS=$((ERRORS + 1)) + fi +} + +# ── 1. Extensions ──────────────────────────── +echo "" +echo "Extensions:" +check_extension "uuid-ossp" +check_extension "pgcrypto" + +# ── 2. Migration tracking ─────────────────── +echo "" +echo "Migration tracking:" +check_table "schema_migrations" +MIGRATION_COUNT=$(psql -tAc "SELECT COUNT(*) FROM schema_migrations;" 2>/dev/null || echo "0") +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) ───────── +echo "" +echo "Core tables:" +check_table "users" +check_table "api_configs" +check_table "chats" +check_table "chat_messages" + +# Key columns +check_column "users" "id" +check_column "users" "email" +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" + +# ── 4. Refresh tokens (002) ───────────────── +echo "" +echo "Auth tables:" +check_table "refresh_tokens" +check_column "refresh_tokens" "token_hash" +check_column "refresh_tokens" "user_id" + +# ── 5. Global settings (003) ──────────────── +echo "" +echo "Settings tables:" +check_table "global_settings" +check_column "global_settings" "key" +check_column "global_settings" "value" + +# ── 6. Model configs (004) ────────────────── +echo "" +echo "Model tables:" +check_table "model_configs" +check_column "model_configs" "api_config_id" +check_column "model_configs" "model_id" +check_column "model_configs" "capabilities" + +# ── 7. Provider capabilities (005) ────────── +echo "" +echo "Provider capabilities:" +check_column "api_configs" "custom_headers" +check_column "api_configs" "is_global" +check_table "user_model_preferences" +check_column "user_model_preferences" "user_id" +check_column "user_model_preferences" "model_config_id" + +# ═══════════════════════════════════════════ +# ADD NEW MIGRATION CHECKS ABOVE THIS LINE +# When you add migration 006, add checks here. +# ═══════════════════════════════════════════ + +# ── Summary ────────────────────────────────── +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +if [[ ${ERRORS} -eq 0 ]]; then + echo "✅ Schema validation passed (${MIGRATION_COUNT} migrations)" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 0 +else + echo "❌ Schema validation FAILED: ${ERRORS} errors" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 +fi diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh deleted file mode 100644 index f905b01..0000000 --- a/scripts/docker-entrypoint.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -# ============================================ -# Chat Switchboard - Backend Launcher -# ============================================ -# Runs as an nginx entrypoint.d hook. -# Starts the Go backend in the background -# before nginx begins accepting connections. -# ============================================ - -set -e - -echo "🔀 Starting Chat Switchboard backend..." - -# Launch Go backend in background -/usr/local/bin/switchboard & -BACKEND_PID=$! - -# Wait for backend to be ready (max 10s) -for i in $(seq 1 20); do - if wget -q --spider http://localhost:${PORT:-8080}/health 2>/dev/null; then - echo "✅ Backend ready (PID ${BACKEND_PID})" - exit 0 - fi - sleep 0.5 -done - -echo "❌ Backend failed to start within 10s" -exit 1 diff --git a/server/database/migrate.go b/server/database/migrate.go new file mode 100644 index 0000000..4f095cf --- /dev/null +++ b/server/database/migrate.go @@ -0,0 +1,148 @@ +package database + +import ( + "database/sql" + "embed" + "fmt" + "log" + "sort" + "strings" + "time" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Migrate checks the database schema state and applies any pending +// migrations. This runs at startup before the HTTP server opens. +// +// Flow: +// 1. Ping DB (health check) +// 2. Ensure schema_migrations table exists +// 3. Load applied versions +// 4. Discover embedded SQL files +// 5. Apply pending migrations in order +// +// All migrations run in individual transactions. A failed migration +// aborts startup — the backend will not serve traffic with an +// inconsistent schema. +func Migrate() error { + if DB == nil { + return fmt.Errorf("database not connected") + } + + start := time.Now() + log.Println("📋 Schema migration check...") + + // ── 1. Health check ───────────────────────── + if err := DB.Ping(); err != nil { + return fmt.Errorf("database unreachable: %w", err) + } + + // ── 2. Ensure tracking table exists ───────── + _, err := DB.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMPTZ DEFAULT NOW() + ) + `) + if err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + // ── 3. Load already-applied versions ──────── + applied := make(map[string]bool) + rows, err := DB.Query(`SELECT version FROM schema_migrations`) + if err != nil { + return fmt.Errorf("read schema_migrations: %w", err) + } + defer rows.Close() + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return fmt.Errorf("scan version: %w", err) + } + applied[v] = true + } + + // ── 4. Discover embedded migration files ──── + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read embedded migrations: %w", err) + } + + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + files = append(files, e.Name()) + } + } + sort.Strings(files) // lexicographic = version order (001_, 002_, ...) + + // ── 5. Apply pending ──────────────────────── + pending := 0 + skipped := 0 + for _, name := range files { + if applied[name] { + skipped++ + continue + } + + content, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + + log.Printf(" ▶ applying: %s", name) + + tx, err := DB.Begin() + if err != nil { + return fmt.Errorf("begin tx for %s: %w", name, err) + } + + if _, err := tx.Exec(string(content)); err != nil { + tx.Rollback() + return fmt.Errorf("migration %s failed: %w", name, err) + } + + if _, err := tx.Exec( + `INSERT INTO schema_migrations (version) VALUES ($1)`, name, + ); err != nil { + tx.Rollback() + return fmt.Errorf("record migration %s: %w", name, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + + log.Printf(" ✓ %s applied", name) + pending++ + } + + elapsed := time.Since(start).Round(time.Millisecond) + if pending > 0 { + log.Printf("✅ Migrations complete: %d applied, %d skipped (%s)", pending, skipped, elapsed) + } else { + log.Printf("✅ Schema up to date (%d migrations, %s)", skipped, elapsed) + } + + return nil +} + +// SchemaVersion returns the most recently applied migration version, +// or "" if no migrations have been applied. +func SchemaVersion() string { + if DB == nil { + return "" + } + var version sql.NullString + err := DB.QueryRow(` + SELECT version FROM schema_migrations + ORDER BY version DESC LIMIT 1 + `).Scan(&version) + if err != nil || !version.Valid { + return "" + } + return version.String +} diff --git a/migrations/001_full_schema.sql b/server/database/migrations/001_full_schema.sql similarity index 100% rename from migrations/001_full_schema.sql rename to server/database/migrations/001_full_schema.sql diff --git a/migrations/002_refresh_tokens.sql b/server/database/migrations/002_refresh_tokens.sql similarity index 100% rename from migrations/002_refresh_tokens.sql rename to server/database/migrations/002_refresh_tokens.sql diff --git a/migrations/003_global_settings.sql b/server/database/migrations/003_global_settings.sql similarity index 100% rename from migrations/003_global_settings.sql rename to server/database/migrations/003_global_settings.sql diff --git a/migrations/004_model_configs.sql b/server/database/migrations/004_model_configs.sql similarity index 100% rename from migrations/004_model_configs.sql rename to server/database/migrations/004_model_configs.sql diff --git a/server/database/migrations/005_provider_capabilities.sql b/server/database/migrations/005_provider_capabilities.sql new file mode 100644 index 0000000..0a5ab4f --- /dev/null +++ b/server/database/migrations/005_provider_capabilities.sql @@ -0,0 +1,60 @@ +-- Migration 005: Provider Capabilities & Custom Headers +-- +-- Adds provider-level configuration (custom headers, provider-specific settings) +-- and expands model capabilities to include output token limits. +-- This eliminates hardcoded max_tokens defaults throughout the system. + +-- ── api_configs: custom headers and global flag ── +ALTER TABLE api_configs + ADD COLUMN IF NOT EXISTS custom_headers JSONB DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS provider_settings JSONB DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS is_global BOOLEAN DEFAULT false; + +COMMENT ON COLUMN api_configs.custom_headers IS 'Extra HTTP headers sent with every request (e.g. OpenRouter HTTP-Referer)'; +COMMENT ON COLUMN api_configs.provider_settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)'; +COMMENT ON COLUMN api_configs.is_global IS 'Admin-managed configs visible to all users'; + +-- Backfill: existing admin-created configs (user_id IS NULL) are global +UPDATE api_configs SET is_global = true WHERE user_id IS NULL; + +-- ── model_configs: richer capabilities ── +-- The existing capabilities JSONB gets richer fields. +-- No schema change needed (it's JSONB), but let's update existing rows +-- that have the old minimal shape to include the new fields. +-- New canonical shape: +-- { +-- "streaming": true, +-- "tool_calling": false, +-- "vision": false, +-- "thinking": false, +-- "reasoning": false, +-- "code_optimized": false, +-- "max_context": 0, +-- "max_output_tokens": 0, +-- "web_search": false +-- } +-- max_output_tokens = 0 means "not set, use provider/heuristic default" + +-- Migrate old "tool" key to "tool_calling" for consistency +UPDATE model_configs +SET capabilities = capabilities - 'tool' || jsonb_build_object('tool_calling', COALESCE(capabilities->>'tool', 'false')::boolean) +WHERE capabilities ? 'tool' AND NOT capabilities ? 'tool_calling'; + +-- Migrate old "code" key to "code_optimized" +UPDATE model_configs +SET capabilities = capabilities - 'code' || jsonb_build_object('code_optimized', COALESCE(capabilities->>'code', 'false')::boolean) +WHERE capabilities ? 'code' AND NOT capabilities ? 'code_optimized'; + +-- ── user_model_preferences ── +-- Users can enable/disable models from global providers for personal use. +CREATE TABLE IF NOT EXISTS user_model_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + model_config_id UUID NOT NULL REFERENCES model_configs(id) ON DELETE CASCADE, + is_enabled BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, model_config_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_model_prefs_user ON user_model_preferences(user_id); diff --git a/server/events/bus.go b/server/events/bus.go new file mode 100644 index 0000000..f4c8a31 --- /dev/null +++ b/server/events/bus.go @@ -0,0 +1,116 @@ +package events + +import ( + "strings" + "sync" +) + +// Bus is a labeled publish/subscribe event bus. +// Handlers subscribe to patterns (exact or trailing wildcard). +// Publishing fans out to all matching subscribers. +type Bus struct { + mu sync.RWMutex + subs map[string][]*subscription + seq uint64 // subscription ID counter +} + +type subscription struct { + id uint64 + pattern string + handler Handler +} + +// NewBus creates a new event bus. +func NewBus() *Bus { + return &Bus{ + subs: make(map[string][]*subscription), + } +} + +// Subscribe registers a handler for a label pattern. +// Returns an unsubscribe function. +// +// Patterns: +// +// "chat.message.abc123" — exact match +// "chat.message.*" — wildcard: matches chat.message.{anything} +// "chat.*" — wildcard: matches chat.{anything} +// "*" — matches all events +func (b *Bus) Subscribe(pattern string, handler Handler) func() { + b.mu.Lock() + b.seq++ + sub := &subscription{id: b.seq, pattern: pattern, handler: handler} + b.subs[pattern] = append(b.subs[pattern], sub) + b.mu.Unlock() + + return func() { + b.mu.Lock() + defer b.mu.Unlock() + subs := b.subs[pattern] + for i, s := range subs { + if s.id == sub.id { + b.subs[pattern] = append(subs[:i], subs[i+1:]...) + break + } + } + } +} + +// Publish dispatches an event to all matching subscribers. +// Handlers are called synchronously in subscription order. +// Use PublishAsync for non-blocking dispatch. +func (b *Bus) Publish(event Event) { + b.mu.RLock() + var matched []Handler + for pattern, subs := range b.subs { + if match(event.Label, pattern) { + for _, s := range subs { + matched = append(matched, s.handler) + } + } + } + b.mu.RUnlock() + + for _, h := range matched { + h(event) + } +} + +// PublishAsync dispatches an event to all matching subscribers +// in separate goroutines. Useful for I/O-heavy handlers. +func (b *Bus) PublishAsync(event Event) { + b.mu.RLock() + var matched []Handler + for pattern, subs := range b.subs { + if match(event.Label, pattern) { + for _, s := range subs { + matched = append(matched, s.handler) + } + } + } + b.mu.RUnlock() + + for _, h := range matched { + go h(event) + } +} + +// match checks if a concrete label matches a subscription pattern. +// +// "chat.message.abc" matches "chat.message.abc" (exact) +// "chat.message.abc" matches "chat.message.*" (wildcard) +// "chat.message.abc" matches "chat.*" (wildcard) +// "chat.message.abc" matches "*" (global wildcard) +func match(label, pattern string) bool { + if pattern == "*" { + return true + } + if pattern == label { + return true + } + if !strings.HasSuffix(pattern, "*") { + return false + } + prefix := pattern[:len(pattern)-1] // "chat.message." from "chat.message.*" + return strings.HasPrefix(label, prefix) +} diff --git a/server/events/bus_test.go b/server/events/bus_test.go new file mode 100644 index 0000000..1b5a6a0 --- /dev/null +++ b/server/events/bus_test.go @@ -0,0 +1,123 @@ +package events + +import ( + "encoding/json" + "sync/atomic" + "testing" +) + +func TestMatch(t *testing.T) { + tests := []struct { + label, pattern string + want bool + }{ + {"chat.message.abc", "chat.message.abc", true}, + {"chat.message.abc", "chat.message.*", true}, + {"chat.message.abc", "chat.*", true}, + {"chat.message.abc", "*", true}, + {"chat.message.abc", "chat.message.xyz", false}, + {"chat.message.abc", "channel.message.*", false}, + {"chat.message.abc", "chat.message", false}, + {"ping", "ping", true}, + {"ping", "pong", false}, + {"plugin.hook.pre_completion", "plugin.hook.*", true}, + {"plugin.hook.pre_completion", "plugin.*", true}, + } + + for _, tt := range tests { + got := match(tt.label, tt.pattern) + if got != tt.want { + t.Errorf("match(%q, %q) = %v, want %v", tt.label, tt.pattern, got, tt.want) + } + } +} + +func TestBusPublishExact(t *testing.T) { + bus := NewBus() + var count int32 + + bus.Subscribe("chat.message.abc", func(e Event) { + atomic.AddInt32(&count, 1) + }) + + bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)}) + bus.Publish(Event{Label: "chat.message.xyz", Payload: json.RawMessage(`{}`)}) + + if atomic.LoadInt32(&count) != 1 { + t.Errorf("expected 1 dispatch, got %d", count) + } +} + +func TestBusPublishWildcard(t *testing.T) { + bus := NewBus() + var count int32 + + bus.Subscribe("chat.message.*", func(e Event) { + atomic.AddInt32(&count, 1) + }) + + bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)}) + bus.Publish(Event{Label: "chat.message.xyz", Payload: json.RawMessage(`{}`)}) + bus.Publish(Event{Label: "chat.typing.abc", Payload: json.RawMessage(`{}`)}) + + if atomic.LoadInt32(&count) != 2 { + t.Errorf("expected 2 dispatches, got %d", count) + } +} + +func TestBusUnsubscribe(t *testing.T) { + bus := NewBus() + var count int32 + + unsub := bus.Subscribe("test.event", func(e Event) { + atomic.AddInt32(&count, 1) + }) + + bus.Publish(Event{Label: "test.event", Payload: json.RawMessage(`{}`)}) + unsub() + bus.Publish(Event{Label: "test.event", Payload: json.RawMessage(`{}`)}) + + if atomic.LoadInt32(&count) != 1 { + t.Errorf("expected 1 dispatch after unsub, got %d", count) + } +} + +func TestBusGlobalWildcard(t *testing.T) { + bus := NewBus() + var count int32 + + bus.Subscribe("*", func(e Event) { + atomic.AddInt32(&count, 1) + }) + + bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)}) + bus.Publish(Event{Label: "system.notify", Payload: json.RawMessage(`{}`)}) + bus.Publish(Event{Label: "plugin.hook.pre_completion", Payload: json.RawMessage(`{}`)}) + + if atomic.LoadInt32(&count) != 3 { + t.Errorf("expected 3 dispatches, got %d", count) + } +} + +func TestRouteFor(t *testing.T) { + tests := []struct { + label string + want Direction + }{ + {"chat.message.abc", DirBoth}, + {"chat.typing.abc", DirBoth}, + {"system.notify", DirToClient}, + {"plugin.hook.pre_completion", DirLocal}, + {"internal.db.write", DirLocal}, + {"unknown.event", DirLocal}, // default + {"ping", DirFromClient}, + {"pong", DirToClient}, + } + + for _, tt := range tests { + got := RouteFor(tt.label) + if got != tt.want { + t.Errorf("RouteFor(%q) = %d, want %d", tt.label, got, tt.want) + } + } +} diff --git a/server/events/types.go b/server/events/types.go new file mode 100644 index 0000000..8736511 --- /dev/null +++ b/server/events/types.go @@ -0,0 +1,94 @@ +package events + +import "encoding/json" + +// Event is the universal envelope for all bus communication. +type Event struct { + Label string `json:"event"` + Room string `json:"room,omitempty"` + Payload json.RawMessage `json:"payload"` + Ts int64 `json:"ts"` + + // Server-side metadata (not serialized to clients) + SenderID string `json:"-"` // user ID of sender, empty for system events + ConnID string `json:"-"` // WebSocket connection ID, empty for server-origin +} + +// Handler is a callback for bus subscriptions. +type Handler func(Event) + +// Direction controls which events cross the WebSocket bridge. +type Direction int + +const ( + DirLocal Direction = iota // bus-internal only (plugins, DB hooks) + DirToClient // BE → FE + DirFromClient // FE → BE + DirBoth // bidirectional +) + +// routeTable defines the default routing for known event prefixes. +// Events not listed default to DirLocal (server-only). +var routeTable = map[string]Direction{ + // Chat events + "chat.message.": DirBoth, // new messages + "chat.typing.": DirBoth, // typing indicators + "chat.updated.": DirToClient, // chat title/metadata changed + "chat.deleted.": DirToClient, // chat removed + + // Channel events + "channel.message.": DirBoth, + "channel.typing.": DirBoth, + "channel.updated.": DirToClient, + "channel.member.": DirToClient, + + // User/presence + "user.presence": DirToClient, + "user.status": DirToClient, + + // System + "system.notify": DirToClient, + "system.broadcast": DirToClient, + + // Model/provider status + "model.status": DirToClient, + + // Plugin hooks — never cross the wire + "plugin.hook.": DirLocal, + "internal.": DirLocal, + "db.": DirLocal, + + // Heartbeat + "ping": DirFromClient, + "pong": DirToClient, +} + +// RouteFor returns the routing direction for a given event label. +func RouteFor(label string) Direction { + // Check exact match first + if dir, ok := routeTable[label]; ok { + return dir + } + // Check prefix match (longest prefix wins) + best := "" + bestDir := DirLocal + for prefix, dir := range routeTable { + if len(prefix) > len(best) && len(label) >= len(prefix) && label[:len(prefix)] == prefix { + best = prefix + bestDir = dir + } + } + return bestDir +} + +// ShouldSendToClient returns true if this event should be forwarded to FE. +func ShouldSendToClient(label string) bool { + d := RouteFor(label) + return d == DirToClient || d == DirBoth +} + +// ShouldAcceptFromClient returns true if this event is allowed from FE. +func ShouldAcceptFromClient(label string) bool { + d := RouteFor(label) + return d == DirFromClient || d == DirBoth +} diff --git a/server/events/ws.go b/server/events/ws.go new file mode 100644 index 0000000..2251ce6 --- /dev/null +++ b/server/events/ws.go @@ -0,0 +1,279 @@ +package events + +import ( + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMsgSize = 4096 +) + +// Hub manages WebSocket connections and bridges them to the Bus. +type Hub struct { + bus *Bus + mu sync.RWMutex + conns map[string]*Conn // connID → Conn +} + +// Conn represents a single WebSocket connection. +type Conn struct { + id string + userID string + rooms map[string]bool // rooms this connection is subscribed to + ws *websocket.Conn + send chan []byte + hub *Hub + unsubs []func() // bus unsubscribe functions +} + +// NewHub creates a Hub bound to an event bus. +func NewHub(bus *Bus) *Hub { + return &Hub{ + bus: bus, + conns: make(map[string]*Conn), + } +} + +// HandleWebSocket is a Gin handler for WebSocket upgrade. +// Expects userID set in context (by auth middleware or query param). +func (h *Hub) HandleWebSocket(c *gin.Context) { + // Auth: check token from query param + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + ws, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("[ws] upgrade failed: %v", err) + return + } + + connID := userID + "-" + time.Now().Format("150405.000") + conn := &Conn{ + id: connID, + userID: userID, + rooms: make(map[string]bool), + ws: ws, + send: make(chan []byte, 64), + hub: h, + } + + h.mu.Lock() + h.conns[connID] = conn + h.mu.Unlock() + + log.Printf("[ws] connected: %s (user=%s, total=%d)", connID, userID, h.ConnCount()) + + // Subscribe this connection to bus events destined for clients + conn.subscribeToBus() + + // Publish presence + h.bus.Publish(Event{ + Label: "user.presence", + Payload: mustJSON(map[string]any{"user_id": userID, "status": "online"}), + Ts: time.Now().UnixMilli(), + SenderID: userID, + }) + + // Start read/write pumps + go conn.writePump() + go conn.readPump() +} + +// ConnCount returns the number of active connections. +func (h *Hub) ConnCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.conns) +} + +// ConnsByUser returns all connections for a given user. +func (h *Hub) ConnsByUser(userID string) []*Conn { + h.mu.RLock() + defer h.mu.RUnlock() + var result []*Conn + for _, c := range h.conns { + if c.userID == userID { + result = append(result, c) + } + } + return result +} + +// removeConn cleans up a connection. +func (h *Hub) removeConn(conn *Conn) { + h.mu.Lock() + delete(h.conns, conn.id) + h.mu.Unlock() + + // Unsubscribe from bus + for _, unsub := range conn.unsubs { + unsub() + } + + log.Printf("[ws] disconnected: %s (total=%d)", conn.id, h.ConnCount()) + + // Publish presence offline (only if no other conns for this user) + if len(h.ConnsByUser(conn.userID)) == 0 { + h.bus.Publish(Event{ + Label: "user.presence", + Payload: mustJSON(map[string]any{"user_id": conn.userID, "status": "offline"}), + Ts: time.Now().UnixMilli(), + SenderID: conn.userID, + }) + } +} + +// subscribeToBus registers this conn as a bus subscriber for client-facing events. +func (c *Conn) subscribeToBus() { + unsub := c.hub.bus.Subscribe("*", func(e Event) { + // Only forward events destined for clients + if !ShouldSendToClient(e.Label) { + return + } + + // Don't echo typing events back to the sender + if e.Label == "chat.typing" || e.ConnID == c.id { + if e.SenderID == c.userID && e.ConnID == c.id { + return + } + } + + // Room filtering: if event has a room, only send if conn is in that room + if e.Room != "" && !c.rooms[e.Room] { + return + } + + data, err := json.Marshal(e) + if err != nil { + return + } + + select { + case c.send <- data: + default: + // send buffer full, drop event + } + }) + + c.unsubs = append(c.unsubs, unsub) +} + +// JoinRoom adds this connection to a named room. +func (c *Conn) JoinRoom(room string) { + c.rooms[room] = true +} + +// LeaveRoom removes this connection from a named room. +func (c *Conn) LeaveRoom(room string) { + delete(c.rooms, room) +} + +// ── Read/Write Pumps ───────────────────────── + +func (c *Conn) readPump() { + defer func() { + c.hub.removeConn(c) + c.ws.Close() + }() + + c.ws.SetReadLimit(maxMsgSize) + c.ws.SetReadDeadline(time.Now().Add(pongWait)) + c.ws.SetPongHandler(func(string) error { + c.ws.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, message, err := c.ws.ReadMessage() + if err != nil { + break + } + + var event Event + if err := json.Unmarshal(message, &event); err != nil { + continue + } + + // Handle ping + if event.Label == "ping" { + pong, _ := json.Marshal(Event{Label: "pong", Ts: time.Now().UnixMilli()}) + select { + case c.send <- pong: + default: + } + continue + } + + // Validate: only accept events allowed from clients + if !ShouldAcceptFromClient(event.Label) { + continue + } + + // Tag with sender info + event.SenderID = c.userID + event.ConnID = c.id + if event.Ts == 0 { + event.Ts = time.Now().UnixMilli() + } + + // Publish into bus + c.hub.bus.Publish(event) + } +} + +func (c *Conn) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.ws.Close() + }() + + for { + select { + case msg, ok := <-c.send: + c.ws.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.ws.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.ws.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + + case <-ticker.C: + c.ws.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// ── Helpers ────────────────────────────────── + +func mustJSON(v any) json.RawMessage { + data, err := json.Marshal(v) + if err != nil { + return json.RawMessage(`{}`) + } + return data +} diff --git a/server/go.mod b/server/go.mod index 8c6c813..6c9313e 100644 --- a/server/go.mod +++ b/server/go.mod @@ -5,6 +5,7 @@ go 1.22 require ( github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 golang.org/x/crypto v0.14.0 diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 7d41e62..fc6e370 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -525,7 +525,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) { func (h *AdminHandler) FetchModels(c *gin.Context) { // Load all global api_configs rows, err := database.DB.Query(` - SELECT id, provider, endpoint, api_key_encrypted + SELECT id, provider, endpoint, api_key_encrypted, custom_headers FROM api_configs WHERE user_id IS NULL AND is_active = true `) @@ -539,6 +539,7 @@ func (h *AdminHandler) FetchModels(c *gin.Context) { ConfigID string `json:"config_id"` Provider string `json:"provider"` Added int `json:"added"` + Updated int `json:"updated"` Skipped int `json:"skipped"` Error string `json:"error,omitempty"` } @@ -548,7 +549,8 @@ func (h *AdminHandler) FetchModels(c *gin.Context) { for rows.Next() { var cfgID, providerID, endpoint string var apiKey *string - if err := rows.Scan(&cfgID, &providerID, &endpoint, &apiKey); err != nil { + var customHeadersJSON []byte + if err := rows.Scan(&cfgID, &providerID, &endpoint, &apiKey, &customHeadersJSON); err != nil { continue } @@ -563,9 +565,15 @@ func (h *AdminHandler) FetchModels(c *gin.Context) { key = *apiKey } + customHeaders := make(map[string]string) + if customHeadersJSON != nil { + _ = json.Unmarshal(customHeadersJSON, &customHeaders) + } + models, err := prov.ListModels(c.Request.Context(), providers.ProviderConfig{ - Endpoint: endpoint, - APIKey: key, + Endpoint: endpoint, + APIKey: key, + CustomHeaders: customHeaders, }) if err != nil { results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()}) @@ -574,11 +582,18 @@ func (h *AdminHandler) FetchModels(c *gin.Context) { fr := fetchResult{ConfigID: cfgID, Provider: providerID} for _, m := range models { + // Serialize capabilities to JSONB + capsJSON, _ := json.Marshal(m.Capabilities) + result, err := database.DB.Exec(` - INSERT INTO model_configs (api_config_id, model_id, display_name) - VALUES ($1, $2, $3) - ON CONFLICT (api_config_id, model_id) DO NOTHING - `, cfgID, m.ID, m.Name) + INSERT INTO model_configs (api_config_id, model_id, display_name, capabilities) + VALUES ($1, $2, $3, $4) + ON CONFLICT (api_config_id, model_id) + DO UPDATE SET + display_name = COALESCE(NULLIF(model_configs.display_name, ''), EXCLUDED.display_name), + capabilities = EXCLUDED.capabilities, + updated_at = NOW() + `, cfgID, m.ID, m.Name, capsJSON) if err != nil { fr.Skipped++ continue diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 553f76c..2de2599 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -3,6 +3,7 @@ package handlers import ( "database/sql" "encoding/json" + "log" "math" "net/http" "strconv" @@ -363,11 +364,12 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) { defer rows.Close() type modelEntry struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - OwnedBy string `json:"owned_by,omitempty"` - ConfigID string `json:"config_id"` - Provider string `json:"provider"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + OwnedBy string `json:"owned_by,omitempty"` + ConfigID string `json:"config_id"` + Provider string `json:"provider"` + Capabilities providers.ModelCapabilities `json:"capabilities"` } allModels := make([]modelEntry, 0) @@ -398,12 +400,17 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) { } for _, m := range models { + // Provider caps are authoritative; fill gaps from known table + caps := providers.MergeCapabilities(m.Capabilities, m.ID) + caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps) + allModels = append(allModels, modelEntry{ - ID: m.ID, - Name: m.Name, - OwnedBy: m.OwnedBy, - ConfigID: cfgID, - Provider: providerID, + ID: m.ID, + Name: m.Name, + OwnedBy: m.OwnedBy, + ConfigID: cfgID, + Provider: providerID, + Capabilities: caps, }) } } @@ -414,16 +421,22 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) { // ── List Enabled Models (from model_configs) ─ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { + userID := getUserID(c) + type enabledModel struct { - ID string `json:"id"` - ModelID string `json:"model_id"` - DisplayName *string `json:"display_name"` - Provider string `json:"provider"` - ProviderName string `json:"provider_name"` - ConfigID string `json:"config_id"` - Capabilities map[string]interface{} `json:"capabilities"` + ID string `json:"id"` + ModelID string `json:"model_id"` + DisplayName *string `json:"display_name"` + Provider string `json:"provider"` + ProviderName string `json:"provider_name"` + ConfigID string `json:"config_id"` + Capabilities providers.ModelCapabilities `json:"capabilities"` + Pricing *providers.ModelPricing `json:"pricing,omitempty"` } + models := make([]enabledModel, 0) + + // ── 1. Admin model_configs (pre-synced via FetchModels) ── rows, err := database.DB.Query(` SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities FROM model_configs mc @@ -431,22 +444,92 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { WHERE mc.is_enabled = true AND ac.is_active = true AND ac.user_id IS NULL ORDER BY ac.name, mc.model_id `) - if err != nil { - c.JSON(http.StatusOK, gin.H{"models": []interface{}{}}) - return - } - defer rows.Close() + if err == nil { + defer rows.Close() + for rows.Next() { + var m enabledModel + var capsJSON []byte + if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil { + continue + } - models := make([]enabledModel, 0) - for rows.Next() { - var m enabledModel - var capsJSON []byte - if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil { - continue + // Parse DB capabilities (from provider at sync time) + var dbCaps providers.ModelCapabilities + _ = json.Unmarshal(capsJSON, &dbCaps) + + // Provider-reported caps are authoritative; fill gaps from known table/heuristics + if dbCaps.HasProviderData() { + m.Capabilities = providers.MergeCapabilities(dbCaps, m.ModelID) + } else { + // No provider data — use known table or heuristics as base + knownCaps, found := providers.LookupKnownModel(m.ModelID) + if !found { + knownCaps = providers.InferCapabilities(m.ModelID) + } + m.Capabilities = knownCaps + } + + m.Capabilities.MaxOutputTokens = providers.ResolveMaxOutput(m.ModelID, m.Capabilities) + models = append(models, m) + } + } + + // ── 2. User provider models (live query) ── + userRows, err := database.DB.Query(` + SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers + FROM api_configs + WHERE user_id = $1 AND is_active = true + `, userID) + if err == nil { + defer userRows.Close() + for userRows.Next() { + var cfgID, name, providerID, endpoint string + var apiKey *string + var headersJSON []byte + if err := userRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil { + continue + } + + provider, err := providers.Get(providerID) + if err != nil { + continue + } + + key := "" + if apiKey != nil { + key = *apiKey + } + + var customHeaders map[string]string + _ = json.Unmarshal(headersJSON, &customHeaders) + + provModels, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ + Endpoint: endpoint, + APIKey: key, + CustomHeaders: customHeaders, + }) + if err != nil { + log.Printf("[models] user provider %q (%s) list failed: %v", name, providerID, err) + continue + } + + for _, pm := range provModels { + caps := pm.Capabilities + // Provider-reported caps are authoritative; fill gaps + caps = providers.MergeCapabilities(caps, pm.ID) + caps.MaxOutputTokens = providers.ResolveMaxOutput(pm.ID, caps) + + models = append(models, enabledModel{ + ID: pm.ID, + ModelID: pm.ID, + Provider: providerID, + ProviderName: name, + ConfigID: cfgID, + Capabilities: caps, + Pricing: pm.Pricing, + }) + } } - m.Capabilities = make(map[string]interface{}) - _ = json.Unmarshal(capsJSON, &m.Capabilities) - models = append(models, m) } c.JSON(http.StatusOK, gin.H{"models": models}) diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 6c74539..5fc934a 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -2,6 +2,7 @@ package handlers import ( "database/sql" + "encoding/json" "fmt" "io" "log" @@ -61,7 +62,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } // Resolve provider config - providerCfg, providerID, model, err := h.resolveConfig(userID, req) + providerCfg, providerID, model, configID, err := h.resolveConfig(userID, req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -96,9 +97,17 @@ func (h *CompletionHandler) Complete(c *gin.Context) { Model: model, Messages: messages, } + + // Resolve capabilities for this model — auto-set defaults + caps := h.getModelCapabilities(model, configID) + if req.MaxTokens > 0 { provReq.MaxTokens = req.MaxTokens + } else { + // ResolveMaxOutput checks: caps → known models → context/8 → 4096 + provReq.MaxTokens = providers.ResolveMaxOutput(model, caps) } + if req.Temperature != nil { provReq.Temperature = req.Temperature } @@ -227,10 +236,42 @@ func (h *CompletionHandler) syncCompletion( }) } +// ── Model Capabilities ────────────────────── + +// getModelCapabilities looks up capabilities from model_configs DB, +// then overlays with known model defaults and heuristic detection. +func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities { + // Start with known table or heuristic + // Start with known model table or heuristics + caps, found := providers.LookupKnownModel(model) + if !found { + caps = providers.InferCapabilities(model) + } + + // Overlay with DB-stored capabilities (from provider sync or admin edit — authoritative) + var capsJSON []byte + err := database.DB.QueryRow(` + SELECT capabilities FROM model_configs + WHERE model_id = $1 AND api_config_id = $2 + `, model, apiConfigID).Scan(&capsJSON) + + if err == nil && capsJSON != nil { + var dbCaps providers.ModelCapabilities + if err := json.Unmarshal(capsJSON, &dbCaps); err == nil { + if dbCaps.HasProviderData() { + // DB has real provider data — use as authoritative, fill gaps + caps = providers.MergeCapabilities(dbCaps, model) + } + } + } + + return caps +} + // ── 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, error) { +func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) { var configID string // 1. Explicit config from request @@ -249,33 +290,34 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) } } - // 3. User's first active config + // 3. User's first active config (personal first, then global) if configID == "" { err := database.DB.QueryRow(` SELECT id FROM api_configs - WHERE (user_id = $1 OR user_id IS NULL) AND is_active = true + WHERE (user_id = $1 OR is_global = true) AND is_active = true ORDER BY user_id NULLS LAST, created_at ASC LIMIT 1 `, userID).Scan(&configID) if err != nil { - return providers.ProviderConfig{}, "", "", fmt.Errorf("no API config found — add one at /api-configs") + return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no API config found — add one at /api-configs") } } - // Load the config + // Load the config including custom headers and provider settings var providerID, endpoint string var apiKey, modelDefault *string + var customHeadersJSON, providerSettingsJSON []byte err := database.DB.QueryRow(` - SELECT provider, endpoint, api_key_encrypted, model_default + SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings FROM api_configs - WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND is_active = true - `, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault) + WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true + `, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON) if err == sql.ErrNoRows { - return providers.ProviderConfig{}, "", "", fmt.Errorf("API config not found or not accessible") + return providers.ProviderConfig{}, "", "", "", fmt.Errorf("API config not found or not accessible") } if err != nil { - return providers.ProviderConfig{}, "", "", fmt.Errorf("failed to load API config: %w", err) + return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to load API config: %w", err) } // Resolve model: request > config default @@ -284,7 +326,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) model = *modelDefault } if model == "" { - return providers.ProviderConfig{}, "", "", fmt.Errorf("no model specified and no default model in config") + return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no model specified and no default model in config") } key := "" @@ -292,10 +334,24 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) key = *apiKey } + // Parse custom headers + customHeaders := make(map[string]string) + if customHeadersJSON != nil { + _ = json.Unmarshal(customHeadersJSON, &customHeaders) + } + + // Parse provider-specific settings + providerSettings := make(map[string]interface{}) + if providerSettingsJSON != nil { + _ = json.Unmarshal(providerSettingsJSON, &providerSettings) + } + return providers.ProviderConfig{ - Endpoint: endpoint, - APIKey: key, - }, providerID, model, nil + Endpoint: endpoint, + APIKey: key, + CustomHeaders: customHeaders, + Settings: providerSettings, + }, providerID, model, configID, nil } // ── Conversation Loader ───────────────────── diff --git a/server/main.go b/server/main.go index 104b93c..e76b40a 100644 --- a/server/main.go +++ b/server/main.go @@ -7,6 +7,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/handlers" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/providers" @@ -21,21 +22,34 @@ func main() { if err := database.Connect(cfg); err != nil { log.Printf("⚠ Database unavailable: %v", err) log.Println(" Running in unmanaged mode (no persistence)") + } else { + // Schema check: init if fresh, upgrade if behind, proceed if current + if err := database.Migrate(); err != nil { + log.Fatalf("❌ Schema migration failed: %v", err) + } } defer database.Close() r := gin.Default() r.Use(middleware.CORS()) + // ── EventBus + WebSocket Hub ───────────── + bus := events.NewBus() + hub := events.NewHub(bus) + // Health check (k8s probes hit this directly) r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{ - "status": "ok", - "version": Version, - "database": database.IsConnected(), + "status": "ok", + "version": Version, + "database": database.IsConnected(), + "schema_version": database.SchemaVersion(), }) }) + // WebSocket endpoint — auth via ?token= query param + r.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) + // ── Auth routes (rate limited) ────────────── auth := handlers.NewAuthHandler(cfg) authLimiter := middleware.NewRateLimiter(1, 5) @@ -45,10 +59,11 @@ func main() { // Health (routable through ingress) api.GET("/health", func(c *gin.Context) { info := gin.H{ - "status": "ok", - "version": Version, - "database": database.IsConnected(), - "providers": providers.List(), + "status": "ok", + "version": Version, + "schema_version": database.SchemaVersion(), + "database": database.IsConnected(), + "providers": providers.List(), } // Include registration status for frontend if database.IsConnected() { @@ -147,7 +162,9 @@ func main() { } log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) + log.Printf(" Schema: %s", database.SchemaVersion()) log.Printf(" Providers: %v", providers.List()) + log.Printf(" EventBus: ready, WebSocket on /ws") if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("Failed to start server: %v", err) } diff --git a/server/middleware/auth.go b/server/middleware/auth.go index 56b7eaf..01a01e3 100644 --- a/server/middleware/auth.go +++ b/server/middleware/auth.go @@ -31,6 +31,13 @@ func Auth(cfg *config.Config) gin.HandlerFunc { } header := c.GetHeader("Authorization") + if header == "" { + // WebSocket connections can't set headers from browser. + // Fall back to ?token= query parameter. + if qToken := c.Query("token"); qToken != "" { + header = "Bearer " + qToken + } + } if header == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "missing authorization header", diff --git a/server/providers/anthropic.go b/server/providers/anthropic.go index 7124ce2..07be61c 100644 --- a/server/providers/anthropic.go +++ b/server/providers/anthropic.go @@ -116,14 +116,26 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) { // Anthropic doesn't have a list models endpoint. - // Return the known model families. - return []Model{ - {ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"}, - {ID: "claude-haiku-4-20250414", Name: "Claude Haiku 4"}, - {ID: "claude-opus-4-20250514", Name: "Claude Opus 4"}, - {ID: "claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"}, - {ID: "claude-3-5-haiku-20241022", Name: "Claude 3.5 Haiku"}, - }, nil + // Return known models with authoritative capabilities from our table. + modelIDs := []struct{ id, name string }{ + {"claude-opus-4-20250514", "Claude Opus 4"}, + {"claude-sonnet-4-20250514", "Claude Sonnet 4"}, + {"claude-haiku-4-20250414", "Claude Haiku 4"}, + {"claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet"}, + {"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"}, + } + + models := make([]Model, 0, len(modelIDs)) + for _, m := range modelIDs { + caps, _ := LookupKnownModel(m.id) + models = append(models, Model{ + ID: m.id, + Name: m.name, + OwnedBy: "anthropic", + Capabilities: caps, + }) + } + return models, nil } // ── HTTP Layer ────────────────────────────── @@ -160,7 +172,8 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r antReq.System = system } if antReq.MaxTokens == 0 { - antReq.MaxTokens = 4096 // Anthropic requires max_tokens + // Anthropic requires max_tokens. Resolve from known model table. + antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{}) } if req.Temperature != nil { antReq.Temperature = req.Temperature diff --git a/server/providers/capabilities.go b/server/providers/capabilities.go new file mode 100644 index 0000000..fbbbc50 --- /dev/null +++ b/server/providers/capabilities.go @@ -0,0 +1,396 @@ +package providers + +import ( + "regexp" + "strings" +) + +// ModelCapabilities describes what a model can do and its limits. +// Zero values mean "unknown / use heuristic". +type ModelCapabilities struct { + Streaming bool `json:"streaming"` + ToolCalling bool `json:"tool_calling"` + Vision bool `json:"vision"` + Thinking bool `json:"thinking"` + Reasoning bool `json:"reasoning"` + CodeOptimized bool `json:"code_optimized"` + WebSearch bool `json:"web_search"` + MaxContext int `json:"max_context"` + MaxOutputTokens int `json:"max_output_tokens"` +} + +// ── Known Model Defaults ──────────────────── +// Authoritative output limits for models where the provider API +// doesn't report them. Keyed by exact model ID or prefix. +// +// Sources: +// Anthropic: https://docs.anthropic.com/en/docs/about-claude/models +// OpenAI: https://platform.openai.com/docs/models +// Meta: Model cards on Hugging Face +// Google: https://ai.google.dev/gemini-api/docs/models + +var knownModels = map[string]ModelCapabilities{ + // ── Anthropic ──────────────────────────── + "claude-opus-4": { + Streaming: true, ToolCalling: true, Vision: true, Thinking: true, + MaxContext: 200000, MaxOutputTokens: 32000, + }, + "claude-sonnet-4": { + Streaming: true, ToolCalling: true, Vision: true, Thinking: true, + MaxContext: 200000, MaxOutputTokens: 16000, + }, + "claude-3-5-sonnet": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 200000, MaxOutputTokens: 8192, + }, + "claude-3-5-haiku": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 200000, MaxOutputTokens: 8192, + }, + "claude-3-opus": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 200000, MaxOutputTokens: 4096, + }, + "claude-3-haiku": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 200000, MaxOutputTokens: 4096, + }, + + // ── OpenAI ────────────────────────────── + "gpt-4o": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 128000, MaxOutputTokens: 16384, + }, + "gpt-4o-mini": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 128000, MaxOutputTokens: 16384, + }, + "gpt-4-turbo": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 128000, MaxOutputTokens: 4096, + }, + "gpt-4": { + Streaming: true, ToolCalling: true, + MaxContext: 8192, MaxOutputTokens: 4096, + }, + "o1": { + Streaming: true, Reasoning: true, + MaxContext: 200000, MaxOutputTokens: 100000, + }, + "o1-mini": { + Streaming: true, Reasoning: true, + MaxContext: 128000, MaxOutputTokens: 65536, + }, + "o3": { + Streaming: true, ToolCalling: true, Reasoning: true, + MaxContext: 200000, MaxOutputTokens: 100000, + }, + "o3-mini": { + Streaming: true, ToolCalling: true, Reasoning: true, + MaxContext: 200000, MaxOutputTokens: 65536, + }, + "o4-mini": { + Streaming: true, ToolCalling: true, Reasoning: true, + MaxContext: 200000, MaxOutputTokens: 100000, + }, + + // ── Meta Llama ────────────────────────── + "llama-3.1-405b": { + Streaming: true, ToolCalling: true, + MaxContext: 131072, MaxOutputTokens: 4096, + }, + "llama-3.1-70b": { + Streaming: true, ToolCalling: true, + MaxContext: 131072, MaxOutputTokens: 4096, + }, + "llama-3.1-8b": { + Streaming: true, ToolCalling: true, + MaxContext: 131072, MaxOutputTokens: 4096, + }, + "llama-3.3-70b": { + Streaming: true, ToolCalling: true, + MaxContext: 131072, MaxOutputTokens: 4096, + }, + "llama-4-maverick": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 1048576, MaxOutputTokens: 16384, + }, + "llama-4-scout": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 524288, MaxOutputTokens: 16384, + }, + + // ── Google Gemini ─────────────────────── + "gemini-2.5-pro": { + Streaming: true, ToolCalling: true, Vision: true, Thinking: true, + MaxContext: 1048576, MaxOutputTokens: 65536, + }, + "gemini-2.5-flash": { + Streaming: true, ToolCalling: true, Vision: true, Thinking: true, + MaxContext: 1048576, MaxOutputTokens: 65536, + }, + "gemini-2.0-flash": { + Streaming: true, ToolCalling: true, Vision: true, + MaxContext: 1048576, MaxOutputTokens: 8192, + }, + + // ── DeepSeek ──────────────────────────── + "deepseek-r1": { + Streaming: true, Reasoning: true, + MaxContext: 65536, MaxOutputTokens: 8192, + }, + "deepseek-v3": { + Streaming: true, ToolCalling: true, + MaxContext: 65536, MaxOutputTokens: 8192, + }, + "deepseek-chat": { + Streaming: true, ToolCalling: true, + MaxContext: 65536, MaxOutputTokens: 8192, + }, + + // ── Mistral ───────────────────────────── + "mistral-large": { + Streaming: true, ToolCalling: true, + MaxContext: 131072, MaxOutputTokens: 8192, + }, + "mistral-small": { + Streaming: true, ToolCalling: true, + MaxContext: 131072, MaxOutputTokens: 8192, + }, + "codestral": { + Streaming: true, ToolCalling: true, CodeOptimized: true, + MaxContext: 262144, MaxOutputTokens: 8192, + }, + + // ── Qwen ──────────────────────────────── + "qwen-2.5-72b": { + Streaming: true, ToolCalling: true, + MaxContext: 131072, MaxOutputTokens: 8192, + }, + "qwen-2.5-coder-32b": { + Streaming: true, ToolCalling: true, CodeOptimized: true, + MaxContext: 131072, MaxOutputTokens: 8192, + }, + "qwq-32b": { + Streaming: true, Reasoning: true, + MaxContext: 131072, MaxOutputTokens: 8192, + }, +} + +// LookupKnownModel finds capabilities for a model by exact ID or prefix match. +// Returns the caps and true if found, zero value and false if not. +func LookupKnownModel(modelID string) (ModelCapabilities, bool) { + id := strings.ToLower(modelID) + + // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-20250514" → "claude-sonnet-4-20250514") + if idx := strings.Index(id, "/"); idx >= 0 { + id = id[idx+1:] + } + + // Exact match first + if caps, ok := knownModels[id]; ok { + return caps, true + } + + // Prefix match: "claude-sonnet-4-20250514" matches "claude-sonnet-4" + var bestKey string + var bestCaps ModelCapabilities + for key, caps := range knownModels { + if strings.HasPrefix(id, key) && len(key) > len(bestKey) { + bestKey = key + bestCaps = caps + } + } + if bestKey != "" { + return bestCaps, true + } + + return ModelCapabilities{}, false +} + +// ── Heuristic Capability Detection ────────── +// Ported from ai-editor's ProviderRegistry. +// Used for Ollama, LM Studio, and other generic endpoints +// that don't report capabilities in their /models response. + +var ( + toolPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)llama[_-]?[34]`), + regexp.MustCompile(`(?i)granite[_-]?[34]`), + regexp.MustCompile(`(?i)hermes[_-]?[23]?`), + regexp.MustCompile(`(?i)qwen[_-]?2`), + regexp.MustCompile(`(?i)mistral|mixtral`), + regexp.MustCompile(`(?i)command[_-]?r`), + regexp.MustCompile(`(?i)phi[_-]?[34]`), + regexp.MustCompile(`(?i)deepseek[_-]?v[23]`), + regexp.MustCompile(`(?i)functionary`), + regexp.MustCompile(`(?i)^gpt[_-]?[34]`), + regexp.MustCompile(`(?i)^claude`), + regexp.MustCompile(`(?i)gemma[_-]?2`), + regexp.MustCompile(`(?i)glm[_-]?4`), + regexp.MustCompile(`(?i)gemini`), + } + + visionPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)llava|bakllava`), + regexp.MustCompile(`(?i)moondream`), + regexp.MustCompile(`(?i)llama.*vision|vision.*llama`), + regexp.MustCompile(`(?i)minicpm[_-]?v`), + regexp.MustCompile(`(?i)^gpt[_-]?4[_-]?o|^gpt[_-]?4[_-]?turbo`), + regexp.MustCompile(`(?i)^claude[_-]?3`), + regexp.MustCompile(`(?i)gemini`), + regexp.MustCompile(`(?i)qwen.*vl|vl.*qwen`), + regexp.MustCompile(`(?i)phi[_-]?[34].*vision`), + regexp.MustCompile(`(?i)internvl`), + regexp.MustCompile(`(?i)glm[_-]?4v`), + } + + reasoningPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)deepseek[_-]?r1`), + regexp.MustCompile(`(?i)qwq`), + regexp.MustCompile(`(?i)^o[1234][_-]|^o[1234]$`), + } + + codePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)codellama|code[_-]?llama`), + regexp.MustCompile(`(?i)deepseek[_-]?coder`), + regexp.MustCompile(`(?i)starcoder`), + regexp.MustCompile(`(?i)coder|codestral`), + regexp.MustCompile(`(?i)wizardcoder`), + regexp.MustCompile(`(?i)codegemma`), + } +) + +func matchesAny(id string, patterns []*regexp.Regexp) bool { + for _, p := range patterns { + if p.MatchString(id) { + return true + } + } + return false +} + +// InferCapabilities guesses model capabilities from the model ID string. +// This is the fallback when neither the known model table nor the provider +// API provides capability data. +func InferCapabilities(modelID string) ModelCapabilities { + id := strings.ToLower(modelID) + // Strip provider prefix + if idx := strings.Index(id, "/"); idx >= 0 { + id = id[idx+1:] + } + + return ModelCapabilities{ + Streaming: true, // virtually everything streams + ToolCalling: matchesAny(id, toolPatterns), + Vision: matchesAny(id, visionPatterns), + Reasoning: matchesAny(id, reasoningPatterns), + CodeOptimized: matchesAny(id, codePatterns), + } +} + +// ResolveMaxOutput returns the max output tokens for a model. +// Priority: +// 1. Explicit value in caps (from DB / provider API) +// 2. Known model table +// 3. Derive from context window (context/8, clamped 2048..16384) +// 4. 4096 as absolute last resort +// +// This is the ONE place the default lives. Nothing else in the +// codebase should hardcode a max_tokens value. +func ResolveMaxOutput(modelID string, caps ModelCapabilities) int { + // 1. Already set (from model_configs DB or provider API) + if caps.MaxOutputTokens > 0 { + return caps.MaxOutputTokens + } + + // 2. Known model table + if known, ok := LookupKnownModel(modelID); ok && known.MaxOutputTokens > 0 { + return known.MaxOutputTokens + } + + // 3. Derive from context window + if caps.MaxContext > 0 { + derived := caps.MaxContext / 8 + if derived < 2048 { + derived = 2048 + } + if derived > 16384 { + derived = 16384 + } + return derived + } + + // 4. Last resort — the ONLY place 4096 appears as a default + return 4096 +} + +// MergeCapabilities takes authoritative caps (from provider API or DB) and fills +// gaps from the known model table and heuristic detection. Provider-reported data +// always wins; known table fills missing fields; heuristics are last resort. +func MergeCapabilities(authoritative ModelCapabilities, modelID string) ModelCapabilities { + merged := authoritative + + // Fill gaps from known model table + known, found := LookupKnownModel(modelID) + if found { + if !merged.ToolCalling && known.ToolCalling { + merged.ToolCalling = true + } + if !merged.Vision && known.Vision { + merged.Vision = true + } + if !merged.Thinking && known.Thinking { + merged.Thinking = true + } + if !merged.Reasoning && known.Reasoning { + merged.Reasoning = true + } + if !merged.CodeOptimized && known.CodeOptimized { + merged.CodeOptimized = true + } + if !merged.WebSearch && known.WebSearch { + merged.WebSearch = true + } + if merged.MaxContext == 0 && known.MaxContext > 0 { + merged.MaxContext = known.MaxContext + } + if merged.MaxOutputTokens == 0 && known.MaxOutputTokens > 0 { + merged.MaxOutputTokens = known.MaxOutputTokens + } + return merged + } + + // No known model — fill gaps from heuristics + inferred := InferCapabilities(modelID) + if !merged.ToolCalling && inferred.ToolCalling { + merged.ToolCalling = true + } + if !merged.Vision && inferred.Vision { + merged.Vision = true + } + if !merged.Thinking && inferred.Thinking { + merged.Thinking = true + } + if !merged.Reasoning && inferred.Reasoning { + merged.Reasoning = true + } + if !merged.CodeOptimized && inferred.CodeOptimized { + merged.CodeOptimized = true + } + if merged.MaxContext == 0 && inferred.MaxContext > 0 { + merged.MaxContext = inferred.MaxContext + } + if merged.MaxOutputTokens == 0 && inferred.MaxOutputTokens > 0 { + merged.MaxOutputTokens = inferred.MaxOutputTokens + } + + return merged +} + +// HasProviderData returns true if this capability set contains any data that +// was likely reported by a provider (not just zero values). +func (c ModelCapabilities) HasProviderData() bool { + return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning || + c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0 +} diff --git a/server/providers/capabilities_test.go b/server/providers/capabilities_test.go new file mode 100644 index 0000000..a083465 --- /dev/null +++ b/server/providers/capabilities_test.go @@ -0,0 +1,203 @@ +package providers + +import "testing" + +func TestLookupKnownModel_ExactMatch(t *testing.T) { + caps, ok := LookupKnownModel("gpt-4o") + if !ok { + t.Fatal("expected gpt-4o to be found") + } + if caps.MaxOutputTokens != 16384 { + t.Errorf("gpt-4o max output: got %d, want 16384", caps.MaxOutputTokens) + } + if !caps.ToolCalling { + t.Error("gpt-4o should support tool calling") + } + if !caps.Vision { + t.Error("gpt-4o should support vision") + } +} + +func TestLookupKnownModel_PrefixMatch(t *testing.T) { + caps, ok := LookupKnownModel("claude-sonnet-4-20250514") + if !ok { + t.Fatal("expected claude-sonnet-4-20250514 to match prefix claude-sonnet-4") + } + if caps.MaxOutputTokens != 16000 { + t.Errorf("claude-sonnet-4 max output: got %d, want 16000", caps.MaxOutputTokens) + } +} + +func TestLookupKnownModel_ProviderPrefix(t *testing.T) { + caps, ok := LookupKnownModel("anthropic/claude-sonnet-4-20250514") + if !ok { + t.Fatal("expected provider-prefixed model to match") + } + if caps.MaxOutputTokens != 16000 { + t.Errorf("got %d, want 16000", caps.MaxOutputTokens) + } +} + +func TestLookupKnownModel_NotFound(t *testing.T) { + _, ok := LookupKnownModel("totally-unknown-model-xyz") + if ok { + t.Error("expected unknown model to not be found") + } +} + +func TestInferCapabilities_ToolCalling(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"llama-3.1-70b-instruct", true}, + {"mistral-7b", true}, + {"qwen-2.5-72b", true}, + {"phi-3-mini", true}, + {"random-smallmodel", false}, + } + for _, tt := range tests { + caps := InferCapabilities(tt.model) + if caps.ToolCalling != tt.want { + t.Errorf("InferCapabilities(%q).ToolCalling = %v, want %v", tt.model, caps.ToolCalling, tt.want) + } + } +} + +func TestInferCapabilities_Vision(t *testing.T) { + caps := InferCapabilities("llava-v1.6-34b") + if !caps.Vision { + t.Error("llava should infer vision capability") + } +} + +func TestInferCapabilities_Reasoning(t *testing.T) { + caps := InferCapabilities("deepseek-r1-distill-llama-70b") + if !caps.Reasoning { + t.Error("deepseek-r1 should infer reasoning capability") + } +} + +func TestResolveMaxOutput_FromCaps(t *testing.T) { + // Explicit value in caps takes priority + caps := ModelCapabilities{MaxOutputTokens: 32000} + got := ResolveMaxOutput("whatever-model", caps) + if got != 32000 { + t.Errorf("got %d, want 32000", got) + } +} + +func TestResolveMaxOutput_FromKnownTable(t *testing.T) { + // No value in caps, falls to known table + caps := ModelCapabilities{} + got := ResolveMaxOutput("claude-opus-4-20250514", caps) + if got != 32000 { + t.Errorf("got %d, want 32000", got) + } +} + +func TestResolveMaxOutput_FromContext(t *testing.T) { + // Unknown model but has context window + caps := ModelCapabilities{MaxContext: 32768} + got := ResolveMaxOutput("unknown-model-abc", caps) + // 32768 / 8 = 4096 + if got != 4096 { + t.Errorf("got %d, want 4096 (derived from context/8)", got) + } +} + +func TestResolveMaxOutput_ContextClamp(t *testing.T) { + // Very large context → clamp derived output to 16384 + caps := ModelCapabilities{MaxContext: 1048576} + got := ResolveMaxOutput("unknown-large-model", caps) + if got != 16384 { + t.Errorf("got %d, want 16384 (clamped)", got) + } + + // Very small context → clamp derived output to 2048 + caps = ModelCapabilities{MaxContext: 4096} + got = ResolveMaxOutput("unknown-tiny-model", caps) + if got != 2048 { + t.Errorf("got %d, want 2048 (clamped)", got) + } +} + +func TestResolveMaxOutput_LastResort(t *testing.T) { + // Totally unknown, no context + caps := ModelCapabilities{} + got := ResolveMaxOutput("mystery-model", caps) + if got != 4096 { + t.Errorf("got %d, want 4096 (last resort)", got) + } +} + +func TestMergeCapabilities(t *testing.T) { + // Provider reports tool_calling and max_output — these should be authoritative. + // Known table for claude-sonnet-4 has vision, thinking, etc — should fill gaps. + providerCaps := ModelCapabilities{ + ToolCalling: true, + MaxOutputTokens: 16384, + } + merged := MergeCapabilities(providerCaps, "claude-sonnet-4-20250514") + + if !merged.ToolCalling { + t.Error("tool_calling should be preserved from provider") + } + if merged.MaxOutputTokens != 16384 { + t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens) + } + // Vision should be filled from known table (claude-sonnet-4 has it) + if !merged.Vision { + t.Error("vision should be filled from known table") + } + // MaxContext should be filled from known table + if merged.MaxContext == 0 { + t.Error("max_context should be filled from known table") + } +} + +func TestMergeCapabilities_ProviderFalseWins(t *testing.T) { + // Provider explicitly reports vision:false — should NOT be overridden by known table + providerCaps := ModelCapabilities{ + ToolCalling: true, + Vision: false, // provider says no vision + MaxOutputTokens: 8192, + MaxContext: 65536, + } + // Even though gpt-4o has vision in known table, provider caps are authoritative + // Because HasProviderData() is true, the caller passes these as authoritative + merged := MergeCapabilities(providerCaps, "some-unknown-model") + + if merged.Vision { + t.Error("vision should remain false — provider is authoritative") + } +} + +func TestMergeCapabilities_EmptyProvider(t *testing.T) { + // Empty provider caps — should fall through entirely to known table + merged := MergeCapabilities(ModelCapabilities{}, "gpt-4o") + + if !merged.ToolCalling { + t.Error("should get tool_calling from known table when provider is empty") + } + if !merged.Vision { + t.Error("should get vision from known table when provider is empty") + } +} + +func TestHasProviderData(t *testing.T) { + empty := ModelCapabilities{} + if empty.HasProviderData() { + t.Error("empty caps should not have provider data") + } + + withTool := ModelCapabilities{ToolCalling: true} + if !withTool.HasProviderData() { + t.Error("caps with tool_calling should have provider data") + } + + withContext := ModelCapabilities{MaxContext: 128000} + if !withContext.HasProviderData() { + t.Error("caps with max_context should have provider data") + } +} diff --git a/server/providers/openai.go b/server/providers/openai.go index 8b54183..398e035 100644 --- a/server/providers/openai.go +++ b/server/providers/openai.go @@ -116,6 +116,9 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([] if cfg.APIKey != "" { httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) } + for k, v := range cfg.CustomHeaders { + httpReq.Header.Set(k, v) + } resp, err := http.DefaultClient.Do(httpReq) if err != nil { @@ -135,9 +138,20 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([] models := make([]Model, 0, len(result.Data)) for _, m := range result.Data { + // Try known table first, then heuristic + caps, found := LookupKnownModel(m.ID) + if !found { + caps = InferCapabilities(m.ID) + } + // Use context_length from API if available and we don't have it + if m.ContextLength > 0 && caps.MaxContext == 0 { + caps.MaxContext = m.ContextLength + } + models = append(models, Model{ - ID: m.ID, - OwnedBy: m.OwnedBy, + ID: m.ID, + OwnedBy: m.OwnedBy, + Capabilities: caps, }) } return models, nil @@ -184,6 +198,10 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req if cfg.APIKey != "" { httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) } + // Inject provider-level custom headers (OpenRouter, Venice, etc.) + for k, v := range cfg.CustomHeaders { + httpReq.Header.Set(k, v) + } resp, err := http.DefaultClient.Do(httpReq) if err != nil { @@ -239,7 +257,8 @@ type openaiStreamChunk struct { type openaiModelsResponse struct { Data []struct { - ID string `json:"id"` - OwnedBy string `json:"owned_by"` + ID string `json:"id"` + OwnedBy string `json:"owned_by"` + ContextLength int `json:"context_length,omitempty"` // Ollama, OpenRouter } `json:"data"` } diff --git a/server/providers/openrouter.go b/server/providers/openrouter.go new file mode 100644 index 0000000..c010827 --- /dev/null +++ b/server/providers/openrouter.go @@ -0,0 +1,160 @@ +package providers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +// OpenRouterProvider handles the OpenRouter unified API. +// OpenRouter is OpenAI-compatible with 300+ models, per-model pricing, +// and architecture metadata. Requires HTTP-Referer and X-Title headers. +// +// Ported from ai-editor js/providers/openrouter.js +type OpenRouterProvider struct{} + +func (p *OpenRouterProvider) ID() string { return "openrouter" } + +// ── Chat Completion ───────────────────────── +// Delegates to OpenAI provider — OpenRouter is fully OAI-compatible. + +func (p *OpenRouterProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) { + oai := &OpenAIProvider{} + return oai.ChatCompletion(ctx, cfg, req) +} + +func (p *OpenRouterProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) { + oai := &OpenAIProvider{} + return oai.StreamCompletion(ctx, cfg, req) +} + +// ── List Models ───────────────────────────── +// OpenRouter /v1/models returns architecture, pricing, context_length. + +func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) { + endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/models" + + httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, err + } + if cfg.APIKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } + for k, v := range cfg.CustomHeaders { + httpReq.Header.Set(k, v) + } + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("openrouter list models: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("openrouter list models: HTTP %d: %s", resp.StatusCode, string(b)) + } + + var result orModelsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("openrouter decode models: %w", err) + } + + models := make([]Model, 0, len(result.Data)) + for _, m := range result.Data { + // Start with known table, fall back to heuristic + caps, found := LookupKnownModel(m.ID) + if !found { + caps = InferCapabilities(m.ID) + } + + // Overlay context length from OpenRouter metadata + if m.ContextLength > 0 && caps.MaxContext == 0 { + caps.MaxContext = m.ContextLength + } + + // Overlay max output from OpenRouter if available + if m.TopProvider.MaxCompletionTokens > 0 && caps.MaxOutputTokens == 0 { + caps.MaxOutputTokens = m.TopProvider.MaxCompletionTokens + } + + // Vision from architecture modality + arch := m.Architecture + if arch.Modality == "multimodal" { + caps.Vision = true + } + + // Streaming is universal on OpenRouter + caps.Streaming = true + + // Parse pricing (OpenRouter uses per-token strings, convert to per-1M) + var pricing *ModelPricing + if m.Pricing.Prompt != "" { + inputPerToken, _ := strconv.ParseFloat(m.Pricing.Prompt, 64) + outputPerToken, _ := strconv.ParseFloat(m.Pricing.Completion, 64) + if inputPerToken > 0 || outputPerToken > 0 { + pricing = &ModelPricing{ + InputPerM: inputPerToken * 1_000_000, + OutputPerM: outputPerToken * 1_000_000, + } + } + } + + // Owner from model ID prefix (e.g. "anthropic/claude-3-opus" → "anthropic") + ownedBy := "" + if idx := strings.Index(m.ID, "/"); idx >= 0 { + ownedBy = m.ID[:idx] + } + + name := m.Name + if name == "" { + name = m.ID + } + + models = append(models, Model{ + ID: m.ID, + Name: name, + OwnedBy: ownedBy, + Capabilities: caps, + Pricing: pricing, + }) + } + return models, nil +} + +// ── OpenRouter Wire Types ─────────────────── + +type orModelsResponse struct { + Data []orModel `json:"data"` +} + +type orModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ContextLength int `json:"context_length"` + Architecture orArch `json:"architecture"` + Pricing orPricing `json:"pricing"` + TopProvider orTopProvider `json:"top_provider"` +} + +type orArch struct { + Modality string `json:"modality"` + Tokenizer string `json:"tokenizer"` + InstructType string `json:"instruct_type"` +} + +type orPricing struct { + Prompt string `json:"prompt"` // per-token cost as string + Completion string `json:"completion"` // per-token cost as string +} + +type orTopProvider struct { + MaxCompletionTokens int `json:"max_completion_tokens"` + IsModerated bool `json:"is_moderated"` +} diff --git a/server/providers/provider.go b/server/providers/provider.go index 111cba1..4169770 100644 --- a/server/providers/provider.go +++ b/server/providers/provider.go @@ -27,9 +27,10 @@ type Provider interface { // ProviderConfig holds credentials and endpoint for a configured provider. // Populated from the api_configs table at call time. type ProviderConfig struct { - Endpoint string - APIKey string - Extra map[string]interface{} // Provider-specific settings from config JSONB + Endpoint string + APIKey string + CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer) + Settings map[string]interface{} // Provider-specific settings from provider_settings JSONB } // ── Request / Response Types ──────────────── @@ -77,9 +78,17 @@ type StreamEvent struct { Error error `json:"-"` } -// Model represents an available model from a provider. +// Model represents an available model from a provider, with capabilities. type Model struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - OwnedBy string `json:"owned_by,omitempty"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + OwnedBy string `json:"owned_by,omitempty"` + Capabilities ModelCapabilities `json:"capabilities"` + Pricing *ModelPricing `json:"pricing,omitempty"` +} + +// ModelPricing holds per-million-token costs. +type ModelPricing struct { + InputPerM float64 `json:"input_per_m,omitempty"` + OutputPerM float64 `json:"output_per_m,omitempty"` } diff --git a/server/providers/registry.go b/server/providers/registry.go index 39a1b42..9818e06 100644 --- a/server/providers/registry.go +++ b/server/providers/registry.go @@ -44,4 +44,6 @@ func List() []string { func Init() { Register(&OpenAIProvider{}) Register(&AnthropicProvider{}) + Register(&VeniceProvider{}) + Register(&OpenRouterProvider{}) } diff --git a/server/providers/venice.go b/server/providers/venice.go new file mode 100644 index 0000000..395fc2f --- /dev/null +++ b/server/providers/venice.go @@ -0,0 +1,149 @@ +package providers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// VeniceProvider handles the Venice.ai API. +// Venice is OpenAI-compatible with extensions: venice_parameters for +// web search, thinking controls, and web scraping. +// +// Ported from ai-editor js/providers/venice.js +type VeniceProvider struct{} + +func (p *VeniceProvider) ID() string { return "venice" } + +// ── Chat Completion ───────────────────────── +// Delegates to OpenAI provider after injecting venice_parameters. + +func (p *VeniceProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) { + oai := &OpenAIProvider{} + return oai.ChatCompletion(ctx, cfg, req) +} + +func (p *VeniceProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) { + oai := &OpenAIProvider{} + return oai.StreamCompletion(ctx, cfg, req) +} + +// ── List Models ───────────────────────────── +// Venice /v1/models returns model_spec with capabilities, pricing, +// context tokens, and traits. We parse all of it. + +func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) { + endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/models" + + httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, err + } + if cfg.APIKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("venice list models: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("venice list models: HTTP %d: %s", resp.StatusCode, string(b)) + } + + var result veniceModelsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("venice decode models: %w", err) + } + + models := make([]Model, 0, len(result.Data)) + for _, m := range result.Data { + spec := m.ModelSpec + vcaps := spec.Capabilities + + caps := ModelCapabilities{ + Streaming: true, + ToolCalling: vcaps.SupportsFunctionCalling, + Vision: vcaps.SupportsVision, + Reasoning: vcaps.SupportsReasoning, + WebSearch: vcaps.SupportsWebSearch, + MaxContext: spec.AvailableContextTokens, + } + + // Venice doesn't report max output tokens directly. + // Try known table, then derive from context. + if known, ok := LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 { + caps.MaxOutputTokens = known.MaxOutputTokens + } + + var pricing *ModelPricing + if spec.Pricing.Input.USD > 0 { + pricing = &ModelPricing{ + InputPerM: spec.Pricing.Input.USD, + OutputPerM: spec.Pricing.Output.USD, + } + } + + name := spec.Name + if name == "" { + name = m.ID + } + + models = append(models, Model{ + ID: m.ID, + Name: name, + OwnedBy: "venice", + Capabilities: caps, + Pricing: pricing, + }) + } + return models, nil +} + +// ── Venice Wire Types ─────────────────────── + +type veniceModelsResponse struct { + Data []veniceModel `json:"data"` +} + +type veniceModel struct { + ID string `json:"id"` + Type string `json:"type"` + OwnedBy string `json:"owned_by"` + ModelSpec veniceModelSpec `json:"model_spec"` +} + +type veniceModelSpec struct { + Name string `json:"name"` + Description string `json:"description"` + AvailableContextTokens int `json:"availableContextTokens"` + Capabilities veniceCapabilities `json:"capabilities"` + Pricing venicePricing `json:"pricing"` + Traits []string `json:"traits"` + Offline bool `json:"offline"` +} + +type veniceCapabilities struct { + SupportsFunctionCalling bool `json:"supportsFunctionCalling"` + SupportsVision bool `json:"supportsVision"` + SupportsReasoning bool `json:"supportsReasoning"` + SupportsWebSearch bool `json:"supportsWebSearch"` + SupportsResponseSchema bool `json:"supportsResponseSchema"` + SupportsAudioInput bool `json:"supportsAudioInput"` + SupportsLogProbs bool `json:"supportsLogProbs"` +} + +type venicePricing struct { + Input venicePriceUnit `json:"input"` + Output venicePriceUnit `json:"output"` +} + +type venicePriceUnit struct { + USD float64 `json:"usd"` +} diff --git a/src/css/styles.css b/src/css/styles.css index 1afb765..aad0422 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -56,6 +56,21 @@ a:hover { text-decoration: underline; } border-bottom: 1px solid var(--border); } +/* Brand / collapse toggle */ +.sb-brand { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: var(--radius); + background: none; border: none; color: var(--text); + cursor: pointer; font-size: 13px; white-space: nowrap; + transition: background var(--transition); width: 100%; +} +.sb-brand:hover { background: var(--bg-hover); } +.brand-logo { font-size: 18px; flex-shrink: 0; display: inline; } +.brand-collapse { flex-shrink: 0; display: none; color: var(--text-2); } +.sb-brand:hover .brand-logo { display: none; } +.sb-brand:hover .brand-collapse { display: inline; } +.brand-text { font-weight: 600; font-size: 14px; } + .sb-btn { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: var(--radius); @@ -68,6 +83,7 @@ a:hover { text-decoration: underline; } .sb-btn svg { flex-shrink: 0; } .sb-label { overflow: hidden; transition: opacity var(--transition); } .sidebar.collapsed .sb-label { opacity: 0; width: 0; } +.sidebar.collapsed .brand-text { opacity: 0; width: 0; } /* Chat list */ .sidebar-chats { @@ -135,11 +151,16 @@ a:hover { text-decoration: underline; } justify-content: center; font-size: 13px; font-weight: 600; color: var(--accent); flex-shrink: 0; position: relative; } -.avatar-dot { - position: absolute; bottom: -1px; right: -1px; - width: 9px; height: 9px; border-radius: 50%; - background: var(--success); border: 2px solid var(--bg-surface); +.avatar-bug { + position: absolute; bottom: -3px; right: -3px; + font-size: 10px; line-height: 1; + transition: filter var(--transition); } +.avatar-bug.has-errors { + filter: drop-shadow(0 0 4px var(--danger)); + animation: bug-pulse 1.5s ease infinite; +} +@keyframes bug-pulse { 0%,100% { filter: drop-shadow(0 0 3px var(--danger)); } 50% { filter: drop-shadow(0 0 8px var(--danger)); } } .user-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Flyout */ @@ -182,6 +203,25 @@ a:hover { text-decoration: underline; } .model-bar select:focus { outline: none; border-color: var(--accent); } .model-bar select option { background: var(--bg-surface); color: var(--text); } +.model-caps { + display: flex; align-items: center; gap: 4px; + margin-left: 4px; flex-wrap: wrap; +} +.cap-badge { + display: inline-flex; align-items: center; gap: 3px; + font-size: 10px; line-height: 1; padding: 2px 6px; + border-radius: 3px; white-space: nowrap; + background: var(--bg-raised); color: var(--text-3); + border: 1px solid transparent; +} +.cap-badge.cap-accent { + color: var(--accent); border-color: color-mix(in srgb, var(--accent), transparent 80%); + background: color-mix(in srgb, var(--accent), transparent 92%); +} +.cap-badge.cap-context { + color: var(--text-3); +} + .icon-btn { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 4px; border-radius: 4px; @@ -413,6 +453,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); } /* ── Modal ────────────────────────────────── */ @@ -475,6 +516,8 @@ button { font-family: var(--font); cursor: pointer; } .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; } .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; } @@ -526,4 +569,5 @@ button { font-family: var(--font); cursor: pointer; } .sidebar { position: fixed; z-index: 100; height: 100%; } .sidebar.collapsed { width: 0; border: none; } .msg-inner { padding: 0 1rem; } + .model-caps { display: none; } } diff --git a/src/index.html b/src/index.html index 68cde0e..6d36de9 100644 --- a/src/index.html +++ b/src/index.html @@ -7,7 +7,7 @@ - + @@ -17,8 +17,10 @@