Changeset 0.5.0 (#35)

This commit is contained in:
2026-02-19 15:03:20 +00:00
parent a93a6b9635
commit 30d0c11219
65 changed files with 5345 additions and 8070 deletions

View File

@@ -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
View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -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

418
README.md
View File

@@ -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.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?logo=go)](https://go.dev/) [![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go)](https://go.dev/)
[![Python Version](https://img.shields.io/badge/Python-3.9+-3776AB?logo=python)](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! 🚀**

View File

@@ -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 18, 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! 🚀**

View File

@@ -1 +1 @@
0.5.2 0.5.4

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.**

View File

@@ -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)

View File

@@ -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! 🚀**

View File

@@ -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.**

View File

@@ -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(&params)
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! 🚀**

View File

@@ -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!

View File

@@ -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.**

View File

@@ -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); }

View File

@@ -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 };
}
};

View File

@@ -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) {}
}
};

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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
View 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
}

View 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
View 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
View 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
View 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
View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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})
} }

View File

@@ -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 ─────────────────────

View File

@@ -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)
} }

View File

@@ -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",

View File

@@ -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

View 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
}

View 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")
}
}

View File

@@ -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"`
} }

View 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"`
}

View File

@@ -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"`
} }

View File

@@ -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
View 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"`
}

View File

@@ -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; }
} }

View File

@@ -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>

View File

@@ -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');
}
}

View File

@@ -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',

View File

@@ -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');
} }

View File

@@ -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();
}
};

View File

@@ -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
View 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;
}
};

View File

@@ -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);
}
}

View File

@@ -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;
}
}
};

View File

@@ -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>`; }
}, },

View File

@@ -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';