Changeset 0.5.0 (#35)
This commit is contained in:
@@ -1,16 +1,30 @@
|
|||||||
# .gitea/workflows/ci.yaml
|
# .gitea/workflows/ci.yaml
|
||||||
# ============================================
|
# ============================================
|
||||||
# Chat Switchboard - CI/CD Pipeline
|
# Chat Switchboard - CI/CD Pipeline (v0.5.4)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Cluster deployments use SEPARATE FE + BE images.
|
# 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:
|
# Deployment mapping:
|
||||||
# PR → FE + BE :dev → wipe/migrate → dev.DOMAIN
|
# PR → FE + BE :dev → dev.DOMAIN (DB wipe + fresh schema)
|
||||||
# Push to main → FE + BE :test → migrate → test.DOMAIN
|
# Push to main → FE + BE :test → test.DOMAIN (migrate only)
|
||||||
# Push to main → FE-only :lite → (no DB) → lite.DOMAIN
|
# Tag v* → FE + BE :latest → www.DOMAIN (migrate only)
|
||||||
# Tag v* → FE + BE :latest → migrate → www.DOMAIN
|
# → Unified :latest → Docker Hub (not deployed)
|
||||||
# → Unified :latest → Docker Hub only (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:
|
# Required Gitea Variables:
|
||||||
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
||||||
@@ -46,7 +60,6 @@ env:
|
|||||||
POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }}
|
POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }}
|
||||||
FE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-fe
|
FE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-fe
|
||||||
BE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-be
|
BE_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard-be
|
||||||
UNIFIED_IMAGE: ${{ vars.REGISTRY }}/xcaliber/switchboard
|
|
||||||
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }}
|
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -157,7 +170,10 @@ jobs:
|
|||||||
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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
|
- name: Bootstrap database
|
||||||
env:
|
env:
|
||||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||||
@@ -171,18 +187,114 @@ jobs:
|
|||||||
chmod +x scripts/db-bootstrap.sh
|
chmod +x scripts/db-bootstrap.sh
|
||||||
scripts/db-bootstrap.sh
|
scripts/db-bootstrap.sh
|
||||||
|
|
||||||
# ── Database Migration ───────────────────────
|
# ── Dev: Upgrade Test (dev only) ───────────
|
||||||
- name: Run migrations
|
# 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:
|
env:
|
||||||
PGHOST: ${{ env.POSTGRES_HOST }}
|
PGHOST: ${{ env.POSTGRES_HOST }}
|
||||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||||
PGUSER: ${{ secrets.POSTGRES_USER }}
|
PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||||
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
DB_NAME: ${{ steps.env.outputs.DB_NAME }}
|
PGDATABASE: ${{ steps.env.outputs.DB_NAME }}
|
||||||
DB_WIPE: ${{ steps.env.outputs.DB_WIPE }}
|
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/db-migrate.sh
|
echo "━━━ Upgrade test: applying migrations to existing dev DB ━━━"
|
||||||
scripts/db-migrate.sh
|
|
||||||
|
# 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 ──────────────────────
|
# ── Build Backend Image ──────────────────────
|
||||||
- name: Build backend image
|
- name: Build backend image
|
||||||
@@ -191,7 +303,7 @@ jobs:
|
|||||||
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
|
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
|
||||||
./server
|
./server
|
||||||
|
|
||||||
# ── Build Frontend Image (cluster mode) ──────
|
# ── Build Frontend Image ─────────────────────
|
||||||
- name: Build frontend image
|
- name: Build frontend image
|
||||||
run: |
|
run: |
|
||||||
docker build -f Dockerfile.frontend \
|
docker build -f Dockerfile.frontend \
|
||||||
@@ -215,27 +327,22 @@ jobs:
|
|||||||
docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
|
docker push ${FE_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Build + Push Unified (release only) ──────
|
# ── Build + Push Unified to Docker Hub (release only) ──
|
||||||
- name: Build unified image
|
- name: Build and push unified image
|
||||||
if: steps.env.outputs.is_release == 'true'
|
if: steps.env.outputs.is_release == 'true'
|
||||||
run: |
|
run: |
|
||||||
docker build -f Dockerfile.unified \
|
docker build -f Dockerfile \
|
||||||
-t ${UNIFIED_IMAGE}:latest \
|
-t ${DOCKERHUB_IMAGE}:latest \
|
||||||
-t ${UNIFIED_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \
|
-t ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }} \
|
||||||
.
|
.
|
||||||
docker push ${UNIFIED_IMAGE}:latest
|
if [[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then
|
||||||
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 }}" | \
|
echo "${{ secrets.DOCKERHUB_TOKEN }}" | \
|
||||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
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}:latest
|
||||||
docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
|
docker push ${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}
|
||||||
|
else
|
||||||
|
echo "⚠ Docker Hub credentials not configured, skipping push"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Deploy to Kubernetes ─────────────────────
|
# ── Deploy to Kubernetes ─────────────────────
|
||||||
- name: Ensure namespace
|
- name: Ensure namespace
|
||||||
@@ -278,10 +385,10 @@ jobs:
|
|||||||
kubectl apply -f /tmp/ingress.yaml
|
kubectl apply -f /tmp/ingress.yaml
|
||||||
|
|
||||||
- name: Rollout and verify
|
- name: Rollout and verify
|
||||||
|
env:
|
||||||
|
SUFFIX: ${{ steps.env.outputs.DEPLOY_SUFFIX }}
|
||||||
|
ENV: ${{ steps.env.outputs.ENVIRONMENT }}
|
||||||
run: |
|
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-be${SUFFIX} -n ${NAMESPACE}
|
||||||
kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE}
|
kubectl rollout restart deployment/switchboard-fe${SUFFIX} -n ${NAMESPACE}
|
||||||
|
|
||||||
@@ -303,6 +410,25 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Summary
|
||||||
run: |
|
run: |
|
||||||
HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
|
HOST="${{ steps.env.outputs.DEPLOY_HOST }}"
|
||||||
@@ -314,46 +440,7 @@ jobs:
|
|||||||
echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Backend** | \`${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then
|
if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then
|
||||||
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
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}"
|
|
||||||
|
|||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,11 +1,5 @@
|
|||||||
# Dependencies
|
# Vendor libs (downloaded at build time)
|
||||||
node_modules/
|
src/vendor/
|
||||||
vendor/
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
standalone/index.html
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
*.exe
|
*.exe
|
||||||
@@ -17,6 +11,9 @@ build/
|
|||||||
*.out
|
*.out
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
# Node (if any dev tooling)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -49,3 +46,7 @@ docker-compose.override.yml
|
|||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
secrets/
|
secrets/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|||||||
1349
ARCHITECTURE.md
Normal file
1349
ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,28 @@
|
|||||||
# Chat Switchboard - Frontend Dockerfile
|
# Chat Switchboard - Frontend Dockerfile
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Static SPA served by nginx. No API proxy —
|
# 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.
|
# the backend service directly.
|
||||||
# Also used for the lite (unmanaged) deployment.
|
#
|
||||||
|
# Build context: repo root
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
# Production stage
|
# Stage 1: Download vendor libs (vendor/ is gitignored)
|
||||||
FROM nginx:alpine
|
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
|
COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY src/ /usr/share/nginx/html/
|
||||||
# Built SPA files
|
COPY --from=vendor /vendor/ /usr/share/nginx/html/vendor/
|
||||||
COPY src /usr/share/nginx/html
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
636
EXTENSIONS.md
Normal file
636
EXTENSIONS.md
Normal file
@@ -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 `<script>` tags for all enabled browser extensions.
|
||||||
|
Load order respects declared dependencies.
|
||||||
|
|
||||||
|
**Init**: Each extension's entry script calls `Extensions.register()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
Extensions.register({
|
||||||
|
id: 'cost-tracker',
|
||||||
|
|
||||||
|
init(ctx) {
|
||||||
|
// ctx.events — scoped EventBus (only permitted events)
|
||||||
|
// ctx.storage — scoped localStorage wrapper
|
||||||
|
// ctx.settings — this extension's settings values
|
||||||
|
// ctx.ui — DOM injection points
|
||||||
|
this.ctx = ctx;
|
||||||
|
|
||||||
|
ctx.events.on('chat.message.send', (msg) => {
|
||||||
|
const est = this.estimateTokens(msg.content);
|
||||||
|
ctx.ui.inject('input-area', this.renderCost(est));
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.events.on('model.selected', ({ capabilities }) => {
|
||||||
|
this.pricing = this.lookupPricing(capabilities);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
// Cleanup: remove DOM elements, unsubscribe events
|
||||||
|
},
|
||||||
|
|
||||||
|
estimateTokens(text) { /* ... */ },
|
||||||
|
renderCost(est) { /* ... */ },
|
||||||
|
lookupPricing(caps) { /* ... */ }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Context Object
|
||||||
|
|
||||||
|
The `ctx` object is the extension's API surface. It's scoped — an
|
||||||
|
extension that didn't declare `dom:sidebar` in permissions gets a `ctx.ui`
|
||||||
|
that throws on `inject('sidebar', ...)`.
|
||||||
|
|
||||||
|
```
|
||||||
|
ctx.events — EventBus subscribe/publish (filtered by permissions)
|
||||||
|
ctx.storage — localStorage namespace (extensions::{id}::*)
|
||||||
|
ctx.settings — Read-only settings values from manifest
|
||||||
|
ctx.ui — DOM injection into declared surfaces
|
||||||
|
ctx.api — Proxied fetch() to backend (auth headers injected)
|
||||||
|
ctx.model — Current model ID + resolved capabilities
|
||||||
|
ctx.user — Current user info (id, username, role)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Admin-Pushed vs User-Installed
|
||||||
|
|
||||||
|
- **Admin-pushed**: `extensions` table row with `is_system = true`. Loaded
|
||||||
|
for all users. Users cannot disable. JS served from
|
||||||
|
`/api/v1/extensions/{id}/assets/`.
|
||||||
|
- **User-installed**: Stored in user settings JSONB. Users can enable/disable.
|
||||||
|
JS loaded from same asset path or inline (for small scripts).
|
||||||
|
- Both use the same runtime. The difference is governance, not execution.
|
||||||
|
|
||||||
|
### 4.4 Security Model
|
||||||
|
|
||||||
|
Browser extensions run in the same origin. They can't be truly sandboxed
|
||||||
|
without iframes (which breaks DOM injection). The security model is:
|
||||||
|
|
||||||
|
1. **Permission declaration** — extensions declare what they need; the
|
||||||
|
loader enforces it via the `ctx` proxy.
|
||||||
|
2. **Admin review** — admin-pushed extensions are implicitly trusted.
|
||||||
|
User-installed extensions get a "this extension can access: ..." prompt.
|
||||||
|
3. **Event scoping** — extensions only see events they declared in
|
||||||
|
`permissions`. The scoped EventBus filters unpermitted subscriptions.
|
||||||
|
4. **CSP headers** — strict Content-Security-Policy prevents inline script
|
||||||
|
injection. Extension scripts are served from known paths only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Browser-Defined Tools (The Bridge)
|
||||||
|
|
||||||
|
This is the critical innovation. ai-editor proved that LLM tools work in
|
||||||
|
browser JS. But in Chat Switchboard, the completion handler runs server-side.
|
||||||
|
The tool call originates from the LLM response, arrives at the Go backend,
|
||||||
|
and needs to execute in the user's browser.
|
||||||
|
|
||||||
|
### 5.1 The Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User sends message
|
||||||
|
→ Backend sends to LLM with tools[] from enabled extensions
|
||||||
|
→ LLM returns tool_call: { name: "read_file", args: {...} }
|
||||||
|
→ Backend sees tool_call, checks tool registry
|
||||||
|
→ Tool is tier:browser → Backend publishes via WebSocket:
|
||||||
|
event: tool.call.{callId}
|
||||||
|
data: { tool: "read_file", args: {...}, callId: "uuid" }
|
||||||
|
→ Browser extension receives event, executes tool
|
||||||
|
→ Extension publishes result via WebSocket:
|
||||||
|
event: tool.result.{callId}
|
||||||
|
data: { callId: "uuid", result: "file contents..." }
|
||||||
|
→ Backend receives result, feeds back to LLM as tool_result message
|
||||||
|
→ LLM continues with the result
|
||||||
|
→ Response streams to user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Tool Registration
|
||||||
|
|
||||||
|
Extensions declare tools in their manifest and implement them:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "estimate_cost",
|
||||||
|
"description": "Estimate the token cost of a given text",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": { "type": "string", "description": "Text to estimate" }
|
||||||
|
},
|
||||||
|
"required": ["text"]
|
||||||
|
},
|
||||||
|
"tier": "browser"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
Extensions.register({
|
||||||
|
id: 'cost-tracker',
|
||||||
|
init(ctx) {
|
||||||
|
ctx.tools.handle('estimate_cost', async (args) => {
|
||||||
|
const tokens = this.estimateTokens(args.text);
|
||||||
|
return { tokens, estimated_cost_usd: tokens * this.pricing.outputPerM / 1e6 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Server-Side Tool Router
|
||||||
|
|
||||||
|
The completion handler maintains a tool registry. When building the tools[]
|
||||||
|
array for the LLM request:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *CompletionHandler) collectTools(userID string) []Tool {
|
||||||
|
var tools []Tool
|
||||||
|
|
||||||
|
// Tier 1: Starlark tools (server-side, execute inline)
|
||||||
|
tools = append(tools, h.starlarkTools()...)
|
||||||
|
|
||||||
|
// Tier 2: Sidecar tools (server-side, HTTP call)
|
||||||
|
tools = append(tools, h.sidecarTools()...)
|
||||||
|
|
||||||
|
// Tier 0: Browser tools (client-side, routed via WebSocket)
|
||||||
|
// Only included if user has WebSocket connected
|
||||||
|
if hub.IsConnected(userID) {
|
||||||
|
tools = append(tools, h.browserTools(userID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When a tool_call arrives and the tool is `tier: browser`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
case "browser":
|
||||||
|
callId := uuid.New().String()
|
||||||
|
// Publish to user's WebSocket
|
||||||
|
bus.Publish(events.Event{
|
||||||
|
Type: "tool.call." + callId,
|
||||||
|
Data: toolCallData,
|
||||||
|
Room: "user:" + userID,
|
||||||
|
})
|
||||||
|
// Wait for result (with timeout)
|
||||||
|
result, err := bus.WaitFor("tool.result." + callId, 30*time.Second)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Timeout and Fallback
|
||||||
|
|
||||||
|
Browser tools have a 30-second timeout. If the user's browser disconnects
|
||||||
|
or the tool fails, the backend sends a tool_result with an error message
|
||||||
|
to the LLM so it can recover gracefully. This is no different from how
|
||||||
|
ai-editor handles tool failures — the LLM gets an error and adapts.
|
||||||
|
|
||||||
|
### 5.5 Why This Matters
|
||||||
|
|
||||||
|
This means an extension author can write a tool in 20 lines of JavaScript
|
||||||
|
that the LLM can call. No Go code. No container. No deployment. The
|
||||||
|
cluster manager extension defines `kubectl_get`, `ceph_status`,
|
||||||
|
`node_drain` as browser tools. The editor extension defines `read_file`,
|
||||||
|
`write_file`, `search_replace`. The article extension defines
|
||||||
|
`fetch_source`, `check_citation`.
|
||||||
|
|
||||||
|
The LLM doesn't know or care where the tool executes. It sees a tool
|
||||||
|
schema, calls it, gets a result. The bus handles the routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Surfaces (Modes)
|
||||||
|
|
||||||
|
A "mode" is an extension that registers a **surface** — a UI region that
|
||||||
|
replaces or augments the default chat area. The core app provides injection
|
||||||
|
points:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ sidebar-top │ surface-header │
|
||||||
|
│ │ │
|
||||||
|
│ sidebar-nav │ │
|
||||||
|
│ (mode selector) │ surface-main │
|
||||||
|
│ │ (chat, editor, article, │
|
||||||
|
│ sidebar-content │ cluster, ...) │
|
||||||
|
│ (context panel) │ │
|
||||||
|
│ │ │
|
||||||
|
│ sidebar-bottom │ surface-footer │
|
||||||
|
│ │ (input area) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 Surface Registration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"surfaces": [
|
||||||
|
{
|
||||||
|
"id": "editor",
|
||||||
|
"label": "Editor",
|
||||||
|
"icon": "code",
|
||||||
|
"regions": ["surface-main", "surface-footer", "sidebar-content"],
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
Extensions.register({
|
||||||
|
id: 'editor-mode',
|
||||||
|
init(ctx) {
|
||||||
|
ctx.surfaces.register('editor', {
|
||||||
|
activate() {
|
||||||
|
// Replace surface-main with editor UI
|
||||||
|
ctx.ui.replace('surface-main', this.renderEditor());
|
||||||
|
ctx.ui.replace('surface-footer', this.renderEditorInput());
|
||||||
|
ctx.ui.replace('sidebar-content', this.renderFileTree());
|
||||||
|
},
|
||||||
|
deactivate() {
|
||||||
|
// Restore defaults
|
||||||
|
ctx.ui.restore('surface-main');
|
||||||
|
ctx.ui.restore('surface-footer');
|
||||||
|
ctx.ui.restore('sidebar-content');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 The Core Surfaces
|
||||||
|
|
||||||
|
Chat mode is just the default surface. It's not special — it's the surface
|
||||||
|
that's active when no extension surface is selected. In theory, even chat
|
||||||
|
mode could be extracted into an extension, but pragmatically it stays in
|
||||||
|
core because everything depends on it.
|
||||||
|
|
||||||
|
### 6.3 Mode Selector
|
||||||
|
|
||||||
|
When extensions register surfaces, a mode selector appears in the sidebar
|
||||||
|
(below the brand, above chat history). Clicking a mode calls `activate()`
|
||||||
|
on that surface and `deactivate()` on the current one.
|
||||||
|
|
||||||
|
The bus event `surface.activated` fires so other extensions can react.
|
||||||
|
The cost tracker might show different metrics in editor mode vs chat mode.
|
||||||
|
|
||||||
|
### 6.4 Jeff's Planned Modes
|
||||||
|
|
||||||
|
**Chat Mode** (core — v0.5.x)
|
||||||
|
- What exists today, plus tool calling and richer message types.
|
||||||
|
- The "default surface" that everything else builds on.
|
||||||
|
|
||||||
|
**Editor Mode** (extension)
|
||||||
|
- Surfaces: file tree in sidebar, code editor in main, AI chat in a
|
||||||
|
split pane or overlay.
|
||||||
|
- Tools: `read_file`, `write_file`, `search_replace`, `run_command`,
|
||||||
|
`git_status`, `git_commit` — all browser tools backed by a git
|
||||||
|
provider API (GitHub, Gitea).
|
||||||
|
- This is ai-editor rebuilt properly: the tool definitions are JS,
|
||||||
|
the LLM calls happen through the standard completion handler, and
|
||||||
|
the file operations go through a git provider abstraction.
|
||||||
|
- The key difference from ai-editor: the completion handler is
|
||||||
|
server-side, so tool calls are logged, token-counted, and auditable.
|
||||||
|
|
||||||
|
**Article Mode** (extension)
|
||||||
|
- Surfaces: outline in sidebar, rich text editor in main, AI assistant
|
||||||
|
in a panel.
|
||||||
|
- Tools: `fetch_url`, `summarize_section`, `check_citation`,
|
||||||
|
`suggest_structure`.
|
||||||
|
- Think: a writing environment where the AI is a research assistant,
|
||||||
|
not a chatbot. The conversation is hidden; only the document matters.
|
||||||
|
|
||||||
|
**Cluster Manager Mode** (extension — or extensions plural)
|
||||||
|
- This one is interesting because it's actually 2-3 cooperating
|
||||||
|
extensions:
|
||||||
|
- `k8s-manager`: tools for `kubectl` operations, surfaces for
|
||||||
|
pod/deployment views.
|
||||||
|
- `ceph-manager`: tools for `ceph status`, OSD management, pool
|
||||||
|
operations.
|
||||||
|
- `node-manager`: tools for SSH commands, system metrics,
|
||||||
|
package management.
|
||||||
|
- These extensions talk to each other via the EventBus. When
|
||||||
|
`k8s-manager` detects a node is NotReady, it publishes
|
||||||
|
`cluster.node.unhealthy`. `node-manager` subscribes and surfaces
|
||||||
|
diagnostics. `ceph-manager` subscribes and checks if that node
|
||||||
|
had OSDs.
|
||||||
|
- The LLM sees ALL the tools from ALL active cluster extensions.
|
||||||
|
"Drain node-3, ensure Ceph rebalances, then cordon it" becomes
|
||||||
|
a multi-tool conversation where the LLM calls `kubectl_drain`,
|
||||||
|
then `ceph_osd_out`, then `kubectl_cordon` — each routed to the
|
||||||
|
appropriate extension.
|
||||||
|
- This is what you do manually with Claude Desktop today. The
|
||||||
|
extension system makes it one coherent interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Extension Loader Architecture
|
||||||
|
|
||||||
|
### 7.1 Load Order
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Core -->
|
||||||
|
<script src="js/events.js"></script>
|
||||||
|
<script src="js/extensions.js"></script> <!-- NEW: loader + registry -->
|
||||||
|
|
||||||
|
<!-- Extensions (injected by loader) -->
|
||||||
|
<script src="/api/v1/extensions/cost-tracker/assets/main.js"></script>
|
||||||
|
<script src="/api/v1/extensions/editor-mode/assets/main.js"></script>
|
||||||
|
|
||||||
|
<!-- App (runs after extensions registered) -->
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/ui.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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."
|
||||||
388
ISSUES.md
388
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**
|
## PR Checklist
|
||||||
- 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
|
|
||||||
|
|
||||||
### 2. **Create Branch**
|
- [ ] Code follows existing style
|
||||||
```bash
|
- [ ] Tested manually
|
||||||
git checkout develop
|
- [ ] No breaking API changes (or documented)
|
||||||
git pull origin develop
|
- [ ] Docs updated if user-facing
|
||||||
git checkout -b issue-XX-short-description
|
- [ ] Screenshots if UI changes
|
||||||
```
|
|
||||||
|
|
||||||
Branch naming: `issue-XX-short-kebab-case-description`
|
## Current Priorities
|
||||||
|
|
||||||
### 3. **Development**
|
1. WebSocket hub (blocks Channels)
|
||||||
- Keep commits focused and atomic
|
2. Channels backend + frontend
|
||||||
- Reference issue in commits: `Fix #XX: Description`
|
3. Plugin architecture design
|
||||||
- Update issue with progress comments
|
4. Notes & Knowledge Base
|
||||||
- Request help if blocked
|
|
||||||
|
|
||||||
### 4. **Create Pull Request**
|
See [ROADMAP.md](ROADMAP.md) for detail.
|
||||||
- **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
|
|
||||||
|
|||||||
416
README.md
416
README.md
@@ -1,360 +1,192 @@
|
|||||||
# 🔀 Chat Switchboard
|
# 🔀 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.
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://go.dev/)
|
[](https://go.dev/)
|
||||||
[](https://python.org/)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 What Makes Us Different
|
## What It Does
|
||||||
|
|
||||||
| Feature | Chat Switchboard | Others |
|
- **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.
|
||||||
| **Works Offline** | ✅ Full-featured unmanaged mode | ❌ Backend required |
|
- **Per-user API keys** — Each user manages their own provider keys. Admins can set global keys shared across the instance.
|
||||||
| **Plugin System** | ✅ Core features ARE plugins | 🟡 Limited or none |
|
- **Admin panel** — User management, global provider config, model visibility, registration toggle, usage stats.
|
||||||
| **Visual Workflows** | ✅ Chain multiple AI models | ❌ Single-shot only |
|
- **Self-hosted** — Single Docker image, PostgreSQL backend. No external dependencies at runtime.
|
||||||
| **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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Option 1: Offline Mode (No Backend)
|
### Docker (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and build
|
|
||||||
git clone https://git.gobha.me/xcaliber/chat-switchboard.git
|
git clone https://git.gobha.me/xcaliber/chat-switchboard.git
|
||||||
cd chat-switchboard
|
cd chat-switchboard
|
||||||
./build.sh
|
cp server/.env.example server/.env
|
||||||
|
# Edit .env: set DATABASE_URL, JWT_SECRET, etc.
|
||||||
# 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
|
|
||||||
docker-compose up -d
|
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
|
```bash
|
||||||
# Use template
|
# Backend
|
||||||
cp -r extensions/_template-python extensions/my-plugin
|
cd server
|
||||||
cd extensions/my-plugin
|
cp .env.example .env
|
||||||
# Edit extension.json, main.py
|
go build -o switchboard .
|
||||||
python main.py
|
./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
|
## Architecture
|
||||||
|
|
||||||
- **[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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────┐
|
┌──────────────────────────┐
|
||||||
│ Frontend (Vanilla JS) │
|
│ Frontend (Vanilla JS) │
|
||||||
│ - Works offline (LocalStorage) │
|
│ 4 files + vendor libs │
|
||||||
│ - Switches to backend if available │
|
│ No build step required │
|
||||||
└─────────────┬───────────────────────┘
|
└───────────┬──────────────┘
|
||||||
│
|
│ REST + SSE
|
||||||
┌─────────┴─────────┐
|
|
||||||
│ │
|
|
||||||
[Unmanaged] [Managed Mode]
|
|
||||||
LocalStorage │
|
|
||||||
▼
|
▼
|
||||||
┌────────────────┐
|
┌──────────────────────────┐
|
||||||
│ Go Backend │
|
│ Go Backend │
|
||||||
│ - Auth/Users │
|
│ ├── Auth (JWT + refresh)│
|
||||||
│ - WebSocket │
|
│ ├── Chat CRUD │
|
||||||
│ - Extensions │
|
│ ├── Completion proxy │
|
||||||
└────────┬───────┘
|
│ ├── Provider management │
|
||||||
|
│ ├── Admin endpoints │
|
||||||
|
│ └── Static file server │
|
||||||
|
└───────────┬──────────────┘
|
||||||
│
|
│
|
||||||
┌─────────────┴──────────────┐
|
PostgreSQL
|
||||||
│ │
|
|
||||||
PostgreSQL Extensions
|
|
||||||
- Chats, users - Chat (Python)
|
|
||||||
- Channels - RAG (Python)
|
|
||||||
- Knowledge - Workflows (Go)
|
|
||||||
- pgvector - Custom tools...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 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
|
No framework. No build step. No node_modules. Works in disconnected (SCIF) environments with vendor libs baked in.
|
||||||
- **Vanilla JavaScript** - No framework bloat
|
|
||||||
- **LocalStorage** - Offline-first
|
|
||||||
- **WebSocket** - Real-time updates (managed)
|
|
||||||
|
|
||||||
### Backend (Managed Mode)
|
### Backend (server/)
|
||||||
- **Go** - Core API, routing, WebSocket
|
|
||||||
- **PostgreSQL** - Primary storage
|
Go 1.22, ~26 source files. Key packages:
|
||||||
- **pgvector** - Vector embeddings for RAG
|
|
||||||
- **Redis** - WebSocket pub/sub (optional)
|
- `handlers/` — Auth, chats, messages, completions, API configs, admin
|
||||||
- **Python** - AI/ML extensions
|
- `middleware/` — JWT auth, admin role check, rate limiting, error handling, logging
|
||||||
- **Docker** - Easy deployment
|
- `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 ✅
|
### Docker Compose (unified)
|
||||||
- [x] Frontend (unmanaged mode)
|
|
||||||
- [ ] Go backend with PostgreSQL
|
|
||||||
- [ ] User authentication
|
|
||||||
- [ ] WebSocket server
|
|
||||||
- [ ] Extension manager
|
|
||||||
|
|
||||||
### Next: Phase 2 - Core Features as Plugins
|
The provided `Dockerfile` is a 3-stage build:
|
||||||
- [ ] Chat Engine (Python)
|
1. `golang:1.22-bookworm` — compiles Go backend
|
||||||
- [ ] Channels (Go)
|
2. `node:20-alpine` — downloads vendor JS libs via `npm pack`
|
||||||
- [ ] Notes (Go)
|
3. `nginx:1-alpine` — serves frontend, proxies `/api/` to Go backend
|
||||||
- [ ] RAG Engine (Python)
|
|
||||||
|
|
||||||
### Future: Phase 3+
|
Vendor libs are baked into the image at build time — no CDN access needed at runtime (SCIF-safe).
|
||||||
- [ ] Visual Workflow Builder
|
|
||||||
- [ ] Desktop app (Tauri)
|
|
||||||
- [ ] Extension marketplace
|
|
||||||
- [ ] Mobile PWA
|
|
||||||
|
|
||||||
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
|
# Frontend — just edit files, hard-refresh browser
|
||||||
2. **Fork** the repo
|
# Debug: Ctrl+Shift+L opens debug modal
|
||||||
3. **Create** a feature branch
|
```
|
||||||
4. **Submit** a PR
|
|
||||||
|
|
||||||
**Good first issues:**
|
### Database Migrations
|
||||||
- Frontend UI improvements
|
|
||||||
- Backend handler implementations
|
Migrations in `migrations/` run automatically on startup. Current schema:
|
||||||
- Example extensions
|
- `001_full_schema.sql` — users, chats, messages, api_configs
|
||||||
- Documentation
|
- `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)
|
See [ROADMAP.md](ROADMAP.md) for the full plan. Next up:
|
||||||
- Privacy-focused AI assistant
|
|
||||||
- Offline research tool
|
|
||||||
- Model comparison testing
|
|
||||||
|
|
||||||
### Team (Managed)
|
1. **WebSocket hub** — real-time message delivery, typing indicators
|
||||||
- Collaborative AI workspace
|
2. **Channels** — multi-user + AI chat rooms with @mentions
|
||||||
- Shared knowledge bases
|
3. **Plugin system** — Go-native extensions, installable via admin UI
|
||||||
- Automated workflows
|
4. **Notes & Knowledge Base** — markdown notes, document upload, RAG via pgvector
|
||||||
- Code review pipelines
|
|
||||||
|
|
||||||
### Enterprise
|
|
||||||
- Self-hosted AI platform
|
|
||||||
- Custom model routing
|
|
||||||
- Compliance and audit logs
|
|
||||||
- SSO/SAML integration
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🆚 Comparison
|
## License
|
||||||
|
|
||||||
### vs Open WebUI
|
MIT — build anything, including commercial products.
|
||||||
- ✅ 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 License
|
**Repository:** https://git.gobha.me/xcaliber/chat-switchboard
|
||||||
|
|
||||||
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! 🚀**
|
|
||||||
|
|||||||
590
ROADMAP.md
590
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 |
|
**Backend (Go)**
|
||||||
|---------|-----------------|------------|---------|----------------|-----------|
|
- [x] PostgreSQL schema + auto-migrations (go:embed, startup)
|
||||||
| **Multi-Model** | ✅ Auto-routing | ✅ Manual | ❌ | ❌ | ✅ |
|
- [x] JWT auth with refresh token rotation
|
||||||
| **Plugin System** | ✅⭐ Dogfooded | 🟡 Limited | ❌ | ❌ | 🟡 |
|
- [x] Chat CRUD + message persistence
|
||||||
| **Offline Mode** | ✅ Full featured | ❌ | ❌ | 🟡 | ❌ |
|
- [x] Streaming completion proxy (OpenAI + Anthropic + Venice + OpenRouter)
|
||||||
| **Workflows** | ✅⭐ Visual DAG | ❌ | ❌ | ❌ | ❌ |
|
- [x] Per-user + global API config management
|
||||||
| **Channels** | ✅ With AI | ✅ | ❌ | ❌ | 🟡 |
|
- [x] Admin endpoints (users, providers, models, settings, stats)
|
||||||
| **RAG/Knowledge** | ✅ pgvector | ✅ | 🟡 | 🟡 | ✅ |
|
- [x] Rate limiting, error middleware, request logging
|
||||||
| **Self-Hosted** | ✅ Easy | ✅ Complex | ❌ | ❌ | ✅ |
|
- [x] Health check endpoint (includes schema_version)
|
||||||
| **Open Source** | ✅ MIT | ✅ MIT | ❌ | ❌ | ✅ MIT |
|
- [x] EventBus with WebSocket hub
|
||||||
| **No Backend Required** | ✅⭐ Optional | ❌ | ❌ | ❌ | ❌ |
|
- [x] Provider capability system (known models, heuristic detection, resolution chain)
|
||||||
| **Desktop App** | 🟡 Planned | ❌ | ✅ | ✅ | ❌ |
|
- [x] Dynamic max_tokens resolution (no more hardcoded 4096)
|
||||||
| **Mobile App** | 🟡 PWA | 🟡 | ✅ | ✅ | 🟡 |
|
- [x] User + admin provider model listing with capabilities
|
||||||
|
|
||||||
**Unique Advantages:**
|
**Frontend (Vanilla JS)**
|
||||||
1. **Workflows** - No competitor has visual AI orchestration
|
- [x] Collapsible sidebar with time-grouped chat history
|
||||||
2. **Plugin-First** - Core features ARE plugins (proves extensibility)
|
- [x] User menu flyout (Settings, Admin, Debug, Sign Out)
|
||||||
3. **Dual-Mode** - Works offline or managed (privacy + collaboration)
|
- [x] Model selector with capability badges (output, context, tools, vision, thinking)
|
||||||
4. **Smart Routing** - Automatic model selection for cost/quality
|
- [x] Client-side known model table with backend overlay
|
||||||
|
- [x] Streaming SSE display with smart scroll
|
||||||
## Development Phases
|
- [x] Full markdown rendering (marked.js + DOMPurify)
|
||||||
|
- [x] Thinking block display (`<think>`/`<thinking>` tags)
|
||||||
### ✅ Phase 0: Foundation (Current)
|
- [x] Settings modal (profile, chat prefs, provider management)
|
||||||
**Status:** Complete
|
- [x] Admin modal (users, providers, models with cap badges, settings, stats)
|
||||||
**Timeline:** Week 1-2
|
- [x] Debug modal (console intercept, network log, state inspector)
|
||||||
|
- [x] EventBus client with exponential backoff + max retries
|
||||||
- [x] Frontend core (vanilla JS)
|
|
||||||
- [x] LocalStorage state management
|
|
||||||
- [x] OpenAI-compatible API client
|
|
||||||
- [x] Streaming responses
|
|
||||||
- [x] Chat history
|
|
||||||
- [x] Export (Markdown, JSON, Text)
|
- [x] Export (Markdown, JSON, Text)
|
||||||
- [x] Model switching
|
- [x] Vendor libs with CDN fallback (SCIF-safe)
|
||||||
- [x] Standalone build
|
- [x] 3-stage Dockerfile (Go build → vendor download → nginx)
|
||||||
|
|
||||||
### 🚧 Phase 1: Backend Core
|
**CI/CD (Gitea Actions)**
|
||||||
**Status:** In Progress
|
- [x] Three-env pipeline (dev/test/prod)
|
||||||
**Timeline:** Week 3-6 (4 weeks)
|
- [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:**
|
**Architecture Design (v0.5.4)**
|
||||||
- Full Go backend with PostgreSQL
|
- [x] ARCHITECTURE.md: core services spec (13 sections)
|
||||||
- User authentication (JWT)
|
- [x] EXTENSIONS.md: three-tier extension system spec
|
||||||
- WebSocket server for real-time
|
- [x] Terminology: Roles, Teams, Channels, Message Tree, etc.
|
||||||
- Extension manager (load/manage plugins)
|
- [x] "Everything is a channel" — chat is a channel with type:'direct'
|
||||||
- Mode switching (unmanaged ↔ managed)
|
- [x] Conversation forking (message tree via parent_id)
|
||||||
|
- [x] Auth strategy (builtin/mTLS/OIDC)
|
||||||
|
- [x] Classification banners (CSS custom props)
|
||||||
|
|
||||||
**Tasks:**
|
### Implementation Phasing
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
**Deliverables:**
|
**See ARCHITECTURE.md §10 for the authoritative implementation sequence.**
|
||||||
- Users can create accounts
|
Phases 1–8, from channel foundation through RBAC.
|
||||||
- Backend stores chat history
|
|
||||||
- WebSocket connections work
|
|
||||||
- At least 1 extension loads and runs
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🎯 Phase 2: Core Features as Plugins
|
## Phase 1: Real-Time Foundation
|
||||||
**Status:** Not Started
|
|
||||||
**Timeline:** Week 7-10 (4 weeks)
|
|
||||||
|
|
||||||
**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)**
|
- [ ] WebSocket upgrade endpoint (`/ws`)
|
||||||
```
|
- [ ] Connection manager (register, unregister, broadcast)
|
||||||
extensions/backend/chat-engine/
|
- [ ] Room-based pub/sub (per-chat, per-channel, global)
|
||||||
├── extension.json
|
- [ ] JWT auth on WebSocket handshake
|
||||||
├── main.py
|
- [ ] Heartbeat / ping-pong keepalive
|
||||||
├── requirements.txt
|
- [ ] Reconnection handling (frontend)
|
||||||
└── models/
|
- [ ] Message types: `chat.message`, `chat.typing`, `user.presence`, `system.notify`
|
||||||
├── openai.py
|
|
||||||
├── anthropic.py
|
|
||||||
└── ollama.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tasks:**
|
### 1.2 Live Chat Updates
|
||||||
- [ ] Multi-provider adapter pattern
|
- [ ] New messages pushed via WebSocket (not just SSE streaming)
|
||||||
- [ ] Streaming response handling
|
- [ ] Typing indicators
|
||||||
- [ ] Token counting and cost tracking
|
- [ ] Chat list updates when other sessions create/delete chats
|
||||||
- [ ] Message history management
|
- [ ] Online user presence
|
||||||
- [ ] System prompt injection
|
|
||||||
|
|
||||||
**2.2 Channels Plugin (Go)**
|
---
|
||||||
```
|
|
||||||
extensions/backend/channels/
|
|
||||||
├── extension.json
|
|
||||||
├── main.go
|
|
||||||
├── handlers/
|
|
||||||
│ ├── channels.go
|
|
||||||
│ └── messages.go
|
|
||||||
└── websocket/
|
|
||||||
└── broadcaster.go
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tasks:**
|
## Phase 2: Channels
|
||||||
- [ ] Channel CRUD operations
|
|
||||||
- [ ] Membership management
|
|
||||||
- [ ] Real-time message broadcasting
|
|
||||||
- [ ] @mention parsing (users + AI models)
|
|
||||||
- [ ] Threading support
|
|
||||||
|
|
||||||
**2.3 Notes Plugin (Go)**
|
### 2.1 Backend
|
||||||
```
|
- [ ] Channel model (name, description, type: public/private/DM, creator)
|
||||||
extensions/backend/notes/
|
- [ ] Membership model (user_id, channel_id, role: owner/member)
|
||||||
├── extension.json
|
- [ ] Channel messages (extends message model with channel_id)
|
||||||
├── main.go
|
- [ ] CRUD endpoints: `/api/v1/channels`, `/api/v1/channels/:id/messages`
|
||||||
└── handlers/
|
- [ ] @mention parsing — users and AI models
|
||||||
└── notes.go
|
- [ ] AI response triggered by @model-name mentions
|
||||||
```
|
- [ ] WebSocket broadcast per channel room
|
||||||
|
|
||||||
**Tasks:**
|
### 2.2 Frontend
|
||||||
- [ ] Note CRUD
|
- [ ] 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
|
- [ ] Folder organization
|
||||||
- [ ] Tagging system
|
- [ ] Full-text search (PostgreSQL `tsvector`)
|
||||||
- [ ] Markdown rendering
|
- [ ] Markdown editor in frontend
|
||||||
- [ ] Full-text search
|
- [ ] Link notes to chats
|
||||||
|
|
||||||
**Deliverables:**
|
### 4.2 RAG (Retrieval Augmented Generation)
|
||||||
- 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:**
|
|
||||||
- [ ] Document upload (PDF, DOCX, TXT, MD)
|
- [ ] Document upload (PDF, DOCX, TXT, MD)
|
||||||
- [ ] Smart chunking (recursive, semantic)
|
- [ ] Chunking strategies (recursive, semantic)
|
||||||
- [ ] Embedding generation (OpenAI, local models)
|
- [ ] Embedding generation (OpenAI, local models)
|
||||||
- [ ] Vector storage (pgvector)
|
- [ ] pgvector storage and similarity search
|
||||||
- [ ] Semantic search
|
- [ ] Context injection in completion flow
|
||||||
- [ ] Context injection in chat
|
- [ ] Per-KB toggle in chat settings
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### ⭐ Phase 4: Workflows (KILLER FEATURE)
|
## Phase 5: Polish
|
||||||
**Status:** Not Started
|
|
||||||
**Timeline:** Week 14-18 (5 weeks)
|
|
||||||
|
|
||||||
**Goal:** Visual workflow builder for multi-step AI operations
|
- [ ] Chat search / filter in sidebar
|
||||||
|
- [ ] Message editing
|
||||||
**4.1 Workflow Engine (Go + Python)**
|
- [ ] Chat folders / pinning
|
||||||
```
|
- [ ] Bulk chat operations
|
||||||
extensions/backend/workflows/
|
- [ ] Keyboard shortcuts (Ctrl+K command palette)
|
||||||
├── extension.json
|
- [ ] PWA manifest + offline shell
|
||||||
├── executor.go # Go: DAG execution
|
- [ ] Performance: lazy load chat history, virtual scroll for long chats
|
||||||
├── 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🔧 Phase 5: Developer Experience
|
## Future
|
||||||
**Status:** Not Started
|
|
||||||
**Timeline:** Week 19-21 (3 weeks)
|
|
||||||
|
|
||||||
**Goal:** Make extension development effortless
|
- Desktop app (Tauri)
|
||||||
|
- Smart model routing (cost/quality/latency auto-selection)
|
||||||
**5.1 Extension Templates**
|
- Workflow builder (visual DAG for chaining models + tools)
|
||||||
- [ ] `_template-python` with FastAPI boilerplate
|
- Plugin marketplace
|
||||||
- [ ] `_template-go` with Gin boilerplate
|
- SSO/SAML
|
||||||
- [ ] `_template-node` with Express boilerplate
|
- Audit logging
|
||||||
- [ ] CLI tool: `chatswitch create-extension`
|
- Multi-tenant SaaS mode
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 💎 Phase 6: Polish & Launch Prep
|
## Plugin / Extension Architecture
|
||||||
**Status:** Not Started
|
|
||||||
**Timeline:** Week 22-24 (3 weeks)
|
|
||||||
|
|
||||||
**6.1 Desktop App (Tauri)**
|
**Moved to dedicated documents:**
|
||||||
```
|
- [EXTENSIONS.md](EXTENSIONS.md) — Full extension system spec with three tiers
|
||||||
desktop/
|
(Browser JS, Starlark sandbox, Sidecar containers), manifest format,
|
||||||
├── src-tauri/ # Rust backend
|
browser tool bridge, surface/mode system, and implementation roadmap.
|
||||||
│ ├── main.rs
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core backend services that extensions
|
||||||
│ └── Cargo.toml
|
build on: Notes, Knowledge Bases, Tasks, Channels, Embeddings, and
|
||||||
└── src/ # Frontend (reuse web)
|
Folders/Projects. Includes data models, sequencing, and admin controls.
|
||||||
└── 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! 🚀**
|
|
||||||
|
|||||||
88
build.sh
88
build.sh
@@ -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'
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Chat Switchboard</title>
|
|
||||||
<style>
|
|
||||||
HTMLEOF
|
|
||||||
|
|
||||||
# Append CSS
|
|
||||||
echo "$CSS_CONTENT" >> "$OUTPUT_FILE"
|
|
||||||
|
|
||||||
cat >> "$OUTPUT_FILE" << 'HTMLEOF'
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
HTMLEOF
|
|
||||||
|
|
||||||
# Extract body content (between <body> and </body>, excluding script tags)
|
|
||||||
BODY_CONTENT=$(echo "$HTML_CONTENT" | sed -n '/<body>/,/<\/body>/p' | sed '1d;$d' | sed '/<script/,/<\/script>/d')
|
|
||||||
|
|
||||||
echo "$BODY_CONTENT" >> "$OUTPUT_FILE"
|
|
||||||
|
|
||||||
# Add combined JavaScript
|
|
||||||
cat >> "$OUTPUT_FILE" << 'HTMLEOF'
|
|
||||||
<script>
|
|
||||||
HTMLEOF
|
|
||||||
|
|
||||||
echo "$JS_STORAGE" >> "$OUTPUT_FILE"
|
|
||||||
echo "" >> "$OUTPUT_FILE"
|
|
||||||
echo "$JS_BACKEND" >> "$OUTPUT_FILE"
|
|
||||||
echo "" >> "$OUTPUT_FILE"
|
|
||||||
echo "$JS_STATE" >> "$OUTPUT_FILE"
|
|
||||||
echo "" >> "$OUTPUT_FILE"
|
|
||||||
echo "$JS_API" >> "$OUTPUT_FILE"
|
|
||||||
echo "" >> "$OUTPUT_FILE"
|
|
||||||
echo "$JS_UI" >> "$OUTPUT_FILE"
|
|
||||||
echo "" >> "$OUTPUT_FILE"
|
|
||||||
echo "$JS_APP" >> "$OUTPUT_FILE"
|
|
||||||
|
|
||||||
cat >> "$OUTPUT_FILE" << 'HTMLEOF'
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
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"
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.**
|
|
||||||
@@ -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)
|
|
||||||
@@ -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! 🚀**
|
|
||||||
@@ -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 `
|
|
||||||
<button class="btn btn-small" id="tokenCounter">
|
|
||||||
Tokens: <span id="tokenCount">0</span>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 `
|
|
||||||
<button class="btn btn-small" onclick="copyAllCode()">
|
|
||||||
📋 Copy All Code
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
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.**
|
|
||||||
@@ -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! 🚀**
|
|
||||||
@@ -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!
|
|
||||||
@@ -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.**
|
|
||||||
@@ -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); }
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Routes for a single subdomain:
|
# Routes for a single subdomain:
|
||||||
# /api/* → backend service (Go API on :8080)
|
# /api/* → backend service (Go API on :8080)
|
||||||
|
# /health → backend service (health check)
|
||||||
|
# /ws → backend service (WebSocket, upgraded)
|
||||||
# /* → frontend service (nginx SPA on :80)
|
# /* → frontend service (nginx SPA on :80)
|
||||||
# ============================================
|
# ============================================
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
@@ -35,6 +37,22 @@ spec:
|
|||||||
name: switchboard-be${DEPLOY_SUFFIX}
|
name: switchboard-be${DEPLOY_SUFFIX}
|
||||||
port:
|
port:
|
||||||
number: 8080
|
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
|
# Everything else → frontend
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
|
|||||||
104
k8s/lite.yaml
104
k8s/lite.yaml
@@ -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
|
|
||||||
21
nginx.conf
21
nginx.conf
@@ -8,13 +8,28 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://localhost:8080;
|
proxy_pass http://localhost:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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
|
# Gzip compression
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Chat Switchboard - Database Migration Runner
|
# 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.
|
# Tracks applied migrations in schema_migrations.
|
||||||
# In dev (DB_WIPE=true): drops all tables, re-runs
|
# In dev (DB_WIPE=true): drops all tables, re-runs
|
||||||
# all migrations for a clean slate every PR build.
|
# all migrations for a clean slate every PR build.
|
||||||
@@ -14,7 +18,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo "Migration runner: ${DB_NAME}"
|
echo "Migration runner: ${DB_NAME}"
|
||||||
|
|||||||
159
scripts/db-validate.sh
Normal file
159
scripts/db-validate.sh
Normal file
@@ -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
|
||||||
@@ -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
|
|
||||||
148
server/database/migrate.go
Normal file
148
server/database/migrate.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
60
server/database/migrations/005_provider_capabilities.sql
Normal file
60
server/database/migrations/005_provider_capabilities.sql
Normal file
@@ -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);
|
||||||
116
server/events/bus.go
Normal file
116
server/events/bus.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
123
server/events/bus_test.go
Normal file
123
server/events/bus_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
server/events/types.go
Normal file
94
server/events/types.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
279
server/events/ws.go
Normal file
279
server/events/ws.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ go 1.22
|
|||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.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/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
golang.org/x/crypto v0.14.0
|
golang.org/x/crypto v0.14.0
|
||||||
|
|||||||
@@ -525,7 +525,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
|
|||||||
func (h *AdminHandler) FetchModels(c *gin.Context) {
|
func (h *AdminHandler) FetchModels(c *gin.Context) {
|
||||||
// Load all global api_configs
|
// Load all global api_configs
|
||||||
rows, err := database.DB.Query(`
|
rows, err := database.DB.Query(`
|
||||||
SELECT id, provider, endpoint, api_key_encrypted
|
SELECT id, provider, endpoint, api_key_encrypted, custom_headers
|
||||||
FROM api_configs
|
FROM api_configs
|
||||||
WHERE user_id IS NULL AND is_active = true
|
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"`
|
ConfigID string `json:"config_id"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Added int `json:"added"`
|
Added int `json:"added"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -548,7 +549,8 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cfgID, providerID, endpoint string
|
var cfgID, providerID, endpoint string
|
||||||
var apiKey *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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,9 +565,15 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
|
|||||||
key = *apiKey
|
key = *apiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
customHeaders := make(map[string]string)
|
||||||
|
if customHeadersJSON != nil {
|
||||||
|
_ = json.Unmarshal(customHeadersJSON, &customHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
models, err := prov.ListModels(c.Request.Context(), providers.ProviderConfig{
|
models, err := prov.ListModels(c.Request.Context(), providers.ProviderConfig{
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
APIKey: key,
|
APIKey: key,
|
||||||
|
CustomHeaders: customHeaders,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()})
|
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}
|
fr := fetchResult{ConfigID: cfgID, Provider: providerID}
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
|
// Serialize capabilities to JSONB
|
||||||
|
capsJSON, _ := json.Marshal(m.Capabilities)
|
||||||
|
|
||||||
result, err := database.DB.Exec(`
|
result, err := database.DB.Exec(`
|
||||||
INSERT INTO model_configs (api_config_id, model_id, display_name)
|
INSERT INTO model_configs (api_config_id, model_id, display_name, capabilities)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (api_config_id, model_id) DO NOTHING
|
ON CONFLICT (api_config_id, model_id)
|
||||||
`, cfgID, m.ID, m.Name)
|
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 {
|
if err != nil {
|
||||||
fr.Skipped++
|
fr.Skipped++
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -368,6 +369,7 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
|
|||||||
OwnedBy string `json:"owned_by,omitempty"`
|
OwnedBy string `json:"owned_by,omitempty"`
|
||||||
ConfigID string `json:"config_id"`
|
ConfigID string `json:"config_id"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
|
Capabilities providers.ModelCapabilities `json:"capabilities"`
|
||||||
}
|
}
|
||||||
|
|
||||||
allModels := make([]modelEntry, 0)
|
allModels := make([]modelEntry, 0)
|
||||||
@@ -398,12 +400,17 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range models {
|
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{
|
allModels = append(allModels, modelEntry{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
OwnedBy: m.OwnedBy,
|
OwnedBy: m.OwnedBy,
|
||||||
ConfigID: cfgID,
|
ConfigID: cfgID,
|
||||||
Provider: providerID,
|
Provider: providerID,
|
||||||
|
Capabilities: caps,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,6 +421,8 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
|
|||||||
// ── List Enabled Models (from model_configs) ─
|
// ── List Enabled Models (from model_configs) ─
|
||||||
|
|
||||||
func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
|
||||||
type enabledModel struct {
|
type enabledModel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ModelID string `json:"model_id"`
|
ModelID string `json:"model_id"`
|
||||||
@@ -421,9 +430,13 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
ProviderName string `json:"provider_name"`
|
ProviderName string `json:"provider_name"`
|
||||||
ConfigID string `json:"config_id"`
|
ConfigID string `json:"config_id"`
|
||||||
Capabilities map[string]interface{} `json:"capabilities"`
|
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(`
|
rows, err := database.DB.Query(`
|
||||||
SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities
|
SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities
|
||||||
FROM model_configs mc
|
FROM model_configs mc
|
||||||
@@ -431,23 +444,93 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
WHERE mc.is_enabled = true AND ac.is_active = true AND ac.user_id IS NULL
|
WHERE mc.is_enabled = true AND ac.is_active = true AND ac.user_id IS NULL
|
||||||
ORDER BY ac.name, mc.model_id
|
ORDER BY ac.name, mc.model_id
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"models": []interface{}{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
models := make([]enabledModel, 0)
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m enabledModel
|
var m enabledModel
|
||||||
var capsJSON []byte
|
var capsJSON []byte
|
||||||
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil {
|
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.Capabilities = make(map[string]interface{})
|
|
||||||
_ = json.Unmarshal(capsJSON, &m.Capabilities)
|
// 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)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"models": models})
|
c.JSON(http.StatusOK, gin.H{"models": models})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -61,7 +62,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve provider config
|
// Resolve provider config
|
||||||
providerCfg, providerID, model, err := h.resolveConfig(userID, req)
|
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -96,9 +97,17 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
Model: model,
|
Model: model,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve capabilities for this model — auto-set defaults
|
||||||
|
caps := h.getModelCapabilities(model, configID)
|
||||||
|
|
||||||
if req.MaxTokens > 0 {
|
if req.MaxTokens > 0 {
|
||||||
provReq.MaxTokens = req.MaxTokens
|
provReq.MaxTokens = req.MaxTokens
|
||||||
|
} else {
|
||||||
|
// ResolveMaxOutput checks: caps → known models → context/8 → 4096
|
||||||
|
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Temperature != nil {
|
if req.Temperature != nil {
|
||||||
provReq.Temperature = req.Temperature
|
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 ───────────────────────
|
// ── Config Resolution ───────────────────────
|
||||||
// Priority: request.api_config_id → chat.api_config_id → user's first active config
|
// Priority: request.api_config_id → chat.api_config_id → user's first active config
|
||||||
|
|
||||||
func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) (providers.ProviderConfig, string, string, error) {
|
func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
|
||||||
var configID string
|
var configID string
|
||||||
|
|
||||||
// 1. Explicit config from request
|
// 1. Explicit config from request
|
||||||
@@ -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 == "" {
|
if configID == "" {
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
SELECT id FROM api_configs
|
SELECT id FROM api_configs
|
||||||
WHERE (user_id = $1 OR 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
|
ORDER BY user_id NULLS LAST, created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, userID).Scan(&configID)
|
`, userID).Scan(&configID)
|
||||||
if err != nil {
|
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 providerID, endpoint string
|
||||||
var apiKey, modelDefault *string
|
var apiKey, modelDefault *string
|
||||||
|
var customHeadersJSON, providerSettingsJSON []byte
|
||||||
err := database.DB.QueryRow(`
|
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
|
FROM api_configs
|
||||||
WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND is_active = true
|
WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true
|
||||||
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault)
|
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
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 {
|
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
|
// Resolve model: request > config default
|
||||||
@@ -284,7 +326,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
|
|||||||
model = *modelDefault
|
model = *modelDefault
|
||||||
}
|
}
|
||||||
if model == "" {
|
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 := ""
|
key := ""
|
||||||
@@ -292,10 +334,24 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
|
|||||||
key = *apiKey
|
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{
|
return providers.ProviderConfig{
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
APIKey: key,
|
APIKey: key,
|
||||||
}, providerID, model, nil
|
CustomHeaders: customHeaders,
|
||||||
|
Settings: providerSettings,
|
||||||
|
}, providerID, model, configID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Conversation Loader ─────────────────────
|
// ── Conversation Loader ─────────────────────
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"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/handlers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
@@ -21,21 +22,34 @@ func main() {
|
|||||||
if err := database.Connect(cfg); err != nil {
|
if err := database.Connect(cfg); err != nil {
|
||||||
log.Printf("⚠ Database unavailable: %v", err)
|
log.Printf("⚠ Database unavailable: %v", err)
|
||||||
log.Println(" Running in unmanaged mode (no persistence)")
|
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()
|
defer database.Close()
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
|
// ── EventBus + WebSocket Hub ─────────────
|
||||||
|
bus := events.NewBus()
|
||||||
|
hub := events.NewHub(bus)
|
||||||
|
|
||||||
// Health check (k8s probes hit this directly)
|
// Health check (k8s probes hit this directly)
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": Version,
|
"version": Version,
|
||||||
"database": database.IsConnected(),
|
"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 routes (rate limited) ──────────────
|
||||||
auth := handlers.NewAuthHandler(cfg)
|
auth := handlers.NewAuthHandler(cfg)
|
||||||
authLimiter := middleware.NewRateLimiter(1, 5)
|
authLimiter := middleware.NewRateLimiter(1, 5)
|
||||||
@@ -47,6 +61,7 @@ func main() {
|
|||||||
info := gin.H{
|
info := gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": Version,
|
"version": Version,
|
||||||
|
"schema_version": database.SchemaVersion(),
|
||||||
"database": database.IsConnected(),
|
"database": database.IsConnected(),
|
||||||
"providers": providers.List(),
|
"providers": providers.List(),
|
||||||
}
|
}
|
||||||
@@ -147,7 +162,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
|
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
|
||||||
|
log.Printf(" Schema: %s", database.SchemaVersion())
|
||||||
log.Printf(" Providers: %v", providers.List())
|
log.Printf(" Providers: %v", providers.List())
|
||||||
|
log.Printf(" EventBus: ready, WebSocket on /ws")
|
||||||
if err := r.Run(":" + cfg.Port); err != nil {
|
if err := r.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header := c.GetHeader("Authorization")
|
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 == "" {
|
if header == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
"error": "missing authorization header",
|
"error": "missing authorization header",
|
||||||
|
|||||||
@@ -116,14 +116,26 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
|
|||||||
|
|
||||||
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) {
|
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) {
|
||||||
// Anthropic doesn't have a list models endpoint.
|
// Anthropic doesn't have a list models endpoint.
|
||||||
// Return the known model families.
|
// Return known models with authoritative capabilities from our table.
|
||||||
return []Model{
|
modelIDs := []struct{ id, name string }{
|
||||||
{ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"},
|
{"claude-opus-4-20250514", "Claude Opus 4"},
|
||||||
{ID: "claude-haiku-4-20250414", Name: "Claude Haiku 4"},
|
{"claude-sonnet-4-20250514", "Claude Sonnet 4"},
|
||||||
{ID: "claude-opus-4-20250514", Name: "Claude Opus 4"},
|
{"claude-haiku-4-20250414", "Claude Haiku 4"},
|
||||||
{ID: "claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"},
|
{"claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet"},
|
||||||
{ID: "claude-3-5-haiku-20241022", Name: "Claude 3.5 Haiku"},
|
{"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"},
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
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 ──────────────────────────────
|
// ── HTTP Layer ──────────────────────────────
|
||||||
@@ -160,7 +172,8 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
|||||||
antReq.System = system
|
antReq.System = system
|
||||||
}
|
}
|
||||||
if antReq.MaxTokens == 0 {
|
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 {
|
if req.Temperature != nil {
|
||||||
antReq.Temperature = req.Temperature
|
antReq.Temperature = req.Temperature
|
||||||
|
|||||||
396
server/providers/capabilities.go
Normal file
396
server/providers/capabilities.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
203
server/providers/capabilities_test.go
Normal file
203
server/providers/capabilities_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,9 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
|||||||
if cfg.APIKey != "" {
|
if cfg.APIKey != "" {
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+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)
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,9 +138,20 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
|||||||
|
|
||||||
models := make([]Model, 0, len(result.Data))
|
models := make([]Model, 0, len(result.Data))
|
||||||
for _, m := range 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{
|
models = append(models, Model{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
OwnedBy: m.OwnedBy,
|
OwnedBy: m.OwnedBy,
|
||||||
|
Capabilities: caps,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return models, nil
|
return models, nil
|
||||||
@@ -184,6 +198,10 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
|||||||
if cfg.APIKey != "" {
|
if cfg.APIKey != "" {
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+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)
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,5 +259,6 @@ type openaiModelsResponse struct {
|
|||||||
Data []struct {
|
Data []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OwnedBy string `json:"owned_by"`
|
OwnedBy string `json:"owned_by"`
|
||||||
|
ContextLength int `json:"context_length,omitempty"` // Ollama, OpenRouter
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|||||||
160
server/providers/openrouter.go
Normal file
160
server/providers/openrouter.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ type Provider interface {
|
|||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
Endpoint string
|
Endpoint string
|
||||||
APIKey string
|
APIKey string
|
||||||
Extra map[string]interface{} // Provider-specific settings from config JSONB
|
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 ────────────────
|
// ── Request / Response Types ────────────────
|
||||||
@@ -77,9 +78,17 @@ type StreamEvent struct {
|
|||||||
Error error `json:"-"`
|
Error error `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model represents an available model from a provider.
|
// Model represents an available model from a provider, with capabilities.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
OwnedBy string `json:"owned_by,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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,4 +44,6 @@ func List() []string {
|
|||||||
func Init() {
|
func Init() {
|
||||||
Register(&OpenAIProvider{})
|
Register(&OpenAIProvider{})
|
||||||
Register(&AnthropicProvider{})
|
Register(&AnthropicProvider{})
|
||||||
|
Register(&VeniceProvider{})
|
||||||
|
Register(&OpenRouterProvider{})
|
||||||
}
|
}
|
||||||
|
|||||||
149
server/providers/venice.go
Normal file
149
server/providers/venice.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -56,6 +56,21 @@ a:hover { text-decoration: underline; }
|
|||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.sb-btn {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 10px; border-radius: var(--radius);
|
padding: 8px 10px; border-radius: var(--radius);
|
||||||
@@ -68,6 +83,7 @@ a:hover { text-decoration: underline; }
|
|||||||
.sb-btn svg { flex-shrink: 0; }
|
.sb-btn svg { flex-shrink: 0; }
|
||||||
.sb-label { overflow: hidden; transition: opacity var(--transition); }
|
.sb-label { overflow: hidden; transition: opacity var(--transition); }
|
||||||
.sidebar.collapsed .sb-label { opacity: 0; width: 0; }
|
.sidebar.collapsed .sb-label { opacity: 0; width: 0; }
|
||||||
|
.sidebar.collapsed .brand-text { opacity: 0; width: 0; }
|
||||||
|
|
||||||
/* Chat list */
|
/* Chat list */
|
||||||
.sidebar-chats {
|
.sidebar-chats {
|
||||||
@@ -135,11 +151,16 @@ a:hover { text-decoration: underline; }
|
|||||||
justify-content: center; font-size: 13px; font-weight: 600;
|
justify-content: center; font-size: 13px; font-weight: 600;
|
||||||
color: var(--accent); flex-shrink: 0; position: relative;
|
color: var(--accent); flex-shrink: 0; position: relative;
|
||||||
}
|
}
|
||||||
.avatar-dot {
|
.avatar-bug {
|
||||||
position: absolute; bottom: -1px; right: -1px;
|
position: absolute; bottom: -3px; right: -3px;
|
||||||
width: 9px; height: 9px; border-radius: 50%;
|
font-size: 10px; line-height: 1;
|
||||||
background: var(--success); border: 2px solid var(--bg-surface);
|
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; }
|
.user-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* Flyout */
|
/* Flyout */
|
||||||
@@ -182,6 +203,25 @@ a:hover { text-decoration: underline; }
|
|||||||
.model-bar select:focus { outline: none; border-color: var(--accent); }
|
.model-bar select:focus { outline: none; border-color: var(--accent); }
|
||||||
.model-bar select option { background: var(--bg-surface); color: var(--text); }
|
.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 {
|
.icon-btn {
|
||||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||||
padding: 4px; border-radius: 4px;
|
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 { display: flex; gap: 0.75rem; }
|
||||||
.form-row .form-group { flex: 1; }
|
.form-row .form-group { flex: 1; }
|
||||||
|
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
|
||||||
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
|
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
|
||||||
|
|
||||||
/* ── Modal ────────────────────────────────── */
|
/* ── 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-row { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
.admin-user-email { font-size: 11px; color: var(--text-3); }
|
.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 { 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-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
@@ -526,4 +569,5 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.sidebar { position: fixed; z-index: 100; height: 100%; }
|
.sidebar { position: fixed; z-index: 100; height: 100%; }
|
||||||
.sidebar.collapsed { width: 0; border: none; }
|
.sidebar.collapsed { width: 0; border: none; }
|
||||||
.msg-inner { padding: 0 1rem; }
|
.msg-inner { padding: 0 1rem; }
|
||||||
|
.model-caps { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||||
<link rel="stylesheet" href="css/styles.css?v=0.5.2">
|
<link rel="stylesheet" href="css/styles.css?v=0.5.4">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -17,8 +17,10 @@
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-top">
|
<div class="sidebar-top">
|
||||||
<button class="sb-btn" id="sidebarToggle" title="Toggle sidebar">
|
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
<span class="brand-logo">🔀</span>
|
||||||
|
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||||
|
<span class="sb-label brand-text">Chat Switchboard</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="sb-btn" id="newChatBtn" title="New Chat">
|
<button class="sb-btn" id="newChatBtn" title="New Chat">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||||
@@ -31,7 +33,9 @@
|
|||||||
<!-- User area -->
|
<!-- User area -->
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
<button class="user-btn" id="userMenuBtn">
|
<button class="user-btn" id="userMenuBtn">
|
||||||
<div class="user-avatar" id="userAvatar"><span id="avatarLetter">?</span><span class="avatar-dot"></span></div>
|
<div class="user-avatar" id="userAvatar"><span id="avatarLetter">?</span>
|
||||||
|
<span class="avatar-bug" title="Debug available">🐛</span>
|
||||||
|
</div>
|
||||||
<span class="sb-label user-name" id="userName">User</span>
|
<span class="sb-label user-name" id="userName">User</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-flyout" id="userFlyout">
|
<div class="user-flyout" id="userFlyout">
|
||||||
@@ -63,6 +67,7 @@
|
|||||||
<button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
|
<button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="model-caps" id="modelCaps"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="messages" id="chatMessages">
|
<div class="messages" id="chatMessages">
|
||||||
@@ -143,7 +148,10 @@
|
|||||||
<div class="form-group"><label>Default Model</label><select id="settingsModel"></select></div>
|
<div class="form-group"><label>Default Model</label><select id="settingsModel"></select></div>
|
||||||
<div class="form-group"><label>System Prompt</label><textarea id="settingsSystemPrompt" rows="3"></textarea></div>
|
<div class="form-group"><label>System Prompt</label><textarea id="settingsSystemPrompt" rows="3"></textarea></div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group"><label>Max Tokens</label><input type="number" id="settingsMaxTokens" value="4096"></div>
|
<div class="form-group">
|
||||||
|
<label>Max Tokens <span class="form-hint" id="settingsMaxHint"></span></label>
|
||||||
|
<input type="number" id="settingsMaxTokens" placeholder="Auto (from model)">
|
||||||
|
</div>
|
||||||
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
|
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
|
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
|
||||||
@@ -154,7 +162,7 @@
|
|||||||
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
|
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
|
||||||
<div id="providerAddForm" style="display:none">
|
<div id="providerAddForm" style="display:none">
|
||||||
<div class="form-group"><label>Name</label><input type="text" id="providerName" placeholder="My OpenAI Key"></div>
|
<div class="form-group"><label>Name</label><input type="text" id="providerName" placeholder="My OpenAI Key"></div>
|
||||||
<div class="form-group"><label>Provider</label><select id="providerType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option></select></div>
|
<div class="form-group"><label>Provider</label><select id="providerType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
|
||||||
<div class="form-group"><label>Endpoint</label><input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1"></div>
|
<div class="form-group"><label>Endpoint</label><input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1"></div>
|
||||||
<div class="form-group"><label>API Key</label><input type="password" id="providerApiKey" placeholder="sk-..."></div>
|
<div class="form-group"><label>API Key</label><input type="password" id="providerApiKey" placeholder="sk-..."></div>
|
||||||
<div class="form-group"><label>Default Model</label><input type="text" id="providerDefaultModel" placeholder="gpt-4o"></div>
|
<div class="form-group"><label>Default Model</label><input type="text" id="providerDefaultModel" placeholder="gpt-4o"></div>
|
||||||
@@ -227,9 +235,10 @@
|
|||||||
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
|
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
|
||||||
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
|
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
|
||||||
|
|
||||||
<script src="js/debug.js?v=0.5.2"></script>
|
<script src="js/debug.js?v=0.5.4"></script>
|
||||||
<script src="js/api.js?v=0.5.2"></script>
|
<script src="js/events.js?v=0.5.4"></script>
|
||||||
<script src="js/ui.js?v=0.5.2"></script>
|
<script src="js/api.js?v=0.5.4"></script>
|
||||||
<script src="js/app.js?v=0.5.2"></script>
|
<script src="js/ui.js?v=0.5.4"></script>
|
||||||
|
<script src="js/app.js?v=0.5.4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
487
src/js/admin.js
487
src/js/admin.js
@@ -1,487 +0,0 @@
|
|||||||
// ==========================================
|
|
||||||
// Admin Panel
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
let _adminListenersInit = false;
|
|
||||||
|
|
||||||
function initAdmin() {
|
|
||||||
if (Backend.user && Backend.user.role === 'admin') {
|
|
||||||
document.getElementById('adminBtn').style.display = '';
|
|
||||||
} else {
|
|
||||||
document.getElementById('adminBtn').style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAdmin() {
|
|
||||||
document.getElementById('adminModal').classList.add('active');
|
|
||||||
initAdminListeners();
|
|
||||||
switchAdminTab('users');
|
|
||||||
loadAdminUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAdmin() {
|
|
||||||
document.getElementById('adminModal').classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function initAdminListeners() {
|
|
||||||
if (_adminListenersInit) return;
|
|
||||||
_adminListenersInit = true;
|
|
||||||
|
|
||||||
document.getElementById('adminShowAddUser').addEventListener('click', () => {
|
|
||||||
document.getElementById('adminAddUserForm').style.display = '';
|
|
||||||
document.getElementById('adminShowAddUser').style.display = 'none';
|
|
||||||
});
|
|
||||||
document.getElementById('adminCancelAddUser').addEventListener('click', () => {
|
|
||||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
|
||||||
document.getElementById('adminShowAddUser').style.display = '';
|
|
||||||
clearAddUserForm();
|
|
||||||
});
|
|
||||||
document.getElementById('adminCreateUserBtn').addEventListener('click', handleCreateUser);
|
|
||||||
document.getElementById('adminResetCancelBtn').addEventListener('click', closeResetDialog);
|
|
||||||
document.getElementById('adminResetConfirmBtn').addEventListener('click', handleResetPassword);
|
|
||||||
|
|
||||||
// Admin providers
|
|
||||||
document.getElementById('adminProviderShowAddBtn').addEventListener('click', () => {
|
|
||||||
document.getElementById('adminProviderAddForm').style.display = '';
|
|
||||||
document.getElementById('adminProviderShowAddBtn').style.display = 'none';
|
|
||||||
});
|
|
||||||
document.getElementById('adminProviderCancelBtn').addEventListener('click', () => {
|
|
||||||
document.getElementById('adminProviderAddForm').style.display = 'none';
|
|
||||||
document.getElementById('adminProviderShowAddBtn').style.display = '';
|
|
||||||
clearAdminProviderForm();
|
|
||||||
});
|
|
||||||
document.getElementById('adminProviderCreateBtn').addEventListener('click', handleCreateAdminProvider);
|
|
||||||
|
|
||||||
// Admin models
|
|
||||||
document.getElementById('adminFetchModelsBtn').addEventListener('click', handleAdminFetchModels);
|
|
||||||
|
|
||||||
document.getElementById('adminResetNewPw').addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') handleResetPassword();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchAdminTab(tab) {
|
|
||||||
document.querySelectorAll('.admin-tab').forEach(t => {
|
|
||||||
t.classList.toggle('active', t.dataset.tab === tab);
|
|
||||||
});
|
|
||||||
document.getElementById('adminUsersTab').style.display = tab === 'users' ? '' : 'none';
|
|
||||||
document.getElementById('adminModelsTab').style.display = tab === 'models' ? '' : 'none';
|
|
||||||
document.getElementById('adminProvidersTab').style.display = tab === 'providers' ? '' : 'none';
|
|
||||||
document.getElementById('adminSettingsTab').style.display = tab === 'settings' ? '' : 'none';
|
|
||||||
document.getElementById('adminStatsTab').style.display = tab === 'stats' ? '' : 'none';
|
|
||||||
document.getElementById('adminResetDialog').style.display = 'none';
|
|
||||||
|
|
||||||
if (tab === 'models') loadAdminModels();
|
|
||||||
if (tab === 'providers') loadAdminProviders();
|
|
||||||
if (tab === 'settings') loadAdminSettings();
|
|
||||||
if (tab === 'stats') loadAdminStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Add User ────────────────────────────────
|
|
||||||
|
|
||||||
function clearAddUserForm() {
|
|
||||||
document.getElementById('adminNewUsername').value = '';
|
|
||||||
document.getElementById('adminNewEmail').value = '';
|
|
||||||
document.getElementById('adminNewPassword').value = '';
|
|
||||||
document.getElementById('adminNewRole').value = 'user';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateUser() {
|
|
||||||
const username = document.getElementById('adminNewUsername').value.trim();
|
|
||||||
const email = document.getElementById('adminNewEmail').value.trim();
|
|
||||||
const password = document.getElementById('adminNewPassword').value;
|
|
||||||
const role = document.getElementById('adminNewRole').value;
|
|
||||||
|
|
||||||
if (!username || !email || !password) {
|
|
||||||
showToast('⚠️ Fill in all fields', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (password.length < 8) {
|
|
||||||
showToast('⚠️ Password must be at least 8 characters', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Backend.adminCreateUser(username, email, password, role);
|
|
||||||
showToast('✅ User "' + username + '" created', 'success');
|
|
||||||
clearAddUserForm();
|
|
||||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
|
||||||
document.getElementById('adminShowAddUser').style.display = '';
|
|
||||||
loadAdminUsers();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Users List ──────────────────────────────
|
|
||||||
|
|
||||||
async function loadAdminUsers() {
|
|
||||||
const container = document.getElementById('adminUserList');
|
|
||||||
container.innerHTML = '<div class="admin-loading">Loading users...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await Backend.adminListUsers(1, 100);
|
|
||||||
const users = data.data || [];
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
container.innerHTML = '<div class="admin-empty">No users found</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = users.map(u => {
|
|
||||||
const isSelf = u.id === Backend.user.id;
|
|
||||||
const actions = isSelf ? '<span class="admin-you">(you)</span>' : `
|
|
||||||
<select class="admin-role-select" onchange="changeUserRole('${u.id}', this.value)">
|
|
||||||
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
|
|
||||||
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
|
|
||||||
<option value="moderator" ${u.role === 'moderator' ? 'selected' : ''}>moderator</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-small btn-secondary" onclick="openResetDialog('${u.id}', '${escapeHtml(u.username)}')" title="Reset password">🔑</button>
|
|
||||||
<button class="btn btn-small ${u.is_active ? 'btn-warning' : 'btn-success'}" onclick="toggleUserActive('${u.id}', ${!u.is_active})">${u.is_active ? 'Disable' : 'Enable'}</button>
|
|
||||||
<button class="btn btn-small btn-danger" onclick="deleteUser('${u.id}', '${escapeHtml(u.username)}')">Delete</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="admin-user-row" data-id="${u.id}">
|
|
||||||
<div class="admin-user-info">
|
|
||||||
<span class="admin-user-name">${escapeHtml(u.username)}</span>
|
|
||||||
<span class="admin-user-email">${escapeHtml(u.email)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="admin-user-meta">
|
|
||||||
<span class="admin-badge admin-badge-${u.role}">${u.role}</span>
|
|
||||||
<span class="admin-badge ${u.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}">${u.is_active ? 'active' : 'disabled'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="admin-user-actions">${actions}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = '<div class="admin-error">Failed to load users: ' + e.message + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeUserRole(userId, role) {
|
|
||||||
try {
|
|
||||||
await Backend.adminUpdateUserRole(userId, role);
|
|
||||||
showToast('✅ Role updated to ' + role, 'success');
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
loadAdminUsers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleUserActive(userId, isActive) {
|
|
||||||
try {
|
|
||||||
await Backend.adminToggleUserActive(userId, isActive);
|
|
||||||
showToast('✅ User ' + (isActive ? 'enabled' : 'disabled'), 'success');
|
|
||||||
loadAdminUsers();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteUser(userId, username) {
|
|
||||||
if (!confirm('Delete user "' + username + '"? This removes all their chats and data.')) return;
|
|
||||||
try {
|
|
||||||
await Backend.adminDeleteUser(userId);
|
|
||||||
showToast('✅ User deleted', 'success');
|
|
||||||
loadAdminUsers();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reset Password ──────────────────────────
|
|
||||||
|
|
||||||
let _resetUserId = null;
|
|
||||||
|
|
||||||
function openResetDialog(userId, username) {
|
|
||||||
_resetUserId = userId;
|
|
||||||
document.getElementById('adminResetTarget').textContent = 'Set new password for: ' + username;
|
|
||||||
document.getElementById('adminResetNewPw').value = '';
|
|
||||||
document.getElementById('adminResetDialog').style.display = '';
|
|
||||||
document.getElementById('adminResetNewPw').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeResetDialog() {
|
|
||||||
_resetUserId = null;
|
|
||||||
document.getElementById('adminResetDialog').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetPassword() {
|
|
||||||
if (!_resetUserId) return;
|
|
||||||
const pw = document.getElementById('adminResetNewPw').value;
|
|
||||||
if (!pw || pw.length < 8) {
|
|
||||||
showToast('⚠️ Password must be at least 8 characters', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Backend.adminResetPassword(_resetUserId, pw);
|
|
||||||
showToast('✅ Password reset', 'success');
|
|
||||||
closeResetDialog();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings ────────────────────────────────
|
|
||||||
|
|
||||||
async function loadAdminSettings() {
|
|
||||||
try {
|
|
||||||
const data = await Backend.adminListSettings();
|
|
||||||
const settings = data.settings || [];
|
|
||||||
|
|
||||||
for (const s of settings) {
|
|
||||||
if (s.key === 'registration') {
|
|
||||||
document.getElementById('adminRegToggle').checked = s.value.enabled !== false;
|
|
||||||
}
|
|
||||||
if (s.key === 'site') {
|
|
||||||
document.getElementById('adminSiteName').value = s.value.name || 'Chat Switchboard';
|
|
||||||
document.getElementById('adminTagline').value = s.value.tagline || 'Multi-Model AI Chat';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ Failed to load settings: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveAdminSettings() {
|
|
||||||
try {
|
|
||||||
await Backend.adminUpdateSetting('registration', {
|
|
||||||
enabled: document.getElementById('adminRegToggle').checked
|
|
||||||
});
|
|
||||||
await Backend.adminUpdateSetting('site', {
|
|
||||||
name: document.getElementById('adminSiteName').value.trim(),
|
|
||||||
tagline: document.getElementById('adminTagline').value.trim()
|
|
||||||
});
|
|
||||||
showToast('✅ Admin settings saved', 'success');
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats ───────────────────────────────────
|
|
||||||
|
|
||||||
async function loadAdminStats() {
|
|
||||||
const container = document.getElementById('adminStats');
|
|
||||||
container.innerHTML = '<div class="admin-loading">Loading stats...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = await Backend.adminGetStats();
|
|
||||||
container.innerHTML = '<div class="admin-stats-grid">' +
|
|
||||||
statCard(stats.total_users, 'Total Users') +
|
|
||||||
statCard(stats.active_users, 'Active Users') +
|
|
||||||
statCard(stats.total_chats, 'Chats') +
|
|
||||||
statCard(stats.total_messages, 'Messages') +
|
|
||||||
statCard(stats.api_configs, 'API Configs') +
|
|
||||||
'</div>';
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = '<div class="admin-error">Failed to load stats: ' + e.message + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statCard(value, label) {
|
|
||||||
return '<div class="admin-stat-card"><div class="admin-stat-value">' +
|
|
||||||
(value || 0) + '</div><div class="admin-stat-label">' + label + '</div></div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = str;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin Models ────────────────────────────
|
|
||||||
|
|
||||||
const CAPS = ['tool', 'thinking', 'vision', 'code'];
|
|
||||||
|
|
||||||
async function loadAdminModels() {
|
|
||||||
const container = document.getElementById('adminModelList');
|
|
||||||
container.innerHTML = '<div class="admin-loading">Loading models...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await Backend.adminListModels();
|
|
||||||
const models = data.models || [];
|
|
||||||
|
|
||||||
if (models.length === 0) {
|
|
||||||
container.innerHTML = '<div class="admin-empty">No models configured. Add a provider first, then click "Fetch Models".</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by provider
|
|
||||||
const grouped = {};
|
|
||||||
for (const m of models) {
|
|
||||||
const key = m.provider_name || 'Unknown';
|
|
||||||
if (!grouped[key]) grouped[key] = [];
|
|
||||||
grouped[key].push(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
for (const [provider, provModels] of Object.entries(grouped)) {
|
|
||||||
html += '<div class="admin-model-group">';
|
|
||||||
html += '<div class="admin-model-group-header">' + escapeHtml(provider) + '</div>';
|
|
||||||
for (const m of provModels) {
|
|
||||||
const caps = m.capabilities || {};
|
|
||||||
const name = m.display_name || m.model_id;
|
|
||||||
html += '<div class="admin-model-row">';
|
|
||||||
html += ' <div class="admin-model-toggle">';
|
|
||||||
html += ' <label class="toggle-label-compact">';
|
|
||||||
html += ' <input type="checkbox" ' + (m.is_enabled ? 'checked' : '') +
|
|
||||||
' onchange="toggleModelEnabled(\'' + m.id + '\', this.checked)">';
|
|
||||||
html += ' </label>';
|
|
||||||
html += ' </div>';
|
|
||||||
html += ' <div class="admin-model-info">';
|
|
||||||
html += ' <span class="admin-model-name">' + escapeHtml(name) + '</span>';
|
|
||||||
html += ' <span class="admin-model-id">' + escapeHtml(m.model_id) + '</span>';
|
|
||||||
html += ' </div>';
|
|
||||||
html += ' <div class="admin-model-caps">';
|
|
||||||
for (const cap of CAPS) {
|
|
||||||
const active = caps[cap] === true;
|
|
||||||
html += ' <button class="cap-badge ' + (active ? 'cap-active' : 'cap-inactive') + '"' +
|
|
||||||
' onclick="toggleModelCap(\'' + m.id + '\', \'' + cap + '\', ' + !active + ', \'' + escapeHtml(JSON.stringify(caps)) + '\')"' +
|
|
||||||
' title="' + cap + '">' + cap + '</button>';
|
|
||||||
}
|
|
||||||
html += ' </div>';
|
|
||||||
html += ' <div class="admin-model-actions">';
|
|
||||||
html += ' <button class="btn btn-small btn-danger" onclick="deleteAdminModel(\'' + m.id + '\', \'' + escapeHtml(m.model_id) + '\')">✕</button>';
|
|
||||||
html += ' </div>';
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = '<div class="admin-error">Failed to load models: ' + e.message + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAdminFetchModels() {
|
|
||||||
const btn = document.getElementById('adminFetchModelsBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '⏳ Fetching...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await Backend.adminFetchModels();
|
|
||||||
const added = data.total_added || 0;
|
|
||||||
const results = data.results || [];
|
|
||||||
const errors = results.filter(r => r.error);
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
showToast('⚠️ Fetched with ' + errors.length + ' error(s). ' + added + ' new models.', 'warning');
|
|
||||||
} else {
|
|
||||||
showToast('✅ Fetched ' + added + ' new models', 'success');
|
|
||||||
}
|
|
||||||
loadAdminModels();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '🔄 Fetch Models';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleModelEnabled(modelId, enabled) {
|
|
||||||
try {
|
|
||||||
await Backend.adminUpdateModel(modelId, { is_enabled: enabled });
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
loadAdminModels();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleModelCap(modelId, cap, value, capsJson) {
|
|
||||||
try {
|
|
||||||
const caps = JSON.parse(capsJson);
|
|
||||||
caps[cap] = value;
|
|
||||||
await Backend.adminUpdateModel(modelId, { capabilities: caps });
|
|
||||||
loadAdminModels();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
loadAdminModels();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAdminModel(modelId, modelName) {
|
|
||||||
if (!confirm('Remove model "' + modelName + '"? It can be re-fetched later.')) return;
|
|
||||||
try {
|
|
||||||
await Backend.adminDeleteModel(modelId);
|
|
||||||
showToast('✅ Model removed', 'success');
|
|
||||||
loadAdminModels();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAdminProviders() {
|
|
||||||
const container = document.getElementById('adminProviderList');
|
|
||||||
container.innerHTML = '<div class="admin-loading">Loading...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await Backend.adminListGlobalConfigs();
|
|
||||||
const configs = data.configs || [];
|
|
||||||
|
|
||||||
if (configs.length === 0) {
|
|
||||||
container.innerHTML = '<div class="provider-empty">No global providers. Users must add their own.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = configs.map(c => `
|
|
||||||
<div class="provider-row">
|
|
||||||
<div class="provider-info">
|
|
||||||
<span class="provider-name">${escapeHtml(c.name)}</span>
|
|
||||||
<span class="provider-meta">${c.provider} · ${c.model_default || 'no default'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="provider-actions">
|
|
||||||
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
|
|
||||||
<button class="btn btn-small btn-danger" onclick="deleteAdminProvider('${c.id}', '${escapeHtml(c.name)}')">Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + e.message + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAdminProviderForm() {
|
|
||||||
document.getElementById('adminProviderName').value = '';
|
|
||||||
document.getElementById('adminProviderType').value = 'openai';
|
|
||||||
document.getElementById('adminProviderEndpoint').value = '';
|
|
||||||
document.getElementById('adminProviderApiKey').value = '';
|
|
||||||
document.getElementById('adminProviderDefaultModel').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateAdminProvider() {
|
|
||||||
const name = document.getElementById('adminProviderName').value.trim();
|
|
||||||
const provider = document.getElementById('adminProviderType').value;
|
|
||||||
const endpoint = document.getElementById('adminProviderEndpoint').value.trim();
|
|
||||||
const apiKey = document.getElementById('adminProviderApiKey').value.trim();
|
|
||||||
const modelDefault = document.getElementById('adminProviderDefaultModel').value.trim();
|
|
||||||
|
|
||||||
if (!name || !endpoint || !apiKey) {
|
|
||||||
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Backend.adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault || null);
|
|
||||||
showToast('✅ Global provider added', 'success');
|
|
||||||
clearAdminProviderForm();
|
|
||||||
document.getElementById('adminProviderAddForm').style.display = 'none';
|
|
||||||
document.getElementById('adminProviderShowAddBtn').style.display = '';
|
|
||||||
loadAdminProviders();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAdminProvider(configId, name) {
|
|
||||||
if (!confirm('Remove global provider "' + name + '"?')) return;
|
|
||||||
try {
|
|
||||||
await Backend.adminDeleteGlobalConfig(configId);
|
|
||||||
showToast('✅ Global provider removed', 'success');
|
|
||||||
loadAdminProviders();
|
|
||||||
} catch (e) {
|
|
||||||
showToast('❌ ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -118,6 +118,8 @@ const API = {
|
|||||||
async streamCompletion(chatId, content, model, signal) {
|
async streamCompletion(chatId, content, model, signal) {
|
||||||
const body = { chat_id: chatId, content, stream: true };
|
const body = { chat_id: chatId, content, stream: true };
|
||||||
if (model) body.model = model;
|
if (model) body.model = model;
|
||||||
|
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||||
|
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||||
|
|
||||||
let resp = await fetch('/api/v1/chat/completions', {
|
let resp = await fetch('/api/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
134
src/js/app.js
134
src/js/app.js
@@ -1,5 +1,5 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Chat Switchboard – Application (v0.5.2)
|
// Chat Switchboard – Application (v0.5.4)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
const App = {
|
const App = {
|
||||||
@@ -14,7 +14,7 @@ const App = {
|
|||||||
stream: true,
|
stream: true,
|
||||||
showThinking: true,
|
showThinking: true,
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
maxTokens: 4096,
|
maxTokens: 0, // 0 = auto from model capabilities
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -64,6 +64,14 @@ async function startApp() {
|
|||||||
UI.updateUser();
|
UI.updateUser();
|
||||||
UI.showAdminButton(API.isAdmin);
|
UI.showAdminButton(API.isAdmin);
|
||||||
initListeners();
|
initListeners();
|
||||||
|
|
||||||
|
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||||
|
try {
|
||||||
|
Events.connect('/ws');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('EventBus WebSocket not available:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ Chat Switchboard ready');
|
console.log('✅ Chat Switchboard ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,28 +102,104 @@ async function saveSettings() {
|
|||||||
|
|
||||||
// ── Models ───────────────────────────────────
|
// ── Models ───────────────────────────────────
|
||||||
|
|
||||||
|
// Client-side known model capabilities — used when backend hasn't synced yet.
|
||||||
|
// Mirrors server/providers/capabilities.go knownModels table.
|
||||||
|
const KNOWN_MODELS = {
|
||||||
|
'claude-opus-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:32000 },
|
||||||
|
'claude-sonnet-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:16000 },
|
||||||
|
'claude-3-5-sonnet': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
|
||||||
|
'claude-3-5-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
|
||||||
|
'claude-3-opus': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
|
||||||
|
'claude-3-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
|
||||||
|
'gpt-4o': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
|
||||||
|
'gpt-4o-mini': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
|
||||||
|
'gpt-4-turbo': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:4096 },
|
||||||
|
'o1': { streaming:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||||
|
'o3': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||||
|
'o3-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:65536 },
|
||||||
|
'o4-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||||
|
'gemini-2.5-pro': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
|
||||||
|
'gemini-2.5-flash': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
|
||||||
|
'gemini-2.0-flash': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:8192 },
|
||||||
|
'deepseek-r1': { streaming:true, reasoning:true, max_context:65536, max_output_tokens:8192 },
|
||||||
|
'deepseek-v3': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
|
||||||
|
'deepseek-chat': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
|
||||||
|
'llama-3.1-405b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||||
|
'llama-3.1-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||||
|
'llama-3.3-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||||
|
'llama-4-maverick': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:16384 },
|
||||||
|
'llama-4-scout': { streaming:true, tool_calling:true, vision:true, max_context:524288, max_output_tokens:16384 },
|
||||||
|
'mistral-large': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
|
||||||
|
'codestral': { streaming:true, tool_calling:true, code_optimized:true, max_context:262144, max_output_tokens:8192 },
|
||||||
|
'qwen-2.5-72b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
|
||||||
|
'qwq-32b': { streaming:true, reasoning:true, max_context:131072, max_output_tokens:8192 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look up client-side capabilities by model ID with prefix matching
|
||||||
|
function lookupKnownCaps(modelId) {
|
||||||
|
const id = modelId.toLowerCase().replace(/^[^/]+\//, ''); // strip provider prefix
|
||||||
|
// Exact match
|
||||||
|
if (KNOWN_MODELS[id]) return { ...KNOWN_MODELS[id] };
|
||||||
|
// Prefix match (longest wins)
|
||||||
|
let best = null, bestLen = 0;
|
||||||
|
for (const key of Object.keys(KNOWN_MODELS)) {
|
||||||
|
if (id.startsWith(key) && key.length > bestLen) {
|
||||||
|
best = key; bestLen = key.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? { ...KNOWN_MODELS[best] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: backend/provider caps are authoritative, client-side fills gaps only
|
||||||
|
function resolveCapabilities(backendCaps, modelId) {
|
||||||
|
const known = lookupKnownCaps(modelId) || {};
|
||||||
|
if (!backendCaps || Object.keys(backendCaps).length === 0) return known;
|
||||||
|
|
||||||
|
// Backend is authoritative — start with it
|
||||||
|
const caps = { ...backendCaps };
|
||||||
|
|
||||||
|
// Fill gaps (fields backend didn't report) from known table
|
||||||
|
for (const [k, v] of Object.entries(known)) {
|
||||||
|
if (caps[k] === undefined || caps[k] === null) {
|
||||||
|
caps[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caps;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchModels() {
|
async function fetchModels() {
|
||||||
try {
|
try {
|
||||||
const data = await API.listEnabledModels();
|
const data = await API.listEnabledModels();
|
||||||
App.models = (data.models || []).map(m => ({
|
App.models = (data.models || []).map(m => {
|
||||||
id: m.model_id || m.id,
|
const id = m.model_id || m.id;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: m.display_name || id,
|
||||||
provider: m.provider_name || m.provider || '',
|
provider: m.provider_name || m.provider || '',
|
||||||
configId: m.config_id || null
|
configId: m.config_id || null,
|
||||||
}));
|
capabilities: resolveCapabilities(m.capabilities, id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (App.models.length === 0) {
|
if (App.models.length === 0) {
|
||||||
const raw = await API.listAllModels();
|
const raw = await API.listAllModels();
|
||||||
App.models = (raw.models || []).map(m => ({
|
App.models = (raw.models || []).map(m => {
|
||||||
id: m.id || m.name,
|
const id = m.id || m.name;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
provider: m.owned_by || m.provider || '',
|
provider: m.owned_by || m.provider || '',
|
||||||
configId: m.config_id || null
|
configId: m.config_id || null,
|
||||||
}));
|
capabilities: resolveCapabilities(m.capabilities, id),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
App.models.sort((a, b) => a.id.localeCompare(b.id));
|
App.models.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
console.log(`📋 Loaded ${App.models.length} models`);
|
console.log(`📋 Loaded ${App.models.length} models`);
|
||||||
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
||||||
UI.updateModelSelector();
|
UI.updateModelSelector();
|
||||||
|
UI.updateCapabilityBadges();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat Management ──────────────────────────
|
// ── Chat Management ──────────────────────────
|
||||||
@@ -216,6 +300,7 @@ async function sendMessage() {
|
|||||||
const assistantContent = await UI.streamResponse(resp, chat.messages);
|
const assistantContent = await UI.streamResponse(resp, chat.messages);
|
||||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
|
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
|
||||||
chat.messageCount = chat.messages.length;
|
chat.messageCount = chat.messages.length;
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
UI.showRegenerate(true);
|
UI.showRegenerate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
@@ -263,6 +348,7 @@ async function regenerate() {
|
|||||||
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal);
|
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal);
|
||||||
const content = await UI.streamResponse(resp, chat.messages);
|
const content = await UI.streamResponse(resp, chat.messages);
|
||||||
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
|
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
UI.showRegenerate(true);
|
UI.showRegenerate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name !== 'AbortError') {
|
if (e.name !== 'AbortError') {
|
||||||
@@ -317,6 +403,8 @@ async function handleRegister() {
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
if (!confirm('Sign out?')) return;
|
if (!confirm('Sign out?')) return;
|
||||||
|
Events.disconnect();
|
||||||
|
Events.clear();
|
||||||
await API.logout();
|
await API.logout();
|
||||||
App.chats = [];
|
App.chats = [];
|
||||||
App.currentChatId = null;
|
App.currentChatId = null;
|
||||||
@@ -375,6 +463,7 @@ function initListeners() {
|
|||||||
// Model selector
|
// Model selector
|
||||||
document.getElementById('modelSelect').addEventListener('change', function() {
|
document.getElementById('modelSelect').addEventListener('change', function() {
|
||||||
if (this.value) { App.settings.model = this.value; saveSettings(); }
|
if (this.value) { App.settings.model = this.value; saveSettings(); }
|
||||||
|
UI.updateCapabilityBadges();
|
||||||
});
|
});
|
||||||
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
|
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
|
||||||
await fetchModels();
|
await fetchModels();
|
||||||
@@ -384,6 +473,16 @@ function initListeners() {
|
|||||||
// Settings modal
|
// Settings modal
|
||||||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||||||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||||||
|
document.getElementById('settingsModel').addEventListener('change', function() {
|
||||||
|
const model = App.models.find(m => m.id === this.value);
|
||||||
|
const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
|
||||||
|
const hint = document.getElementById('settingsMaxHint');
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = caps.max_output_tokens > 0
|
||||||
|
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
});
|
||||||
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
|
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
|
||||||
document.getElementById('profileChangePwForm').style.display = '';
|
document.getElementById('profileChangePwForm').style.display = '';
|
||||||
});
|
});
|
||||||
@@ -391,6 +490,18 @@ function initListeners() {
|
|||||||
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
||||||
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
||||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||||
|
document.getElementById('providerType').addEventListener('change', function() {
|
||||||
|
const endpoints = {
|
||||||
|
openai: 'https://api.openai.com/v1',
|
||||||
|
anthropic: 'https://api.anthropic.com',
|
||||||
|
venice: 'https://api.venice.ai/api/v1',
|
||||||
|
openrouter: 'https://openrouter.ai/api/v1',
|
||||||
|
};
|
||||||
|
const ep = document.getElementById('providerEndpoint');
|
||||||
|
if (!ep.value || Object.values(endpoints).includes(ep.value)) {
|
||||||
|
ep.value = endpoints[this.value] || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Admin modal
|
// Admin modal
|
||||||
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
|
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
|
||||||
@@ -427,11 +538,12 @@ function initListeners() {
|
|||||||
function handleSaveSettings() {
|
function handleSaveSettings() {
|
||||||
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
|
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
|
||||||
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
|
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
|
||||||
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 4096;
|
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 0; // 0 = auto
|
||||||
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
||||||
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
UI.updateModelSelector();
|
UI.updateModelSelector();
|
||||||
|
UI.updateCapabilityBadges();
|
||||||
UI.closeSettings();
|
UI.closeSettings();
|
||||||
UI.toast('Settings saved', 'success');
|
UI.toast('Settings saved', 'success');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,456 +0,0 @@
|
|||||||
// ==========================================
|
|
||||||
// Switchboard Backend API Client
|
|
||||||
// ==========================================
|
|
||||||
// Handles auth + chat/message CRUD against the
|
|
||||||
// Switchboard backend. Token refresh is automatic.
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const Backend = {
|
|
||||||
baseUrl: '',
|
|
||||||
accessToken: null,
|
|
||||||
refreshToken: null,
|
|
||||||
user: null,
|
|
||||||
_refreshPromise: null,
|
|
||||||
|
|
||||||
// ── Connection ──────────────────────────
|
|
||||||
|
|
||||||
get isConnected() {
|
|
||||||
return !!(this.baseUrl && this.accessToken);
|
|
||||||
},
|
|
||||||
|
|
||||||
get isManaged() {
|
|
||||||
return this.isConnected;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Init ────────────────────────────────
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Auto-detect: if we're served from a domain with /api/v1,
|
|
||||||
// the backend is co-located. Otherwise check saved URL.
|
|
||||||
const saved = Storage.get('switchboard_backend');
|
|
||||||
if (saved) {
|
|
||||||
this.baseUrl = saved.baseUrl || '';
|
|
||||||
this.accessToken = saved.accessToken || null;
|
|
||||||
this.refreshToken = saved.refreshToken || null;
|
|
||||||
this.user = saved.user || null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
save() {
|
|
||||||
Storage.set('switchboard_backend', {
|
|
||||||
baseUrl: this.baseUrl,
|
|
||||||
accessToken: this.accessToken,
|
|
||||||
refreshToken: this.refreshToken,
|
|
||||||
user: this.user
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.accessToken = null;
|
|
||||||
this.refreshToken = null;
|
|
||||||
this.user = null;
|
|
||||||
this.save();
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Auth ────────────────────────────────
|
|
||||||
|
|
||||||
async register(username, email, password) {
|
|
||||||
const data = await this._fetch('/api/v1/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username, email, password })
|
|
||||||
}, true);
|
|
||||||
this._setAuth(data);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async login(login, password) {
|
|
||||||
const data = await this._fetch('/api/v1/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ login, password })
|
|
||||||
}, true);
|
|
||||||
this._setAuth(data);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async logout() {
|
|
||||||
try {
|
|
||||||
await this._fetch('/api/v1/auth/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ refresh_token: this.refreshToken })
|
|
||||||
}, true);
|
|
||||||
} catch (e) {
|
|
||||||
// Best-effort
|
|
||||||
}
|
|
||||||
this.clear();
|
|
||||||
},
|
|
||||||
|
|
||||||
async refresh() {
|
|
||||||
// Deduplicate concurrent refresh calls
|
|
||||||
if (this._refreshPromise) return this._refreshPromise;
|
|
||||||
|
|
||||||
this._refreshPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const data = await this._fetch('/api/v1/auth/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ refresh_token: this.refreshToken })
|
|
||||||
}, true);
|
|
||||||
this._setAuth(data);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
this.clear();
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this._refreshPromise = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return this._refreshPromise;
|
|
||||||
},
|
|
||||||
|
|
||||||
_setAuth(data) {
|
|
||||||
this.accessToken = data.access_token;
|
|
||||||
this.refreshToken = data.refresh_token;
|
|
||||||
this.user = data.user;
|
|
||||||
this.save();
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Health ───────────────────────────────
|
|
||||||
|
|
||||||
async checkHealth(url) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url + '/api/v1/health', {
|
|
||||||
signal: AbortSignal.timeout(5000)
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
return await resp.json();
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Chats ───────────────────────────────
|
|
||||||
|
|
||||||
async listChats(page = 1, perPage = 50) {
|
|
||||||
return this._authedFetch(`/api/v1/chats?page=${page}&per_page=${perPage}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async createChat(title, model, systemPrompt) {
|
|
||||||
const body = { title };
|
|
||||||
if (model) body.model = model;
|
|
||||||
if (systemPrompt) body.system_prompt = systemPrompt;
|
|
||||||
return this._authedFetch('/api/v1/chats', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async getChat(chatId) {
|
|
||||||
return this._authedFetch(`/api/v1/chats/${chatId}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateChat(chatId, updates) {
|
|
||||||
return this._authedFetch(`/api/v1/chats/${chatId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(updates)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteChat(chatId) {
|
|
||||||
return this._authedFetch(`/api/v1/chats/${chatId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Messages ────────────────────────────
|
|
||||||
|
|
||||||
async listMessages(chatId, page = 1, perPage = 100) {
|
|
||||||
return this._authedFetch(
|
|
||||||
`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async createMessage(chatId, role, content, model) {
|
|
||||||
const body = { role, content };
|
|
||||||
if (model) body.model = model;
|
|
||||||
return this._authedFetch(`/api/v1/chats/${chatId}/messages`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── API Configs ─────────────────────────
|
|
||||||
|
|
||||||
async listConfigs() {
|
|
||||||
return this._authedFetch('/api/v1/api-configs');
|
|
||||||
},
|
|
||||||
|
|
||||||
async createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
|
||||||
return this._authedFetch('/api/v1/api-configs', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name, provider, endpoint,
|
|
||||||
api_key: apiKey,
|
|
||||||
model_default: modelDefault
|
|
||||||
})
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteConfig(configId) {
|
|
||||||
return this._authedFetch(`/api/v1/api-configs/${configId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Profile & Settings ──────────────────
|
|
||||||
|
|
||||||
async getProfile() {
|
|
||||||
return this._authedFetch('/api/v1/profile');
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateProfile(updates) {
|
|
||||||
return this._authedFetch('/api/v1/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(updates)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async changePassword(currentPassword, newPassword) {
|
|
||||||
return this._authedFetch('/api/v1/profile/password', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
current_password: currentPassword,
|
|
||||||
new_password: newPassword
|
|
||||||
})
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSettings() {
|
|
||||||
return this._authedFetch('/api/v1/settings');
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateSettings(settings) {
|
|
||||||
return this._authedFetch('/api/v1/settings', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(settings)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Admin ───────────────────────────────
|
|
||||||
|
|
||||||
async adminListUsers(page = 1, perPage = 50) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/users?page=${page}&per_page=${perPage}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminCreateUser(username, email, password, role) {
|
|
||||||
return this._authedFetch('/api/v1/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username, email, password, role })
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminResetPassword(userId, newPassword) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/users/${userId}/reset-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ new_password: newPassword })
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminUpdateUserRole(userId, role) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/users/${userId}/role`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ role })
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminToggleUserActive(userId, isActive) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/users/${userId}/active`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ is_active: isActive })
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminDeleteUser(userId) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/users/${userId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminListSettings() {
|
|
||||||
return this._authedFetch('/api/v1/admin/settings');
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminGetSetting(key) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/settings/${key}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminUpdateSetting(key, value) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/settings/${key}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(value)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminGetStats() {
|
|
||||||
return this._authedFetch('/api/v1/admin/stats');
|
|
||||||
},
|
|
||||||
|
|
||||||
// Admin Global Configs
|
|
||||||
async adminListGlobalConfigs() {
|
|
||||||
return this._authedFetch('/api/v1/admin/configs');
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
|
|
||||||
return this._authedFetch('/api/v1/admin/configs', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name, provider, endpoint,
|
|
||||||
api_key: apiKey,
|
|
||||||
model_default: modelDefault
|
|
||||||
})
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminDeleteGlobalConfig(configId) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/configs/${configId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Admin Model Configs
|
|
||||||
async adminListModels() {
|
|
||||||
return this._authedFetch('/api/v1/admin/models');
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminFetchModels() {
|
|
||||||
return this._authedFetch('/api/v1/admin/models/fetch', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminUpdateModel(modelId, updates) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/models/${modelId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(updates)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async adminDeleteModel(modelId) {
|
|
||||||
return this._authedFetch(`/api/v1/admin/models/${modelId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Enabled models (for quick selector in managed mode)
|
|
||||||
async listEnabledModels() {
|
|
||||||
return this._authedFetch('/api/v1/models/enabled');
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Models ──────────────────────────────
|
|
||||||
|
|
||||||
async listAllModels() {
|
|
||||||
return this._authedFetch('/api/v1/models');
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Chat Completion (streaming) ─────────
|
|
||||||
// Returns the raw Response so the caller can read the SSE stream.
|
|
||||||
// The backend persists user + assistant messages automatically.
|
|
||||||
|
|
||||||
async streamCompletion(chatId, content, model, signal) {
|
|
||||||
const url = this.baseUrl + '/api/v1/chat/completions';
|
|
||||||
const body = { chat_id: chatId, content, stream: true };
|
|
||||||
if (model) body.model = model;
|
|
||||||
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${this.accessToken}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resp.status === 401 && this.refreshToken) {
|
|
||||||
const refreshed = await this.refresh();
|
|
||||||
if (refreshed) {
|
|
||||||
return fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${this.accessToken}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const errBody = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(errBody.error || `HTTP ${resp.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp;
|
|
||||||
},
|
|
||||||
|
|
||||||
async syncCompletion(chatId, content, model) {
|
|
||||||
return this._authedFetch('/api/v1/chat/completions', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
chat_id: chatId,
|
|
||||||
content,
|
|
||||||
model,
|
|
||||||
stream: false
|
|
||||||
})
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── HTTP Layer ──────────────────────────
|
|
||||||
|
|
||||||
async _authedFetch(path, opts = {}) {
|
|
||||||
try {
|
|
||||||
return await this._fetch(path, {
|
|
||||||
...opts,
|
|
||||||
headers: {
|
|
||||||
...opts.headers,
|
|
||||||
'Authorization': `Bearer ${this.accessToken}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// If 401, try refresh once
|
|
||||||
if (e.status === 401 && this.refreshToken) {
|
|
||||||
const refreshed = await this.refresh();
|
|
||||||
if (refreshed) {
|
|
||||||
return this._fetch(path, {
|
|
||||||
...opts,
|
|
||||||
headers: {
|
|
||||||
...opts.headers,
|
|
||||||
'Authorization': `Bearer ${this.accessToken}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async _fetch(path, opts = {}, skipAuth = false) {
|
|
||||||
const url = this.baseUrl + path;
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
...opts,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...opts.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.json().catch(() => ({}));
|
|
||||||
const err = new Error(body.error || `HTTP ${resp.status}`);
|
|
||||||
err.status = resp.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.json();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -232,12 +232,21 @@ const DebugLog = {
|
|||||||
// localStorage keys
|
// localStorage keys
|
||||||
try {
|
try {
|
||||||
snap.storageKeys = Object.keys(localStorage).filter(k =>
|
snap.storageKeys = Object.keys(localStorage).filter(k =>
|
||||||
k.startsWith('chatSwitchboard') || k.startsWith('switchboard')
|
k.startsWith('chatSwitchboard') || k.startsWith('switchboard') || k.startsWith('sb_')
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
snap.storageKeys = '(error reading)';
|
snap.storageKeys = '(error reading)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventBus state
|
||||||
|
if (typeof Events !== 'undefined') {
|
||||||
|
snap.eventBus = {
|
||||||
|
wsConnected: Events.connected,
|
||||||
|
subscriptions: Events.debug(),
|
||||||
|
queueLength: Events._wsQueue?.length || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return snap;
|
return snap;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -246,8 +255,8 @@ const DebugLog = {
|
|||||||
async runDiagnostics() {
|
async runDiagnostics() {
|
||||||
this.log('DIAG', '── Starting connection diagnostics ──');
|
this.log('DIAG', '── Starting connection diagnostics ──');
|
||||||
|
|
||||||
const baseUrl = (typeof Backend !== 'undefined' && Backend.baseUrl)
|
const baseUrl = (typeof API !== 'undefined' && API._base)
|
||||||
? Backend.baseUrl
|
? API._base
|
||||||
: window.location.origin;
|
: window.location.origin;
|
||||||
|
|
||||||
// Test 1: Basic fetch to same origin
|
// Test 1: Basic fetch to same origin
|
||||||
@@ -297,12 +306,12 @@ const DebugLog = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test 3: Token validity
|
// Test 3: Token validity
|
||||||
if (typeof Backend !== 'undefined' && Backend.accessToken) {
|
if (typeof API !== 'undefined' && API.accessToken) {
|
||||||
this.log('DIAG', 'Test 3: Token validation');
|
this.log('DIAG', 'Test 3: Token validation');
|
||||||
try {
|
try {
|
||||||
const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
|
const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${Backend.accessToken}`,
|
'Authorization': `Bearer ${API.accessToken}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(5000)
|
signal: AbortSignal.timeout(5000)
|
||||||
@@ -318,6 +327,13 @@ const DebugLog = {
|
|||||||
this.log('DIAG', 'Test 3: Skipped (no token)');
|
this.log('DIAG', 'Test 3: Skipped (no token)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test 4: WebSocket connectivity
|
||||||
|
this.log('DIAG', `Test 4: EventBus WebSocket = ${typeof Events !== 'undefined' ? (Events.connected ? 'connected' : 'disconnected') : 'N/A'}`);
|
||||||
|
if (typeof Events !== 'undefined') {
|
||||||
|
this.log('DIAG', ` Subscriptions: ${JSON.stringify(Events.debug())}`);
|
||||||
|
this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`);
|
||||||
|
}
|
||||||
|
|
||||||
this.log('DIAG', '── Diagnostics complete ──');
|
this.log('DIAG', '── Diagnostics complete ──');
|
||||||
this.render();
|
this.render();
|
||||||
},
|
},
|
||||||
@@ -325,24 +341,7 @@ const DebugLog = {
|
|||||||
// ── Badge ───────────────────────────────
|
// ── Badge ───────────────────────────────
|
||||||
|
|
||||||
_injectBadge() {
|
_injectBadge() {
|
||||||
const badge = document.createElement('div');
|
// Keyboard shortcut only — visual indicator is the avatar 🐛
|
||||||
badge.id = 'debugBadge';
|
|
||||||
badge.innerHTML = '🐛';
|
|
||||||
badge.title = 'Debug Log (Ctrl+Shift+L)';
|
|
||||||
badge.style.cssText = `
|
|
||||||
position: fixed; bottom: 12px; left: 12px; z-index: 100000;
|
|
||||||
width: 36px; height: 36px; border-radius: 50%;
|
|
||||||
background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border, #444);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
cursor: pointer; font-size: 18px; opacity: 0.7;
|
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
|
||||||
`;
|
|
||||||
badge.addEventListener('mouseenter', () => { badge.style.opacity = '1'; badge.style.transform = 'scale(1.1)'; });
|
|
||||||
badge.addEventListener('mouseleave', () => { badge.style.opacity = this._errorCount > 0 ? '0.9' : '0.7'; badge.style.transform = ''; });
|
|
||||||
badge.addEventListener('click', () => openDebugModal());
|
|
||||||
document.body.appendChild(badge);
|
|
||||||
|
|
||||||
// Keyboard shortcut
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'L') {
|
if (e.ctrlKey && e.shiftKey && e.key === 'L') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -352,13 +351,17 @@ const DebugLog = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_updateBadge() {
|
_updateBadge() {
|
||||||
const badge = document.getElementById('debugBadge');
|
// Show error count on avatar bug in sidebar
|
||||||
if (!badge) return;
|
const bug = document.querySelector('.avatar-bug');
|
||||||
|
if (!bug) return;
|
||||||
if (this._errorCount > 0) {
|
if (this._errorCount > 0) {
|
||||||
badge.style.opacity = '0.9';
|
bug.textContent = '🐛';
|
||||||
badge.style.background = 'var(--danger, #c0392b)';
|
bug.title = `${this._errorCount} error${this._errorCount > 1 ? 's' : ''} — click for Debug Log`;
|
||||||
badge.innerHTML = `🐛<span style="position:absolute;top:-4px;right:-4px;background:#e74c3c;color:#fff;font-size:10px;border-radius:50%;width:16px;height:16px;display:flex;align-items:center;justify-content:center;">${this._errorCount > 99 ? '99+' : this._errorCount}</span>`;
|
bug.classList.add('has-errors');
|
||||||
badge.style.position = 'fixed'; // keep relative positioning for the counter
|
} else {
|
||||||
|
bug.textContent = '🐛';
|
||||||
|
bug.title = 'Debug available';
|
||||||
|
bug.classList.remove('has-errors');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
327
src/js/events.js
Normal file
327
src/js/events.js
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – EventBus (v0.5.3)
|
||||||
|
// ==========================================
|
||||||
|
// Labeled event bus with WebSocket bridge.
|
||||||
|
// Components subscribe by label (supports * wildcard).
|
||||||
|
// Events route: local-only, FE→BE, BE→FE, or both.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// Events.on('chat.message.*', (payload, meta) => { ... });
|
||||||
|
// Events.emit('chat.typing.abc123', { user: 'jeff' });
|
||||||
|
// Events.connect('/ws');
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const Events = {
|
||||||
|
|
||||||
|
_handlers: new Map(), // label → Set<{fn, once}>
|
||||||
|
_ws: null,
|
||||||
|
_wsUrl: null,
|
||||||
|
_wsReconnectMs: 1000,
|
||||||
|
_wsMaxReconnectMs: 30000,
|
||||||
|
_wsReconnectTimer: null,
|
||||||
|
_wsConnected: false,
|
||||||
|
_wsQueue: [], // buffered while disconnected
|
||||||
|
|
||||||
|
// Labels that should NOT cross the wire
|
||||||
|
_localOnly: new Set([
|
||||||
|
'ui.modal.open', 'ui.modal.close',
|
||||||
|
'ui.sidebar.toggle', 'ui.sidebar.collapse',
|
||||||
|
'ui.toast',
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Labels the FE should never receive from BE
|
||||||
|
// (enforced server-side, this is defense-in-depth)
|
||||||
|
_serverOnly: new Set([
|
||||||
|
'plugin.hook.pre_completion',
|
||||||
|
'plugin.hook.post_completion',
|
||||||
|
'internal.',
|
||||||
|
'db.',
|
||||||
|
]),
|
||||||
|
|
||||||
|
// ── Subscribe ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to events matching a label pattern.
|
||||||
|
* Supports exact match and trailing wildcard:
|
||||||
|
* 'chat.message.abc123' — exact
|
||||||
|
* 'chat.message.*' — any chat.message.{id}
|
||||||
|
* 'chat.*' — any chat.{anything}
|
||||||
|
*
|
||||||
|
* @param {string} label - Event label or pattern
|
||||||
|
* @param {Function} fn - Handler(payload, meta)
|
||||||
|
* @returns {Function} unsubscribe function
|
||||||
|
*/
|
||||||
|
on(label, fn) {
|
||||||
|
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
|
||||||
|
const entry = { fn, once: false };
|
||||||
|
this._handlers.get(label).add(entry);
|
||||||
|
return () => this._handlers.get(label)?.delete(entry);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe for a single event, then auto-unsubscribe.
|
||||||
|
*/
|
||||||
|
once(label, fn) {
|
||||||
|
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
|
||||||
|
const entry = { fn, once: true };
|
||||||
|
this._handlers.get(label).add(entry);
|
||||||
|
return () => this._handlers.get(label)?.delete(entry);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all handlers for a label, or a specific handler.
|
||||||
|
*/
|
||||||
|
off(label, fn) {
|
||||||
|
if (!fn) {
|
||||||
|
this._handlers.delete(label);
|
||||||
|
} else {
|
||||||
|
const set = this._handlers.get(label);
|
||||||
|
if (set) {
|
||||||
|
for (const entry of set) {
|
||||||
|
if (entry.fn === fn) { set.delete(entry); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Emit ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an event. Dispatches locally and sends via WebSocket
|
||||||
|
* unless the label is in _localOnly.
|
||||||
|
*
|
||||||
|
* @param {string} label - Event label
|
||||||
|
* @param {*} payload - Event data
|
||||||
|
* @param {object} opts - { localOnly: bool, room: string }
|
||||||
|
*/
|
||||||
|
emit(label, payload, opts = {}) {
|
||||||
|
const meta = {
|
||||||
|
event: label,
|
||||||
|
room: opts.room || null,
|
||||||
|
ts: Date.now(),
|
||||||
|
local: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local dispatch
|
||||||
|
this._dispatch(label, payload, meta);
|
||||||
|
|
||||||
|
// Send over WebSocket unless local-only
|
||||||
|
if (!opts.localOnly && !this._isLocalOnly(label)) {
|
||||||
|
this._send({ event: label, room: meta.room, payload, ts: meta.ts });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Internal dispatch ────────────────────
|
||||||
|
|
||||||
|
_dispatch(label, payload, meta) {
|
||||||
|
// Check every registered pattern against this label
|
||||||
|
for (const [pattern, entries] of this._handlers) {
|
||||||
|
if (this._match(label, pattern)) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
entry.fn(payload, meta);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[EventBus] Handler error for ${pattern}:`, e);
|
||||||
|
}
|
||||||
|
if (entry.once) entries.delete(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a concrete label against a subscription pattern.
|
||||||
|
* 'chat.message.abc' matches:
|
||||||
|
* 'chat.message.abc' (exact)
|
||||||
|
* 'chat.message.*' (wildcard last segment)
|
||||||
|
* 'chat.*' (wildcard all after chat.)
|
||||||
|
* '*' (match everything)
|
||||||
|
*/
|
||||||
|
_match(label, pattern) {
|
||||||
|
if (pattern === '*') return true;
|
||||||
|
if (pattern === label) return true;
|
||||||
|
if (!pattern.endsWith('*')) return false;
|
||||||
|
const prefix = pattern.slice(0, -1); // 'chat.message.' from 'chat.message.*'
|
||||||
|
return label.startsWith(prefix);
|
||||||
|
},
|
||||||
|
|
||||||
|
_isLocalOnly(label) {
|
||||||
|
if (this._localOnly.has(label)) return true;
|
||||||
|
for (const prefix of this._localOnly) {
|
||||||
|
if (prefix.endsWith('.') && label.startsWith(prefix)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── WebSocket Bridge ─────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to WebSocket endpoint. Auto-reconnects on drop.
|
||||||
|
* @param {string} url - WebSocket URL (e.g. '/ws' or 'wss://host/ws')
|
||||||
|
*/
|
||||||
|
connect(url) {
|
||||||
|
this._wsUrl = url;
|
||||||
|
this._wsReconnectMs = 1000;
|
||||||
|
this._wsRetries = 0;
|
||||||
|
this._wsMaxRetries = 5;
|
||||||
|
this._doConnect();
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this._wsReconnectTimer) clearTimeout(this._wsReconnectTimer);
|
||||||
|
this._wsReconnectTimer = null;
|
||||||
|
this._wsRetries = 0;
|
||||||
|
if (this._ws) {
|
||||||
|
this._ws.onclose = null; // prevent reconnect
|
||||||
|
this._ws.close();
|
||||||
|
this._ws = null;
|
||||||
|
}
|
||||||
|
this._wsConnected = false;
|
||||||
|
this._dispatch('ws.disconnected', {}, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
_doConnect() {
|
||||||
|
if (!this._wsUrl) return;
|
||||||
|
|
||||||
|
// Build full URL
|
||||||
|
let url = this._wsUrl;
|
||||||
|
if (url.startsWith('/')) {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
url = `${proto}//${location.host}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth token as query param (WebSocket doesn't support headers)
|
||||||
|
const tokens = JSON.parse(localStorage.getItem('sb_auth') || '{}');
|
||||||
|
if (tokens.access) {
|
||||||
|
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
|
||||||
|
} else {
|
||||||
|
console.warn('[EventBus] No auth token — skipping WebSocket');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._ws = new WebSocket(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EventBus] WebSocket creation failed:', e);
|
||||||
|
this._scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ws.onopen = () => {
|
||||||
|
console.log('[EventBus] WebSocket connected');
|
||||||
|
this._wsConnected = true;
|
||||||
|
this._wsReconnectMs = 1000;
|
||||||
|
this._wsRetries = 0;
|
||||||
|
|
||||||
|
// Flush queued events
|
||||||
|
while (this._wsQueue.length > 0) {
|
||||||
|
const msg = this._wsQueue.shift();
|
||||||
|
this._ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
this._dispatch('ws.connected', {}, { event: 'ws.connected', ts: Date.now(), local: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
this._ws.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
|
||||||
|
// Server pong
|
||||||
|
if (msg.event === 'pong') return;
|
||||||
|
|
||||||
|
// Drop events that shouldn't reach the frontend
|
||||||
|
for (const prefix of this._serverOnly) {
|
||||||
|
if (msg.event.startsWith(prefix)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
event: msg.event,
|
||||||
|
room: msg.room || null,
|
||||||
|
ts: msg.ts || Date.now(),
|
||||||
|
local: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._dispatch(msg.event, msg.payload, meta);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EventBus] Bad message:', evt.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._ws.onclose = (evt) => {
|
||||||
|
this._wsConnected = false;
|
||||||
|
this._ws = null;
|
||||||
|
if (evt.code !== 1000) {
|
||||||
|
// Abnormal close — log with context
|
||||||
|
console.log(`[EventBus] WebSocket closed (code=${evt.code}, reason=${evt.reason || 'none'})`);
|
||||||
|
}
|
||||||
|
this._dispatch('ws.disconnected', { code: evt.code }, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||||
|
this._scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
this._ws.onerror = () => {
|
||||||
|
// onclose will fire after this — don't double-log
|
||||||
|
};
|
||||||
|
|
||||||
|
// Heartbeat
|
||||||
|
this._startHeartbeat();
|
||||||
|
},
|
||||||
|
|
||||||
|
_scheduleReconnect() {
|
||||||
|
if (!this._wsUrl) return;
|
||||||
|
if (this._wsReconnectTimer) return;
|
||||||
|
|
||||||
|
this._wsRetries++;
|
||||||
|
if (this._wsRetries > this._wsMaxRetries) {
|
||||||
|
console.warn(`[EventBus] WebSocket failed after ${this._wsMaxRetries} attempts — giving up. Real-time features unavailable.`);
|
||||||
|
this._dispatch('ws.failed', { retries: this._wsRetries }, { event: 'ws.failed', ts: Date.now(), local: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[EventBus] Reconnecting in ${this._wsReconnectMs}ms (attempt ${this._wsRetries}/${this._wsMaxRetries})`);
|
||||||
|
this._wsReconnectTimer = setTimeout(() => {
|
||||||
|
this._wsReconnectTimer = null;
|
||||||
|
this._doConnect();
|
||||||
|
}, this._wsReconnectMs);
|
||||||
|
|
||||||
|
// Exponential backoff with cap
|
||||||
|
this._wsReconnectMs = Math.min(this._wsReconnectMs * 2, this._wsMaxReconnectMs);
|
||||||
|
},
|
||||||
|
|
||||||
|
_send(msg) {
|
||||||
|
if (this._wsConnected && this._ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this._ws.send(JSON.stringify(msg));
|
||||||
|
} else {
|
||||||
|
// Buffer up to 100 events while disconnected
|
||||||
|
if (this._wsQueue.length < 100) this._wsQueue.push(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_heartbeatInterval: null,
|
||||||
|
_startHeartbeat() {
|
||||||
|
if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
|
||||||
|
this._heartbeatInterval = setInterval(() => {
|
||||||
|
if (this._wsConnected) {
|
||||||
|
this._send({ event: 'ping', payload: {}, ts: Date.now() });
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Utility ──────────────────────────────
|
||||||
|
|
||||||
|
/** Check if WebSocket is connected */
|
||||||
|
get connected() { return this._wsConnected; },
|
||||||
|
|
||||||
|
/** Remove all handlers (for cleanup / logout) */
|
||||||
|
clear() {
|
||||||
|
this._handlers.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Debug: list all subscriptions */
|
||||||
|
debug() {
|
||||||
|
const subs = {};
|
||||||
|
for (const [label, entries] of this._handlers) {
|
||||||
|
subs[label] = entries.size;
|
||||||
|
}
|
||||||
|
return subs;
|
||||||
|
}
|
||||||
|
};
|
||||||
263
src/js/state.js
263
src/js/state.js
@@ -1,263 +0,0 @@
|
|||||||
// ==========================================
|
|
||||||
// State Management (Mode-Aware)
|
|
||||||
// ==========================================
|
|
||||||
// In unmanaged mode: localStorage (same as before)
|
|
||||||
// In managed mode: backend API with local cache
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const State = {
|
|
||||||
settings: {
|
|
||||||
apiEndpoint: '',
|
|
||||||
apiKey: '',
|
|
||||||
model: 'gpt-3.5-turbo',
|
|
||||||
stream: true,
|
|
||||||
saveHistory: true,
|
|
||||||
showThinking: true,
|
|
||||||
systemPrompt: '',
|
|
||||||
maxTokens: 4096,
|
|
||||||
temperature: 0.7,
|
|
||||||
topP: 1,
|
|
||||||
presencePenalty: 0
|
|
||||||
},
|
|
||||||
models: [],
|
|
||||||
chats: [],
|
|
||||||
currentChatId: null,
|
|
||||||
isGenerating: false,
|
|
||||||
abortController: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Settings (localStorage + backend sync) ──
|
|
||||||
|
|
||||||
function loadSettings() {
|
|
||||||
const saved = Storage.get('chatSwitchboard_settings');
|
|
||||||
if (saved) {
|
|
||||||
State.settings = { ...State.settings, ...saved };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSettings() {
|
|
||||||
Storage.set('chatSwitchboard_settings', State.settings);
|
|
||||||
// Fire-and-forget sync to backend
|
|
||||||
syncSettingsToBackend();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncSettingsToBackend() {
|
|
||||||
if (!Backend.isManaged) return;
|
|
||||||
try {
|
|
||||||
// Only sync preferences, not apiEndpoint/apiKey (those live in api_configs)
|
|
||||||
const prefs = {
|
|
||||||
model: State.settings.model,
|
|
||||||
stream: State.settings.stream,
|
|
||||||
saveHistory: State.settings.saveHistory,
|
|
||||||
showThinking: State.settings.showThinking,
|
|
||||||
systemPrompt: State.settings.systemPrompt,
|
|
||||||
maxTokens: State.settings.maxTokens,
|
|
||||||
temperature: State.settings.temperature,
|
|
||||||
topP: State.settings.topP,
|
|
||||||
presencePenalty: State.settings.presencePenalty
|
|
||||||
};
|
|
||||||
await Backend.updateSettings(prefs);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to sync settings to backend:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSettingsFromBackend() {
|
|
||||||
if (!Backend.isManaged) return;
|
|
||||||
try {
|
|
||||||
const remote = await Backend.getSettings();
|
|
||||||
if (remote && Object.keys(remote).length > 0) {
|
|
||||||
// Merge remote prefs over local, but keep apiEndpoint/apiKey local
|
|
||||||
const localEndpoint = State.settings.apiEndpoint;
|
|
||||||
const localKey = State.settings.apiKey;
|
|
||||||
State.settings = { ...State.settings, ...remote };
|
|
||||||
State.settings.apiEndpoint = localEndpoint;
|
|
||||||
State.settings.apiKey = localKey;
|
|
||||||
Storage.set('chatSwitchboard_settings', State.settings);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to load settings from backend:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Models (always localStorage) ────────────
|
|
||||||
|
|
||||||
function loadModels() {
|
|
||||||
State.models = Storage.get('chatSwitchboard_models', []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveModels() {
|
|
||||||
Storage.set('chatSwitchboard_models', State.models);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chats ───────────────────────────────────
|
|
||||||
|
|
||||||
async function loadChats() {
|
|
||||||
if (Backend.isManaged) {
|
|
||||||
try {
|
|
||||||
const resp = await Backend.listChats(1, 100);
|
|
||||||
State.chats = (resp.data || []).map(c => ({
|
|
||||||
id: c.id,
|
|
||||||
title: c.title,
|
|
||||||
model: c.model || '',
|
|
||||||
systemPrompt: c.system_prompt || '',
|
|
||||||
isArchived: c.is_archived,
|
|
||||||
isPinned: c.is_pinned,
|
|
||||||
messageCount: c.message_count || 0,
|
|
||||||
messages: [], // loaded on demand
|
|
||||||
createdAt: c.created_at,
|
|
||||||
updatedAt: c.updated_at
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load chats from backend:', e);
|
|
||||||
showToast('⚠️ Failed to load chats', 'error');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
State.chats = Storage.get('chatSwitchboard_chats', []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveChats() {
|
|
||||||
// In managed mode, individual operations handle persistence.
|
|
||||||
// In unmanaged mode, write to localStorage.
|
|
||||||
if (!Backend.isManaged && State.settings.saveHistory) {
|
|
||||||
Storage.set('chatSwitchboard_chats', State.chats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createChat(title, messages) {
|
|
||||||
if (Backend.isManaged) {
|
|
||||||
try {
|
|
||||||
const resp = await Backend.createChat(
|
|
||||||
title,
|
|
||||||
State.settings.model,
|
|
||||||
State.settings.systemPrompt
|
|
||||||
);
|
|
||||||
const chat = {
|
|
||||||
id: resp.id,
|
|
||||||
title: resp.title,
|
|
||||||
model: resp.model || '',
|
|
||||||
systemPrompt: resp.system_prompt || '',
|
|
||||||
messages: messages || [],
|
|
||||||
messageCount: 0,
|
|
||||||
createdAt: resp.created_at,
|
|
||||||
updatedAt: resp.updated_at
|
|
||||||
};
|
|
||||||
State.chats.unshift(chat);
|
|
||||||
|
|
||||||
// Persist any initial messages (system prompt, first user msg)
|
|
||||||
for (const msg of (messages || [])) {
|
|
||||||
await Backend.createMessage(chat.id, msg.role, msg.content, msg.model);
|
|
||||||
}
|
|
||||||
chat.messageCount = (messages || []).length;
|
|
||||||
|
|
||||||
return chat;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to create chat:', e);
|
|
||||||
showToast('⚠️ Failed to create chat', 'error');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const chat = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
title,
|
|
||||||
messages: messages || [],
|
|
||||||
model: State.settings.model,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
State.chats.unshift(chat);
|
|
||||||
if (State.chats.length > 100) {
|
|
||||||
State.chats = State.chats.slice(0, 100);
|
|
||||||
}
|
|
||||||
saveChats();
|
|
||||||
return chat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateChat(chatId, updates) {
|
|
||||||
const chat = State.chats.find(c => c.id === chatId);
|
|
||||||
if (!chat) return;
|
|
||||||
|
|
||||||
if (Backend.isManaged) {
|
|
||||||
// Separate backend-updateable fields from local-only fields
|
|
||||||
const backendUpdates = {};
|
|
||||||
if (updates.title !== undefined) backendUpdates.title = updates.title;
|
|
||||||
if (updates.model !== undefined) backendUpdates.model = updates.model;
|
|
||||||
if (updates.is_archived !== undefined) backendUpdates.is_archived = updates.is_archived;
|
|
||||||
if (updates.is_pinned !== undefined) backendUpdates.is_pinned = updates.is_pinned;
|
|
||||||
|
|
||||||
if (Object.keys(backendUpdates).length > 0) {
|
|
||||||
try {
|
|
||||||
await Backend.updateChat(chatId, backendUpdates);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to update chat:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local cache
|
|
||||||
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
|
|
||||||
} else {
|
|
||||||
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
|
|
||||||
saveChats();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteChat(chatId) {
|
|
||||||
if (Backend.isManaged) {
|
|
||||||
try {
|
|
||||||
await Backend.deleteChat(chatId);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to delete chat:', e);
|
|
||||||
showToast('⚠️ Failed to delete chat', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
State.chats = State.chats.filter(c => c.id !== chatId);
|
|
||||||
saveChats();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChat(chatId) {
|
|
||||||
return State.chats.find(c => c.id === chatId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentChat() {
|
|
||||||
return State.currentChatId ? getChat(State.currentChatId) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Messages (managed mode) ─────────────────
|
|
||||||
|
|
||||||
async function loadMessages(chatId) {
|
|
||||||
if (!Backend.isManaged) return;
|
|
||||||
|
|
||||||
const chat = getChat(chatId);
|
|
||||||
if (!chat) return;
|
|
||||||
|
|
||||||
// Skip if already loaded
|
|
||||||
if (chat.messages && chat.messages.length > 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await Backend.listMessages(chatId, 1, 200);
|
|
||||||
chat.messages = (resp.data || []).map(m => ({
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
model: m.model || '',
|
|
||||||
timestamp: m.created_at,
|
|
||||||
_backendId: m.id
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load messages:', e);
|
|
||||||
showToast('⚠️ Failed to load messages', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistMessage(chatId, role, content, model) {
|
|
||||||
if (!Backend.isManaged) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Backend.createMessage(chatId, role, content, model);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to persist message:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// ==========================================
|
|
||||||
// Storage Utilities
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const Storage = {
|
|
||||||
get(key, defaultValue = null) {
|
|
||||||
try {
|
|
||||||
const item = localStorage.getItem(key);
|
|
||||||
return item ? JSON.parse(item) : defaultValue;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Storage get error:', e);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
set(key, value) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Storage set error:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
remove(key) {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Storage remove error:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
try {
|
|
||||||
localStorage.clear();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Storage clear error:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
90
src/js/ui.js
90
src/js/ui.js
@@ -1,5 +1,5 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Chat Switchboard – UI (v0.5.2)
|
// Chat Switchboard – UI (v0.5.4)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
const UI = {
|
const UI = {
|
||||||
@@ -187,7 +187,8 @@ const UI = {
|
|||||||
App.models.forEach(m => {
|
App.models.forEach(m => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = m.id;
|
opt.value = m.id;
|
||||||
opt.textContent = m.id + (m.provider ? ` (${m.provider})` : '');
|
const label = m.name || m.id;
|
||||||
|
opt.textContent = label + (m.provider ? ` (${m.provider})` : '');
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -213,6 +214,56 @@ const UI = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Capability Badges ────────────────────
|
||||||
|
|
||||||
|
getSelectedModelCaps() {
|
||||||
|
const modelId = document.getElementById('modelSelect').value || App.settings.model;
|
||||||
|
if (!modelId) return {};
|
||||||
|
const model = App.models.find(m => m.id === modelId);
|
||||||
|
// Model in list has resolved caps; fallback to client-side lookup
|
||||||
|
if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
|
||||||
|
return model.capabilities;
|
||||||
|
}
|
||||||
|
return (typeof lookupKnownCaps === 'function' ? lookupKnownCaps(modelId) : null) || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCapabilityBadges() {
|
||||||
|
const el = document.getElementById('modelCaps');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const caps = this.getSelectedModelCaps();
|
||||||
|
if (!caps || Object.keys(caps).length === 0) {
|
||||||
|
el.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badges = [];
|
||||||
|
|
||||||
|
// Output tokens
|
||||||
|
if (caps.max_output_tokens > 0) {
|
||||||
|
const k = caps.max_output_tokens >= 1000
|
||||||
|
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
|
||||||
|
: caps.max_output_tokens;
|
||||||
|
badges.push(`<span class="cap-badge cap-context" title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens">${k} out</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context window
|
||||||
|
if (caps.max_context > 0) {
|
||||||
|
const k = (caps.max_context / 1000).toFixed(0) + 'K';
|
||||||
|
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability flags
|
||||||
|
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent" title="Supports tool/function calling">🔧 tools</span>');
|
||||||
|
if (caps.vision) badges.push('<span class="cap-badge cap-accent" title="Supports image/vision input">👁 vision</span>');
|
||||||
|
if (caps.thinking) badges.push('<span class="cap-badge cap-accent" title="Supports extended thinking">💭 thinking</span>');
|
||||||
|
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent" title="Reasoning model (chain-of-thought)">🧠 reasoning</span>');
|
||||||
|
if (caps.code_optimized) badges.push('<span class="cap-badge" title="Optimized for code generation">⟨/⟩ code</span>');
|
||||||
|
if (caps.web_search) badges.push('<span class="cap-badge" title="Supports web search">🔍 search</span>');
|
||||||
|
|
||||||
|
el.innerHTML = badges.join('');
|
||||||
|
},
|
||||||
|
|
||||||
// ── User / Connection ────────────────────
|
// ── User / Connection ────────────────────
|
||||||
|
|
||||||
updateUser() {
|
updateUser() {
|
||||||
@@ -268,10 +319,23 @@ const UI = {
|
|||||||
openSettings() {
|
openSettings() {
|
||||||
document.getElementById('settingsModel').value = App.settings.model;
|
document.getElementById('settingsModel').value = App.settings.model;
|
||||||
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
|
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
|
||||||
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens;
|
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens || '';
|
||||||
document.getElementById('settingsTemp').value = App.settings.temperature;
|
document.getElementById('settingsTemp').value = App.settings.temperature;
|
||||||
document.getElementById('settingsThinking').checked = App.settings.showThinking;
|
document.getElementById('settingsThinking').checked = App.settings.showThinking;
|
||||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||||
|
|
||||||
|
// Show model's max output in the hint
|
||||||
|
const caps = this.getSelectedModelCaps();
|
||||||
|
const hint = document.getElementById('settingsMaxHint');
|
||||||
|
if (hint) {
|
||||||
|
if (caps.max_output_tokens > 0) {
|
||||||
|
const k = (caps.max_output_tokens / 1000).toFixed(0) + 'K';
|
||||||
|
hint.textContent = `(model max: ${k})`;
|
||||||
|
} else {
|
||||||
|
hint.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UI.loadProfileIntoSettings();
|
UI.loadProfileIntoSettings();
|
||||||
UI.loadProviderList();
|
UI.loadProviderList();
|
||||||
document.getElementById('settingsModal').classList.add('active');
|
document.getElementById('settingsModal').classList.add('active');
|
||||||
@@ -375,9 +439,23 @@ const UI = {
|
|||||||
try {
|
try {
|
||||||
const data = await API.adminListModels();
|
const data = await API.adminListModels();
|
||||||
const list = data.models || data.data || data || [];
|
const list = data.models || data.data || data || [];
|
||||||
el.innerHTML = (Array.isArray(list) ? list : []).map(m => `
|
el.innerHTML = (Array.isArray(list) ? list : []).map(m => {
|
||||||
<div class="admin-model-row"><span>${esc(m.model_id || m.id)}</span><span class="provider-meta">${esc(m.provider_name || '')}</span><span>${m.is_enabled ? '✅' : '⬜'}</span></div>
|
const caps = m.capabilities || {};
|
||||||
`).join('') || '<div class="empty-hint">No models</div>';
|
const badges = [];
|
||||||
|
if (caps.max_output_tokens > 0) {
|
||||||
|
badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K out</span>`);
|
||||||
|
}
|
||||||
|
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||||
|
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||||
|
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||||
|
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent">🧠</span>');
|
||||||
|
return `<div class="admin-model-row">
|
||||||
|
<span>${esc(m.model_id || m.id)}</span>
|
||||||
|
<span class="model-caps-inline">${badges.join('')}</span>
|
||||||
|
<span class="provider-meta">${esc(m.provider_name || '')}</span>
|
||||||
|
<span>${m.is_enabled ? '✅' : '⬜'}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<div class="empty-hint">No models — use Fetch Models to sync from providers</div>';
|
||||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chat Switchboard - Version Configuration
|
|
||||||
* Single source of truth: VERSION file at repo root
|
|
||||||
* Injected by build.sh at build time
|
|
||||||
*/
|
|
||||||
const SWITCHBOARD_VERSION = '__VERSION__';
|
|
||||||
const APP_NAME = 'Chat Switchboard';
|
|
||||||
Reference in New Issue
Block a user