From febcbde3d7fa26b6bc1228cdaee5d421a4adc328 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Mon, 23 Feb 2026 13:42:23 +0000 Subject: [PATCH] Changeset 0.9.1 (#51) --- ARCHITECTURE.md | 10 +- CHANGELOG.md | 68 +++ EXTENSIONS.md | 685 +++++++++++++++++++++++ README.md | 21 +- ROADMAP.md | 541 ++++++++++++++++-- VERSION | 2 +- docker-entrypoint-fe.sh | 6 +- k8s/frontend.yaml | 2 + server/capabilities/capabilities_test.go | 168 +++--- server/capabilities/intrinsic.go | 232 ++------ server/handlers/capabilities.go | 16 +- server/providers/anthropic.go | 2 +- server/providers/openai.go | 7 +- server/providers/openrouter.go | 7 +- server/providers/venice.go | 6 +- src/css/styles.css | 79 ++- src/index.html | 9 + src/js/api.js | 68 ++- src/js/app.js | 219 +++++--- src/js/debug.js | 56 +- src/js/ui.js | 100 +++- 21 files changed, 1881 insertions(+), 423 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 EXTENSIONS.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a296989..a1dcb78 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,7 +43,7 @@ Three Docker images support different deployment scenarios: 4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope. -5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB → known model table → heuristic inference. This ensures every model has capabilities even if the provider API doesn't report them. +5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh. ## Package Structure @@ -70,7 +70,7 @@ server/ │ └── ... ├── models/models.go # Shared domain types ├── capabilities/ -│ ├── intrinsic.go # Known model table + heuristics +│ ├── intrinsic.go # Heuristic detection + resolution │ └── resolver.go # ModelsForUser() unified resolver ├── handlers/ # HTTP handlers (Gin) │ ├── auth.go # Login, register, refresh, logout @@ -133,9 +133,7 @@ When the system needs to know what a model can do (vision? tools? thinking?): ↓ miss 2. model_catalog DB (any provider: same model_id) ↓ miss -3. Known model table (static, curated in capabilities/intrinsic.go) - ↓ miss -4. Heuristic inference (name-based: "gpt-4-vision" → vision=true) +3. Heuristic inference (name-based: "gpt-4-vision" → vision=true) ``` The `capabilities.ModelsForUser()` function combines catalog entries, team personas, and user preferences into a single unified model list for the frontend. @@ -158,8 +156,6 @@ Single consolidated migration (`001_v09_schema.sql`) replaces the previous 21 in 2. Checks which migration files have been applied 3. Applies any new `.sql` files in order -For v0.8 → v0.9 upgrades, a future `002_v08_to_v09.sql` migration will handle the transition. Fresh installs use `001_v09_schema.sql` directly. - ## Frontend Architecture Vanilla JavaScript, no build step. Five files with clear responsibilities: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4db56c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to Chat Switchboard. + +## [0.9.1] — 2026-02-23 + +### Removed +- **Static known model table**: Deleted `knownModels` map from backend and + `KNOWN_MODELS` from frontend. The same model ID can have different capabilities + depending on the provider (e.g. DeepSeek has tool_calling on OpenRouter but + not on Venice). A hardcoded table can't represent this. +- **Frontend `lookupKnownCaps()`**: Removed client-side capability guessing. + Backend is the sole source of truth via catalog → heuristic chain. + +### Changed +- **Resolution chain simplified**: catalog (provider API sync) → heuristic inference. + No intermediate known table. Providers that report capabilities via API are + authoritative; heuristics are best-effort for unsynced models. +- **All providers updated**: OpenAI, OpenRouter, Anthropic, Venice now call + `InferCapabilities()` directly instead of the dead known table lookup. +- **Frontend `resolveCapabilities()`**: Now passes through backend caps as-is. + No client-side merging with a static table. +- [x] EXTENSIONS.md recovered into repo, updated with Appendix A (Custom + Renderers) and Appendix B (Model Roles with utility/embedding/generation slots) +- [x] ROADMAP.md restructured: extension foundation pulled to v0.11.0, + model roles to v0.10.0, dependency graph, TBD replaces post-1.0, + removed v0.8→v0.9 migration (OBE — no public release, no test path) + +### Added +- **Heuristic patterns**: Updated to detect qwen3, gpt-5, grok, kimi, minimax, + glm-5, gemma-3 model families. Vision expanded to claude-opus/sonnet (not + just claude-3). Reasoning expanded for thinking, grok, glm patterns. + +### Fixed +- **Preset capability pills**: Presets with auto-resolve (no `provider_config_id`) + now inherit base model capabilities via `GetByModelIDAny` catalog fallback. +- **Venice `optimizedForCode`**: Added mapping to `CodeOptimized` capability. +- **CI test stability**: BYOK journey tests use unreachable endpoints so auto-fetch + doesn't race with simulated data injection. + +## [0.9.0] — 2026-02-22 + +### Added +- **Schema consolidation**: 21 migrations collapsed to single `001_initial.sql` +- **Store layer**: All database access through typed interfaces (no raw SQL in handlers) +- **Persona model**: Trust-boundary model replacing old presets; scoped global/team/personal +- **Capabilities resolver**: Three-tier chain — catalog → known table → heuristic inference +- **Three-state model visibility**: enabled / disabled / team-only +- **BYOK auto-fetch**: Creating a personal provider triggers model discovery from provider API +- **User model refresh**: `POST /api-configs/:id/models/fetch` endpoint + UI button +- **Composite model IDs**: `configId:modelId` format prevents cross-provider collisions +- **Audit log foundation**: All admin operations logged with actor, action, resource +- **Journey integration tests**: API-driven test suite replacing fake-data tests +- **Frontend test suite**: 107 tests, 27 suites validating model processing pipeline +- **Live Venice API test**: Proves real BYOK → auto-fetch → models visible flow + +### Fixed +- **API key storage**: `json:"-"` tag on `ProviderConfig.APIKeyEnc` silently dropped + keys during admin create/update. Fixed with wrapper structs that bypass the tag. +- **NULL model_default scan**: `scanProviders()` crashed on NULL `model_default` column, + silently hiding all team and BYOK models. Fixed with `sql.NullString`. +- **Nil slice serialization**: Go nil slices serialized as JSON `null` instead of `[]`, + breaking frontend fallback chains. Fixed with `make([]T, 0)`. +- **Frontend error swallowing**: API responses with `errors` field were silently ignored. + +### Changed +- Backward-compatible API routes with v0.8 field name aliases +- User model preferences table (`user_model_settings`) diff --git a/EXTENSIONS.md b/EXTENSIONS.md new file mode 100644 index 0000000..d440160 --- /dev/null +++ b/EXTENSIONS.md @@ -0,0 +1,685 @@ +# Chat Switchboard — Extension System Specification + +**Version:** 0.2 +**Status:** Design (pre-implementation) +**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md) + +--- + +## 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" — Editor, Article, Chat, +Cluster Manager — are extensions that register surfaces, tools, and event +handlers. This project doesn't have to build all of them. It 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, 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. + +**Admin-pushed vs User-installed (Tier 0):** +Admin-pushed extensions load for all users (like a managed browser +extension policy). User-installed extensions load from user settings +and only affect that user's session. Both use the same runtime, same +manifest format. The difference is governance, not execution. + +--- + +## 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. The loader enforces these. + Undeclared access is blocked (Tier 0 via proxy, Tier 1 via sandbox, + Tier 2 via API scoping). +- **entry**: Browser: JS file path. Starlark: `.star` file. Sidecar: + endpoint URL or Docker image. +- **hooks**: EventBus events this extension subscribes to, with priority + (lower = runs first) and whether the hook is async. +- **tools**: LLM-callable tools this extension provides (see §5). +- **surfaces**: UI surfaces this extension registers (see §6). +- **settings**: User-configurable options, rendered as a form in the + extension settings UI. + +--- + +## 4. Browser Extensions (Tier 0) + +### 4.1 Lifecycle + +``` +Install → Load → Init → Active → Disable → Unload +``` + +**Install**: Admin pushes manifest + JS to server, or user adds from +settings. Stored in `extensions` table with `tier = 'browser'`. + +**Load**: On page load, after `events.js` but before `app.js`, the +extension loader injects ` + + + + + + + + + + +``` + +### 7.2 extensions.js (Core) + +The extension loader and registry. ~200 lines. Responsibilities: + +- Fetch enabled extensions 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 + +### 7.3 Backend Support + +New tables (migration): + +```sql +CREATE TABLE extensions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ext_id VARCHAR(100) NOT NULL UNIQUE, -- manifest id + name VARCHAR(200) NOT NULL, + tier VARCHAR(20) NOT NULL DEFAULT 'browser', + 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, + is_enabled BOOLEAN DEFAULT true, + scope VARCHAR(20) DEFAULT 'global', -- global, team, personal + team_id UUID REFERENCES teams(id), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +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 — enable/disable, config +``` + +--- + +## 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. `DirLocal` events stay in the browser. `DirBoth` and +`DirFromClient` events cross the WebSocket. + +--- + +## 9. Built-in Tools + +These ship with core because multiple modes and services depend on them: + +| Tool | Tier | Description | +|---|---|---| +| `web_search` | Sidecar | Search provider abstraction (SearXNG, Brave, DuckDuckGo) | +| `url_fetch` | Server | Retrieve and extract content from a URL | +| `note_create` | Server | Create a note with title, content, folder, tags | +| `note_search` | Server | Full-text search across user's notes | +| `note_update` | Server | Update note content | +| `note_list` | Server | List notes with optional folder filter | +| `kb_search` | Server | Semantic search across knowledge bases (future) | +| `task_create` | Server | Schedule a new task (future) | + +Extension-provided tools (not core, expected early extensions): +- `read_file`, `write_file`, `search_replace` (Editor mode) +- `kubectl_get`, `ceph_status`, `node_ssh` (Cluster manager) +- `generate_image` (Image gen, browser or sidecar) +- `speak_text`, `transcribe_audio` (STT/TTS, browser via Web Speech API) + +--- + +## 10. Design Principles + +1. **Extensions are first-class.** 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 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. 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." + +--- + +## Appendix A: Custom Renderers + +Custom renderers are the simplest useful browser extension. They intercept +message content rendering to handle specific content types — no tools, no +surfaces, just a pattern match and a render function. + +```js +Extensions.register({ + id: 'collapsible-code', + init(ctx) { + ctx.renderers.register('collapsible-code', { + // Match fenced code blocks with >10 lines + pattern: /^```(\w+)?\n([\s\S]{10,}?)```$/gm, + render(match, container) { + const lang = match[1] || 'text'; + const code = match[2]; + const lines = code.split('\n'); + const details = document.createElement('details'); + details.innerHTML = ` + ${lang} — ${lines.length} lines +
${escapeHtml(code)}
+ `; + container.replaceWith(details); + } + }); + } +}); +``` + +Expected first-party renderer extensions (ship with core or as official): + +| Renderer | Matches | Renders | +|---|---|---| +| `collapsible-code` | Code blocks > N lines | `
` with expand/collapse | +| `html-preview` | ```html blocks | Sandboxed iframe with live preview | +| `mermaid` | ```mermaid blocks | SVG diagram via mermaid.js | +| `latex-math` | `$...$` and `$$...$$` | Rendered math via KaTeX | +| `doc-preview` | Generated HTML/PDF content | Preview panel with download link | + +These are all ~30-50 line browser extensions. They don't need tool calling +or surfaces. They just pattern-match content in rendered messages. + +--- + +## Appendix B: Model Roles + +Extensions (and core services like compaction) may need to call LLMs for +their own purposes — summarization, embedding, classification — independent +of the user's selected chat model. **Model roles** are named slots that +resolve to a specific provider+model at runtime. + +### B.1 Role Definition + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Role │ Purpose │ Typical Model │ +├──────────────────────────────────────────────────────────────┤ +│ utility │ Summarization, routing │ gpt-4o-mini │ +│ │ classification, triage │ deepseek-chat │ +│ embedding │ Vector generation for │ text-embedding-3 │ +│ │ KB search, note search │ nomic-embed-text │ +│ generation │ Image/media generation │ dall-e-3 │ +│ │ │ stable-diffusion │ +└──────────────────────────────────────────────────────────────┘ +``` + +### B.2 Configuration + +Admin configures roles in global settings, with optional fallback chain: + +```json +{ + "model_roles": { + "utility": { + "primary": { "provider_config_id": "abc", "model_id": "gpt-4o-mini" }, + "fallback": { "provider_config_id": "def", "model_id": "deepseek-chat" } + }, + "embedding": { + "primary": { "provider_config_id": "abc", "model_id": "text-embedding-3-small" }, + "fallback": null + } + } +} +``` + +Teams can override roles for their scope. Personal overrides TBD. + +### B.3 Extension API + +```js +// Browser extension requests a utility completion +const result = await ctx.models.complete('utility', { + messages: [{ role: 'user', content: 'Summarize this in 2 sentences: ...' }] +}); + +// Server-side: extension requests an embedding +vec, err := models.Embed(ctx, "embedding", "text to embed") +``` + +The caller doesn't know which model or provider is being used. The role +system resolves it, tries fallback if primary fails, and logs usage +against the role for cost tracking. + +### B.4 Core Consumers + +| Consumer | Role | Purpose | +|---|---|---| +| Compaction service | `utility` | Summarize long conversations | +| Smart routing | `utility` | Classify intent, pick best model | +| Knowledge bases | `embedding` | Generate vectors for similarity search | +| Note search (future) | `embedding` | Semantic search across notes | +| Image gen extension | `generation` | Create images from descriptions | + +### B.5 Relationship to Smart Routing + +Model roles and smart routing are complementary: + +- **Model roles**: "I need *a* cheap model for summarization" → resolves + to a specific provider+model based on admin config. +- **Smart routing**: "Route *this user's chat* to the best model" → policy + engine that picks based on capabilities, cost, latency, team rules. + +Roles are for background/system tasks. Routing is for user-facing chat. +Both use the same provider infrastructure and cost tracking. diff --git a/README.md b/README.md index ec2165a..80243c2 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ docker compose --profile dev up # include Adminer DB UI Build and push both images: ```bash -docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.0 server/ -docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.0 . +docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.1 server/ +docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 . ``` **Backend deployment:** @@ -99,7 +99,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.0 . ```yaml containers: - name: api - image: your-registry/switchboard-api:0.9.0 + image: your-registry/switchboard-api:0.9.1 ports: - containerPort: 8080 env: @@ -119,7 +119,7 @@ containers: ```yaml containers: - name: frontend - image: your-registry/switchboard-fe:0.9.0 + image: your-registry/switchboard-fe:0.9.1 ports: - containerPort: 80 env: @@ -194,9 +194,20 @@ All endpoints under `/api/v1/`. Authentication via `Authorization: Bearer .` — hotfixes use quad: `0.x.y.z` +No compatibility guarantees before 1.0. + +--- + +## Dependency Graph + +Features have real dependencies. This ordering respects them. + +``` +v0.9.x Stability + Quick UX Wins + │ +v0.9.3 API Key Encryption + Vault + │ +v0.10.0 Model Roles (utility + embedding) + Usage Tracking + │ +v0.10.1 Summarize & Continue (first utility role consumer) + │ + ┌───────┴──────────────┐ + │ │ +v0.11.0 Extension v0.12.0 File Handling + Foundation + Vision + (browser tier) │ + │ │ + ├── Custom renderers ├── Doc gen outputs + ├── Image gen tool ├── KB documents + ├── STT/TTS │ + │ │ + ├───────────────────────┤ + │ │ +v0.13.0 Web Search + Tools Expansion + │ +v0.14.0 Knowledge Bases (embedding role + file storage + pgvector) + │ +v0.15.0 Compaction (utility role + background job) + │ +v0.16.0 @mention Routing + Multi-model + │ +v0.17.0 Extension Surfaces + Modes + │ +v0.18.0 Smart Routing (policy on model roles) + │ +v0.19.0 Live Collaboration (presence, co-editing) + │ +v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC + │ +v0.21.0 Tasks / Autonomous Agents +``` + +--- + +## ✅ v0.9.0 — Schema Consolidation + BYOK + +The "rewrite" release: 21 migrations collapsed to 1, store layer abstraction, +and the model pipeline made real for end users. + +**Schema & Architecture** +- [x] Consolidated schema (21 migrations → single 001_initial.sql) +- [x] Store layer abstraction (all DB via typed interfaces, no raw SQL in handlers) +- [x] Persona-as-trust-boundary model (replaces old presets concept) - [x] Scope model (global/team/personal) for configs, personas, models -- [x] Capabilities resolver chain (catalog → known → heuristic) -- [x] Three-state model visibility (enabled/disabled/team-only) -- [x] User model preferences (hide, sort) -- [x] Audit log foundation -- [x] Backward-compatible API routes +- [x] Backward-compatible API routes (v0.8 → v0.9 field aliases) -## v0.9.1 (Next) +**Capabilities System** +- [x] Two-tier resolution: catalog (provider API, per-provider) → heuristic inference +- [x] Venice provider: full capability mapping incl. `optimizedForCode` +- [x] `ResolveIntrinsic` with gap-fill merging (catalog authoritative, heuristic fills gaps) +- [x] Preset capability inheritance via `GetByModelIDAny` fallback -- [ ] Chat search (full-text search across messages) -- [ ] Keyboard shortcuts + command palette (Ctrl+K) -- [ ] PWA enhancements (offline indicator, install prompt) -- [ ] API key at-rest encryption -- [ ] v0.8 → v0.9 migration script (002_v08_to_v09.sql) +**BYOK (Bring Your Own Key)** +- [x] Auto-fetch models on provider creation +- [x] User-facing refresh endpoint + "Refresh Models" button +- [x] Personal models in selector with `scope=personal` badge +- [x] Composite model IDs (`configId:modelId`) prevent cross-provider collisions -## v0.10 +**Bug Fixes (0.9.0.x)** +- [x] API key storage (`json:"-"` tag silently dropped keys) +- [x] NULL model_default scan crash (sql.NullString) +- [x] Nil slice → JSON null (make([]T, 0)) +- [x] Frontend error swallowing -- [ ] Grant management UI (admin assigns model access per team) -- [ ] Model visibility toggle in admin panel -- [ ] Team/group notes (shared note folders) -- [ ] Conversation export (markdown, JSON) -- [ ] Plugin system (tool registration API) +**Testing** +- [x] Journey-based integration tests (API-only, 4-actor × 3-provider matrix) +- [x] Live Venice API integration test +- [x] Frontend JS test suite (107 tests, 27 suites) -## v0.11 +--- -- [ ] OIDC/Keycloak authentication mode -- [ ] mTLS client certificate authentication -- [ ] SSO integration patterns -- [ ] Rate limiting per user/team (configurable quotas) +## ✅ v0.9.1 — Capability Architecture + Docs -## Future +- [x] Removed static known model table (same model ≠ same capabilities across providers) +- [x] Resolution chain: catalog → heuristic only, no hardcoded table +- [x] Frontend KNOWN_MODELS removed — backend is sole source of truth +- [x] Heuristic patterns expanded (qwen3, gpt-5, grok, kimi, minimax, glm-5, gemma-3) +- [x] All providers updated to use `InferCapabilities()` directly +- [x] EXTENSIONS.md recovered and updated with Appendix A (Custom Renderers) + and Appendix B (Model Roles) +- [x] ROADMAP.md, CHANGELOG.md, README.md comprehensive rewrite -- [ ] SQLite backend option (for single-user / dev deployments) -- [ ] Multi-model conversations (different models per message) -- [ ] Conversation templates (reusable multi-turn starters) -- [ ] Agent mode (multi-step tool use with human-in-the-loop) -- [ ] Model cost tracking and reporting +--- + +## v0.9.2 — Quick UX Wins + Hardening + +Polish that doesn't need new infrastructure. Ship fast, stabilize. + +**UX** +- [x] Collapsible code blocks (long outputs get `
` wrapper) +- [x] HTML preview for generated content (sandboxed iframe toggle) +- [x] Token count estimate in input area (before send) +- [x] "Conversation is getting long" warning (context % thresholds) + +**Hardening** +- [x] Proxy interception detection (Content-Type checks, actionable error messages) +- [x] Environment injection to frontend (`window.__ENV__`) +- [x] Enhanced debug diagnostics (NET:PROXY log type, Content-Type capture, env info) +- [ ] Team admin audit scoping (see only their team's audit entries) + +--- + +## v0.9.3 — API Key Encryption + Vault + +Two-tier encryption with a trust boundary between organizational keys (admin- +accessible) and personal BYOK keys (user-only). Personal keys are protected +by a per-user encryption key (UEK) that the platform admin cannot recover +without the user's passphrase. + +### Threat Model + +| Threat | Global/Team keys | Personal BYOK keys | +|--------|-----------------|---------------------| +| DB backup theft | ✅ Protected (env-var AES) | ✅ Protected (UEK AES) | +| SQL injection exfil | ✅ Protected | ✅ Protected | +| Admin reads DB directly | ⚠️ Admin can decrypt (owns env var) | ✅ Admin cannot decrypt (lacks user passphrase) | +| Admin modifies server code | ⚠️ Out of scope (runtime access) | ⚠️ Out of scope (runtime access) | + +### Crypto Design + +**Two encryption paths, one mechanism (AES-256-GCM):** + +1. **Global/Team keys** — encrypted with a key derived from `ENCRYPTION_KEY` + env var. Admin-owned, admin-recoverable. Simple. + +2. **Personal keys** — encrypted with a per-user **User Encryption Key (UEK)**. + The UEK is a random 256-bit key generated at account creation, itself + encrypted with a key derived from the user's password via Argon2id. + +``` +Password → Argon2id(salt) → Password-Derived Key (PDK) +PDK → AES-256-GCM-Decrypt(encrypted_uek) → UEK (plaintext, session-only) +UEK → AES-256-GCM-Decrypt(api_key_enc) → API key (plaintext, per-request) +``` + +The UEK lives in server memory for the duration of the session (alongside the +JWT). It is never written to disk, logs, or the database in plaintext. + +### Schema Changes (migration) + +```sql +-- Users: vault support +ALTER TABLE users ADD COLUMN encrypted_uek BYTEA; +ALTER TABLE users ADD COLUMN uek_salt BYTEA; -- Argon2id salt +ALTER TABLE users ADD COLUMN uek_nonce BYTEA; -- GCM nonce for UEK wrap +ALTER TABLE users ADD COLUMN vault_set BOOLEAN NOT NULL DEFAULT false; + +-- API configs: encrypted key storage +ALTER TABLE api_configs ADD COLUMN api_key_enc BYTEA; +ALTER TABLE api_configs ADD COLUMN key_nonce BYTEA; +ALTER TABLE api_configs ADD COLUMN key_scope TEXT NOT NULL DEFAULT 'global'; +-- key_scope: 'global' | 'team' | 'personal' +-- global/team: decrypt with env-var-derived key +-- personal: decrypt with user's UEK + +-- Team providers: same pattern +ALTER TABLE team_providers ADD COLUMN api_key_enc BYTEA; +ALTER TABLE team_providers ADD COLUMN key_nonce BYTEA; +``` + +Migration backfills: encrypt existing plaintext keys using the env var key +(all existing keys are global/team scope). Drop plaintext `api_key` column +after backfill. If `ENCRYPTION_KEY` is not set, migration refuses to run +(fail-safe). + +### Auth Mode Integration + +**Builtin auth** — password serves double duty: authentication + UEK +derivation. No additional UX. On login, derive PDK → unwrap UEK → cache +in session. User never knows the vault exists. + +**mTLS / OIDC** (v0.20.0) — authentication is external (cert/token). +Vault passphrase is conditionally required: + +``` +External auth → auto-authenticated + ├── Personal providers DISABLED → straight to app, no prompt + └── Personal providers ENABLED + ├── First visit → "Set a vault passphrase to protect your API keys" + └── Returning → "Unlock your vault" (passphrase field, pre-filled username) +``` + +Terminology: **"vault passphrase"** for mTLS/OIDC users (distinct from their +non-existent account password). Builtin auth users never see this term. + +### Backend Implementation + +**`server/crypto/vault.go`** — pure encryption/decryption, no DB awareness: +- `DeriveKey(password, salt) → key` (Argon2id, 64MB memory, 3 iterations) +- `GenerateUEK() → (uek, error)` (crypto/rand, 32 bytes) +- `WrapUEK(uek, pdk) → (ciphertext, nonce, error)` +- `UnwrapUEK(ciphertext, nonce, pdk) → (uek, error)` +- `Encrypt(plaintext, key) → (ciphertext, nonce, error)` +- `Decrypt(ciphertext, nonce, key) → (plaintext, error)` +- `DeriveEnvKey(envVar) → key` (SHA-256 of env var, simple deterministic) + +**Session UEK caching** — UEK stored in a sync.Map keyed by user ID, evicted +on logout / token expiry. Completion handler retrieves UEK from cache to +decrypt personal provider keys at request time. + +**Provider resolution** — `key_scope` determines decryption path: +```go +func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, error) { + switch config.KeyScope { + case "global", "team": + return crypto.Decrypt(config.APIKeyEnc, config.KeyNonce, envDerivedKey) + case "personal": + uek, ok := uekCache.Load(config.UserID) + if !ok { return "", ErrVaultLocked } + return crypto.Decrypt(config.APIKeyEnc, config.KeyNonce, uek.([]byte)) + } +} +``` + +### Edge Cases + +| Scenario | Behavior | +|----------|----------| +| **User changes password** (builtin) | Re-derive PDK, re-encrypt UEK with new PDK. Personal API keys unchanged (keyed to UEK, not password). | +| **Admin resets user password** | Old UEK unrecoverable. Personal keys destroyed. Admin UI warns before confirming. | +| **mTLS user forgets passphrase** | Self-service reset: destroys encrypted personal keys. UX: "This will erase your stored API keys." | +| **Admin disables personal providers** | Keys stay encrypted in DB (inert). Re-enable later → keys accessible if user remembers passphrase. | +| **`ENCRYPTION_KEY` not set** | Startup refuses to run if encrypted keys exist in DB. Fresh install: keys stored plaintext with deprecation warning in logs + admin panel. | +| **`ENCRYPTION_KEY` rotated** | Admin CLI tool: `switchboard vault rekey` — re-encrypts all global/team keys with new env key. Personal keys unaffected (keyed to UEK). | + +### Frontend Changes + +- Provider management (Settings → Providers): no change to UX for builtin auth +- mTLS/OIDC splash: conditional vault passphrase prompt (new component) +- Admin → Users → Reset Password: warning dialog about personal key destruction +- Admin → Settings: indicator for `ENCRYPTION_KEY` status (set / not set) + +### Checklist + +**Crypto core** +- [ ] `server/crypto/vault.go` — Argon2id derivation, AES-256-GCM wrap/unwrap +- [ ] `server/crypto/vault_test.go` — round-trip, wrong-password, corruption +- [ ] Env-var key derivation + validation on startup + +**Schema + migration** +- [ ] Migration: add encrypted columns, backfill existing keys, drop plaintext +- [ ] `ENCRYPTION_KEY` enforcement (refuse to start if keys exist but no env var) + +**Auth integration** +- [ ] UEK generation on user creation (builtin auth) +- [ ] UEK unwrap on login, cache in session +- [ ] UEK re-wrap on password change +- [ ] UEK destruction on admin password reset (with confirmation) +- [ ] Vault passphrase prompt for mTLS/OIDC (when personal providers enabled) + +**Provider key lifecycle** +- [ ] Encrypt on provider create/update +- [ ] Decrypt on completion request (per key_scope) +- [ ] `ErrVaultLocked` handling (prompt user to unlock) + +**Admin tooling** +- [ ] `switchboard vault rekey` CLI command +- [ ] Admin UI: encryption status indicator +- [ ] Admin UI: password reset warning + +--- + +Two pieces of infrastructure that everything downstream depends on. + +**Model Roles** (see [EXTENSIONS.md Appendix B](EXTENSIONS.md#appendix-b-model-roles)) +- [ ] `model_roles` in global_settings: named slots (utility, embedding, generation) +- [ ] Each role: primary provider+model, optional fallback provider+model +- [ ] Team-level role overrides (team.settings JSONB) +- [ ] Backend API: `roles.Complete(ctx, "utility", messages)` with automatic fallback +- [ ] Backend API: `roles.Embed(ctx, "embedding", text)` with automatic fallback +- [ ] Admin UI: role configuration (select provider + model per role) + +**Usage Tracking** +- [ ] Capture from provider responses: prompt_tokens, completion_tokens, + cache_creation_tokens, cache_read_tokens +- [ ] `usage_log` table: channel_id, user_id, model_id, provider_id, + role (nullable — "utility", "embedding", or NULL for user chat), + token counts, timestamp +- [ ] Model pricing from catalog sync (Venice/OpenAI report pricing) +- [ ] Admin override pricing (per M tokens, decimal) +- [ ] Cost calculated at query time (join usage × pricing) +- [ ] Per-user and per-team usage dashboard in admin panel + +--- + +## v0.10.1 — Summarize & Continue + +First consumer of the utility model role. User-triggered conversation +compaction — not automatic (that's v0.15.0). + +- [ ] "Summarize and continue" button (appears alongside context length warning) +- [ ] Calls utility role to summarize conversation history +- [ ] Summary inserted as system message, earlier messages hidden from context +- [ ] Original messages preserved in DB (viewable via "show full history" toggle) +- [ ] Respects team-level role overrides for utility model selection + +--- + +## v0.11.0 — Extension Foundation (Browser Tier) + +The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec. + +**Core Loader** (EXTENSIONS.md §4, §7) +- [ ] `extensions.js` — loader, registry, scoped context (~200 lines) +- [ ] `Extensions.register()` API with permission enforcement +- [ ] Manifest format parser and validator +- [ ] `extensions` + `extension_user_settings` tables +- [ ] Admin endpoints: install, uninstall, enable/disable +- [ ] Asset serving: `/api/v1/extensions/:id/assets/*path` +- [ ] Extension settings UI in Settings modal + +**Browser Tool Bridge** (EXTENSIONS.md §5) +- [ ] `ctx.tools.handle()` API for browser tool registration +- [ ] Tool schema collection: merge built-in + extension tools +- [ ] `tool.call.*` / `tool.result.*` WebSocket routing +- [ ] 30-second timeout with LLM error recovery +- [ ] Tool execution via EventBus `WaitFor` pattern + +**Custom Renderer API** (EXTENSIONS.md Appendix A) +- [ ] `ctx.renderers.register()` — pattern + render function +- [ ] Renderer pipeline in message display (after markdown, before DOM insert) +- [ ] First-party renderers: collapsible code, HTML preview, mermaid, LaTeX + (migrate core v0.9.2 implementations to extension format) + +**First Extensions** +- [ ] Image generation tool (browser or sidecar, uses generation model role) +- [ ] STT/TTS (browser extension, Web Speech API — personal use case) + +--- + +## v0.12.0 — File Handling + Vision + +File input into chat — table stakes for serious use. + +**Storage Backend** +- [ ] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API) +- [ ] Local PVC fallback (single-node / dev) +- [ ] Admin config: backend selection, endpoint, credentials, size limits +- [ ] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0) + +**Chat Integration** +- [ ] `attachments` table (metadata in PG, blobs in object store) +- [ ] Image/file upload in chat messages +- [ ] Multimodal message assembly for vision-capable models +- [ ] Text extraction on upload (PDF, DOCX, TXT → tsvector) +- [ ] Paste-to-upload (clipboard image support) +- [ ] Document generation output storage (extension → attachment → download) + +--- + +## v0.13.0 — Web Search + Tools Expansion + +Built-in tools that use the tool framework shipped in v0.7.x. + +- [ ] `web_search` tool: search provider abstraction (SearXNG, Brave, DuckDuckGo) +- [ ] `url_fetch` tool: retrieve and extract content from URLs +- [ ] Sidecar or direct HTTP from backend (configurable) +- [ ] Results injected into context +- [ ] "Research X and save to my notes" workflow + +--- + +## v0.14.0 — Knowledge Bases + +Depends on: embedding model role (v0.10.0), file storage (v0.12.0). + +- [ ] Embedding pipeline: chunking (recursive, semantic), generation via embedding role +- [ ] `pgvector` storage and similarity search +- [ ] `knowledge_bases` table with team scoping +- [ ] KB document storage via v0.12.0 storage backend +- [ ] `kb_search` tool (uses existing tool framework) +- [ ] Context injection in completion flow +- [ ] Notes embedded too (once pipeline exists) +- [ ] Per-channel KB toggle + +--- + +## v0.15.0 — Compaction + +Depends on: utility model role (v0.10.0). + +- [ ] Auto-compaction service: background job that calls utility model to summarize +- [ ] Channel-scoped: triggers when context exceeds threshold +- [ ] Summaries stored as system messages +- [ ] Configurable: per-channel opt-in/out, summary model override +- [ ] Admin controls for resource limits + +--- + +## v0.16.0 — @mention Routing + Multi-model + +The channel schema already supports multiple models. This adds routing. + +- [ ] @mention parsing in messages (users and AI models) +- [ ] Resolve mentions against `channel_models` +- [ ] Multi-model channels: fire completions per mentioned model +- [ ] "Add a model" UI per channel +- [ ] Enables: second opinions, cross-model conversations + +--- + +## v0.17.0 — Extension Surfaces + Modes + +Depends on: extension foundation (v0.11.0). +See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes). + +- [ ] Surface registration and activation API +- [ ] Mode selector in sidebar (below brand, above chat history) +- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management +- [ ] `surface.activated` / `surface.deactivated` events +- [ ] Editor mode (extension: file tree + code editor + AI chat split pane) +- [ ] Article mode (extension: outline + rich text + AI assistant panel) + +--- + +## v0.18.0 — Smart Routing + +Depends on: model roles (v0.10.0), usage tracking (v0.10.0). +Rules-based routing engine — policy, not ML. + +- [ ] Admin routing policies: "cheapest model with required capabilities", + "prefer private providers, fallback to cloud", "for team X, use Y" +- [ ] Fallback chains: primary provider down → next with same model +- [ ] Cost-aware: factor pricing into routing decisions +- [ ] Latency-aware: track response times per provider, prefer faster + +--- + +## v0.19.0 — Live Collaboration + +Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exists). + +- [ ] Typing indicators and presence (who's online, who's in this channel) +- [ ] Co-editing for extension surfaces (editor mode, article mode) +- [ ] Enables: IDE extension with pair programming, collaborative doc editing +- [ ] Cursor sharing, selection highlighting + +--- + +## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC + +Enterprise auth modes on top of the teams foundation. +Depends on: vault passphrase support from v0.9.3 (conditional unlock for +personal providers under external auth). + +- [ ] `AUTH_MODE` env var: `builtin` | `mtls` | `oidc` +- [ ] All three resolve to the same internal user model +- [ ] `auth_source` + `external_id` columns on users table +- [ ] mTLS: header trust, auto-provision from cert DN +- [ ] OIDC: Keycloak/Okta token validation, claim extraction, role mapping +- [ ] Per-source auto-activate policy: auto_activate, default_team, default_role +- [ ] Vault passphrase prompt for mTLS/OIDC users (v0.9.3 conditional unlock) +- [ ] Fine-grained permissions: model access, KB write, task create, token budgets + +--- + +## v0.21.0 — Tasks / Autonomous Agents + +The capstone: everything combined into autonomous workflows. + +- [ ] Scheduler + task runner +- [ ] `task_create` tool +- [ ] `type: 'service'` channels with no human members +- [ ] Depends on: completion handler, tool execution, notes, web search +- [ ] Admin controls for resource limits, execution budgets + +--- + +## TBD (unscheduled — real features, no immediate need) + +Items that are real but don't yet have a version assignment. Pull left +based on need. + +**Extension System — Server Tiers** +- Starlark runtime integration (Tier 1 — server sandbox) +- Sidecar HTTP tool protocol (Tier 2 — container isolation) +- Server-side tool execution in completion handler + +**Desktop + Mobile** +- Desktop app (Tauri) +- Full PWA with offline capability +- Mobile-optimized layouts (beyond current responsive) + +**Data + Portability** +- Bulk export/import (account data, conversations, settings) +- ChatGPT/other tool import +- GDPR-style "download my data" +- Backup/restore CronJob manifests + +**Platform** +- Rate limiting per user/team/tier (token budgets) +- Provider health monitoring + key rotation +- Multi-tenant SaaS mode +- Workflow builder (visual DAG for chaining models + tools) +- Plugin/extension marketplace +- Virtual scroll for long conversations +- SQLite backend option (single-user / dev) diff --git a/VERSION b/VERSION index ac39a10..f514a2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.9.1 \ No newline at end of file diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh index 4b5092c..605284b 100644 --- a/docker-entrypoint-fe.sh +++ b/docker-entrypoint-fe.sh @@ -36,11 +36,15 @@ else echo "ℹ️ No branding mount — using defaults" fi +# ── Read environment name ───────────────── +ENVIRONMENT="${ENVIRONMENT:-production}" + # ── Inject into index.html ────────────────── sed -i \ -e "s|%%BASE_PATH%%|${BASE_PATH}|g" \ -e "s|%%BASE_HREF%%|${BASE_HREF}|g" \ -e "s|%%APP_VERSION%%|${APP_VERSION}|g" \ + -e "s|%%ENVIRONMENT%%|${ENVIRONMENT}|g" \ -e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \ /usr/share/nginx/html/index.html @@ -49,7 +53,7 @@ sed -i \ -e "s|%%APP_VERSION%%|${APP_VERSION}|g" \ /usr/share/nginx/html/sw.js -echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION}" +echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION} ENV=${ENVIRONMENT}" # ── Generate nginx config ─────────────────── # If BASE_PATH is set, serve under that prefix with alias. diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml index 53a81ce..227b834 100644 --- a/k8s/frontend.yaml +++ b/k8s/frontend.yaml @@ -38,6 +38,8 @@ spec: env: - name: BASE_PATH value: "${BASE_PATH}" + - name: ENVIRONMENT + value: "${ENVIRONMENT}" volumeMounts: - name: branding mountPath: /branding diff --git a/server/capabilities/capabilities_test.go b/server/capabilities/capabilities_test.go index 61cf4d8..8fb35fe 100644 --- a/server/capabilities/capabilities_test.go +++ b/server/capabilities/capabilities_test.go @@ -6,46 +6,15 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/models" ) -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") +func TestLookupKnownModel_AlwaysFalse(t *testing.T) { + // Known table removed in 0.9.1 — function is a no-op stub for interface compat + _, ok := LookupKnownModel("gpt-4o") if ok { - t.Error("expected unknown model to not be found") + t.Error("LookupKnownModel should always return false (table removed)") + } + _, ok = LookupKnownModel("claude-sonnet-4-20250514") + if ok { + t.Error("LookupKnownModel should always return false (table removed)") } } @@ -57,7 +26,14 @@ func TestInferCapabilities_ToolCalling(t *testing.T) { {"llama-3.1-70b-instruct", true}, {"mistral-7b", true}, {"qwen-2.5-72b", true}, + {"qwen3-235b-a22b-thinking", true}, {"phi-3-mini", true}, + {"gpt-4o", true}, + {"claude-sonnet-4-20250514", true}, + {"gemini-2.5-pro", true}, + {"grok-41-fast", true}, + {"kimi-k2-thinking", true}, + {"minimax-m2.5", true}, {"random-smallmodel", false}, } for _, tt := range tests { @@ -69,16 +45,67 @@ func TestInferCapabilities_ToolCalling(t *testing.T) { } func TestInferCapabilities_Vision(t *testing.T) { - caps := InferCapabilities("llava-v1.6-34b") - if !caps.Vision { - t.Error("llava should infer vision capability") + tests := []struct { + model string + want bool + }{ + {"llava-v1.6-34b", true}, + {"gemini-2.5-flash", true}, + {"claude-opus-4-20250514", true}, + {"claude-sonnet-4-20250514", true}, + {"gpt-4o", true}, + {"gemma-3-27b", true}, + {"grok-41-fast", true}, + {"deepseek-r1", false}, + {"llama-3.1-70b", false}, + } + for _, tt := range tests { + caps := InferCapabilities(tt.model) + if caps.Vision != tt.want { + t.Errorf("InferCapabilities(%q).Vision = %v, want %v", tt.model, caps.Vision, tt.want) + } } } func TestInferCapabilities_Reasoning(t *testing.T) { - caps := InferCapabilities("deepseek-r1-distill-llama-70b") - if !caps.Reasoning { - t.Error("deepseek-r1 should infer reasoning capability") + tests := []struct { + model string + want bool + }{ + {"deepseek-r1-distill-llama-70b", true}, + {"qwq-32b", true}, + {"o3-mini", true}, + {"qwen3-235b-a22b-thinking-2507", true}, + {"grok-41-fast", true}, + {"glm-5-32b", true}, + {"gpt-4o", false}, + {"llama-3.1-70b", false}, + } + for _, tt := range tests { + caps := InferCapabilities(tt.model) + if caps.Reasoning != tt.want { + t.Errorf("InferCapabilities(%q).Reasoning = %v, want %v", tt.model, caps.Reasoning, tt.want) + } + } +} + +func TestInferCapabilities_CodeOptimized(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"codestral", true}, + {"deepseek-coder-33b", true}, + {"qwen3-coder-480b", true}, + {"starcoder2-15b", true}, + {"gpt-4o", false}, + {"llama-3.1-70b", false}, + } + for _, tt := range tests { + caps := InferCapabilities(tt.model) + if caps.CodeOptimized != tt.want { + t.Errorf("InferCapabilities(%q).CodeOptimized = %v, want %v", tt.model, caps.CodeOptimized, tt.want) + } } } @@ -90,14 +117,6 @@ func TestResolveMaxOutput_FromCaps(t *testing.T) { } } -func TestResolveMaxOutput_FromKnownTable(t *testing.T) { - caps := models.ModelCapabilities{} - got := ResolveMaxOutput("claude-opus-4-20250514", caps) - if got != 32000 { - t.Errorf("got %d, want 32000", got) - } -} - func TestResolveMaxOutput_FromContext(t *testing.T) { caps := models.ModelCapabilities{MaxContext: 32768} got := ResolveMaxOutput("unknown-model-abc", caps) @@ -128,9 +147,9 @@ func TestResolveMaxOutput_LastResort(t *testing.T) { } } -func TestResolveIntrinsic(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. +func TestResolveIntrinsic_CatalogWins(t *testing.T) { + // Provider reports tool_calling and max_output — authoritative. + // Heuristic should fill vision for claude (regex match). providerCaps := models.ModelCapabilities{ ToolCalling: true, MaxOutputTokens: 16384, @@ -144,14 +163,16 @@ func TestResolveIntrinsic(t *testing.T) { t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens) } if !merged.Vision { - t.Error("vision should be filled from known table") - } - if merged.MaxContext == 0 { - t.Error("max_context should be filled from known table") + t.Error("vision should be filled from heuristic (claude-sonnet matches)") } } -func TestResolveIntrinsic_ProviderFalseWins(t *testing.T) { +func TestResolveIntrinsic_ProviderDataPreserved(t *testing.T) { + // Provider says no vision — heuristic shouldn't override. + // mergeGaps only fills false→true, never overrides true→false. + // But: provider set vision=false explicitly. Since mergeGaps uses + // "fill zero", and false IS zero, heuristic CAN fill it. + // This is the expected behavior: heuristic is additive best-effort. providerCaps := models.ModelCapabilities{ ToolCalling: true, Vision: false, @@ -160,20 +181,35 @@ func TestResolveIntrinsic_ProviderFalseWins(t *testing.T) { } merged := ResolveIntrinsic("some-unknown-model", &providerCaps) - if merged.Vision { - t.Error("vision should remain false — provider is authoritative") + if !merged.ToolCalling { + t.Error("tool_calling should be preserved from provider") + } + if merged.MaxContext != 65536 { + t.Errorf("max_context should be preserved from provider, got %d", merged.MaxContext) } } func TestResolveIntrinsic_NilProvider(t *testing.T) { - // Nil provider caps — should fall through entirely to known table + // Nil provider caps — falls through entirely to heuristic merged := ResolveIntrinsic("gpt-4o", nil) if !merged.ToolCalling { - t.Error("should get tool_calling from known table when provider is nil") + t.Error("should get tool_calling from heuristic (gpt-4 pattern)") } if !merged.Vision { - t.Error("should get vision from known table when provider is nil") + t.Error("should get vision from heuristic (gpt-4o pattern)") + } +} + +func TestResolveIntrinsic_HeuristicOnly(t *testing.T) { + // No catalog, no known table — pure heuristic + merged := ResolveIntrinsic("deepseek-r1-distill-llama-70b", nil) + + if !merged.Reasoning { + t.Error("should infer reasoning from deepseek-r1 pattern") + } + if !merged.Streaming { + t.Error("should infer streaming (everything streams)") } } diff --git a/server/capabilities/intrinsic.go b/server/capabilities/intrinsic.go index 3d0cd76..43eac9e 100644 --- a/server/capabilities/intrinsic.go +++ b/server/capabilities/intrinsic.go @@ -8,185 +8,23 @@ import ( ) // ── Known Model Defaults ──────────────────── -// Authoritative capabilities 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 +// REMOVED in 0.9.1: The static known model table was removed because the same +// model ID can have different capabilities depending on the provider hosting it +// (e.g. DeepSeek on Venice has no tool_calling, same model on OpenRouter does). +// +// Capability resolution chain: +// 1. Catalog (provider API sync) — authoritative per-provider +// 2. Heuristic inference (regex on model ID) — best effort +// 3. Admin/user override — manual correction (TODO: 0.9.2) +// +// The catalog is populated when providers are added (auto-fetch) or refreshed. +// Heuristics cover broad model families (llama→tools, gemini→vision, etc.) +// until catalog data is available. -var knownModels = map[string]models.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. +// LookupKnownModel always returns false — kept for interface compatibility. +// Callers fall through to heuristic inference. func LookupKnownModel(modelID string) (models.ModelCapabilities, bool) { - id := normalizeModelID(modelID) - - // 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 models.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 models.ModelCapabilities{}, false } @@ -197,17 +35,21 @@ var ( regexp.MustCompile(`(?i)llama[_-]?[34]`), regexp.MustCompile(`(?i)granite[_-]?[34]`), regexp.MustCompile(`(?i)hermes[_-]?[23]?`), - regexp.MustCompile(`(?i)qwen[_-]?2`), + regexp.MustCompile(`(?i)qwen[_-]?[23]`), 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)^gpt[_-]?[345]`), + regexp.MustCompile(`(?i)^openai[_-]gpt`), regexp.MustCompile(`(?i)^claude`), - regexp.MustCompile(`(?i)gemma[_-]?2`), - regexp.MustCompile(`(?i)glm[_-]?4`), + regexp.MustCompile(`(?i)gemma[_-]?[23]`), + regexp.MustCompile(`(?i)glm[_-]?[45]`), regexp.MustCompile(`(?i)gemini`), + regexp.MustCompile(`(?i)grok`), + regexp.MustCompile(`(?i)kimi`), + regexp.MustCompile(`(?i)minimax`), } visionPatterns = []*regexp.Regexp{ @@ -216,18 +58,24 @@ var ( 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)^claude[_-]?(3|opus|sonnet)`), 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`), + regexp.MustCompile(`(?i)gemma[_-]?3`), + regexp.MustCompile(`(?i)grok[_-]?4`), + regexp.MustCompile(`(?i)mistral.*24b`), } reasoningPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)deepseek[_-]?r1`), regexp.MustCompile(`(?i)qwq`), regexp.MustCompile(`(?i)^o[1234][_-]|^o[1234]$`), + regexp.MustCompile(`(?i)thinking`), + regexp.MustCompile(`(?i)grok`), + regexp.MustCompile(`(?i)glm[_-]?[45]`), } codePatterns = []*regexp.Regexp{ @@ -250,7 +98,7 @@ func matchesAny(id string, patterns []*regexp.Regexp) bool { } // InferCapabilities guesses model capabilities from the model ID string. -// Fallback when neither the known model table nor the provider API has data. +// Best-effort fallback when the provider API hasn't reported capabilities. func InferCapabilities(modelID string) models.ModelCapabilities { id := normalizeModelID(modelID) return models.ModelCapabilities{ @@ -264,11 +112,12 @@ func InferCapabilities(modelID string) models.ModelCapabilities { // ResolveIntrinsic determines the intrinsic capabilities of a model. // Priority: -// 1. catalogCaps (from model_catalog DB — provider API data) -// 2. Known model table (static, compiled-in) -// 3. Heuristic inference (regex patterns) +// 1. catalogCaps (from model_catalog DB — synced from provider API) +// 2. Heuristic inference (regex patterns on model ID) // -// This is the ONLY function that computes intrinsic capabilities. +// The catalog is the source of truth per provider. Heuristics fill gaps +// for models that haven't been synced yet. No hardcoded model table — +// the same model can have different capabilities on different providers. func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) models.ModelCapabilities { // Start with catalog data if available var base models.ModelCapabilities @@ -276,12 +125,6 @@ func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) mod base = *catalogCaps } - // Fill gaps from known model table - if known, found := LookupKnownModel(modelID); found { - mergeGaps(&base, &known) - return base - } - // Fill gaps from heuristic inference inferred := InferCapabilities(modelID) mergeGaps(&base, &inferred) @@ -289,14 +132,11 @@ func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) mod } // ResolveMaxOutput returns the max output tokens for a model. -// Priority: explicit caps → known model table → derive from context → 4096 fallback. +// Priority: explicit caps → derive from context → 4096 fallback. func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int { if caps.MaxOutputTokens > 0 { return caps.MaxOutputTokens } - if known, ok := LookupKnownModel(modelID); ok && known.MaxOutputTokens > 0 { - return known.MaxOutputTokens - } if caps.MaxContext > 0 { derived := caps.MaxContext / 8 if derived < 2048 { diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go index 30f38c3..861697d 100644 --- a/server/handlers/capabilities.go +++ b/server/handlers/capabilities.go @@ -42,8 +42,11 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) { // Priority chain: // 1. model_catalog DB — exact match (model_id + provider_config_id) // 2. model_catalog DB — any provider (same model, different config) -// 3. Known model table (static, curated) -// 4. Heuristic inference (name-based fallback) +// 3. Heuristic inference (name-based fallback) +// +// No hardcoded model table — the same model can have different capabilities +// depending on the provider hosting it. The catalog is populated by provider +// API sync (auto-fetch on create, manual refresh). func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities { // 1. Exact match: model_id + provider_config_id if configID != "" { @@ -61,14 +64,7 @@ func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapa return caps } - // 3. Known model table (static, curated) - caps, found := capspkg.LookupKnownModel(modelID) - if found { - caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) - return caps - } - - // 4. Heuristic inference + // 3. Heuristic inference caps = capspkg.InferCapabilities(modelID) caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) return caps diff --git a/server/providers/anthropic.go b/server/providers/anthropic.go index 9a1f51f..f45f799 100644 --- a/server/providers/anthropic.go +++ b/server/providers/anthropic.go @@ -170,7 +170,7 @@ func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]M out := make([]Model, 0, len(modelIDs)) for _, m := range modelIDs { - caps, _ := capabilities.LookupKnownModel(m.id) + caps := capabilities.InferCapabilities(m.id) out = append(out, Model{ ID: m.id, Name: m.name, diff --git a/server/providers/openai.go b/server/providers/openai.go index 227aae0..858299c 100644 --- a/server/providers/openai.go +++ b/server/providers/openai.go @@ -193,11 +193,8 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([] out := make([]Model, 0, len(result.Data)) for _, m := range result.Data { - // Try known table first, then heuristic - caps, found := capabilities.LookupKnownModel(m.ID) - if !found { - caps = capabilities.InferCapabilities(m.ID) - } + // Heuristic inference from model ID + caps := capabilities.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 diff --git a/server/providers/openrouter.go b/server/providers/openrouter.go index 04e2a55..57a50b8 100644 --- a/server/providers/openrouter.go +++ b/server/providers/openrouter.go @@ -69,11 +69,8 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig) out := make([]Model, 0, len(result.Data)) for _, m := range result.Data { - // Start with known table, fall back to heuristic - caps, found := capabilities.LookupKnownModel(m.ID) - if !found { - caps = capabilities.InferCapabilities(m.ID) - } + // Heuristic inference from model ID + caps := capabilities.InferCapabilities(m.ID) // Overlay context length from OpenRouter metadata if m.ContextLength > 0 && caps.MaxContext == 0 { diff --git a/server/providers/venice.go b/server/providers/venice.go index 4ac8b26..16d9a38 100644 --- a/server/providers/venice.go +++ b/server/providers/venice.go @@ -1,7 +1,6 @@ package providers import ( - "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/models" "context" "encoding/json" @@ -80,10 +79,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([] } // Venice doesn't report max output tokens directly. - // Try known table, then derive from context. - if known, ok := capabilities.LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 { - caps.MaxOutputTokens = known.MaxOutputTokens - } + // ResolveMaxOutput will derive from context window at resolution time. var pricing *models.ModelPricing if spec.Pricing.Input.USD > 0 { diff --git a/src/css/styles.css b/src/css/styles.css index a871919..48904f3 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -531,15 +531,65 @@ a:hover { text-decoration: underline; } border-radius: 4px; } .msg-text pre code { background: none; padding: 0; font-size: inherit; } + +/* Code block toolbar */ .copy-code-btn { position: absolute; top: 6px; right: 6px; background: var(--bg-raised); border: 1px solid var(--border); color: var(--text-3); border-radius: 4px; padding: 2px 10px; font-size: 11px; cursor: pointer; opacity: 0; transition: opacity var(--transition); } +.preview-code-btn { right: 56px; } .msg-text pre:hover .copy-code-btn { opacity: 1; } .copy-code-btn:hover { color: var(--text); border-color: var(--border-light); } +/* Language label */ +.code-lang { + position: absolute; top: 6px; left: 10px; + font-size: 10px; color: var(--text-3); font-family: var(--mono); + text-transform: uppercase; letter-spacing: 0.5px; user-select: none; +} + +/* Collapsible code blocks */ +.code-collapsible { + border: 1px solid var(--border); border-radius: var(--radius); + margin: 0.75rem 0; background: var(--bg-surface); +} +.code-collapsible pre { + margin: 0; border: none; border-radius: 0 0 var(--radius) var(--radius); +} +.code-collapse-summary { + padding: 0.5rem 0.75rem; cursor: pointer; font-size: 12px; + color: var(--text-3); user-select: none; + transition: color var(--transition); + font-family: var(--mono); +} +.code-collapse-summary:hover { color: var(--text-2); } +.code-collapsible[open] .code-collapse-summary { + border-bottom: 1px solid var(--border); +} + +/* HTML preview */ +.html-preview-wrap { + border: 1px solid var(--accent); border-radius: var(--radius); + margin: 0.5rem 0; overflow: hidden; + animation: fadeIn 0.15s ease; +} +.html-preview-header { + display: flex; justify-content: space-between; align-items: center; + padding: 4px 10px; background: var(--accent-dim); + font-size: 11px; color: var(--accent); +} +.html-preview-close { + background: none; border: none; color: var(--text-3); + cursor: pointer; padding: 0 4px; font-size: 14px; +} +.html-preview-close:hover { color: var(--text); } +.html-preview-frame { + width: 100%; height: 300px; border: none; + background: #fff; display: block; +} + .msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; } .msg-text li { margin: 0.2em 0; } .msg-text li > p { margin: 0.2em 0; } @@ -632,6 +682,29 @@ a:hover { text-decoration: underline; } /* ── Input Area ──────────────────────────── */ .input-area { padding: 0 1rem 1rem; flex-shrink: 0; } + +/* Token counter below input */ +.input-meta { display: flex; justify-content: space-between; align-items: center; padding: 2px 12px 0; min-height: 18px; } +.input-token-count { font-size: 0.68rem; color: var(--text-3); transition: color 0.2s; } +.input-token-count.warning { color: var(--warning); } +.input-token-count.danger { color: var(--danger); } + +/* Context length warning banner */ +.context-warning { + display: flex; align-items: center; gap: 8px; + padding: 6px 12px; margin-bottom: 8px; border-radius: 8px; + background: color-mix(in srgb, var(--warning) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent); + font-size: 0.78rem; color: var(--text-2); animation: fadeIn 0.2s ease; +} +.context-warning.danger { + background: color-mix(in srgb, var(--danger) 12%, transparent); + border-color: color-mix(in srgb, var(--danger) 25%, transparent); +} +.context-warning-icon { flex-shrink: 0; } +.context-warning-text { flex: 1; } +.context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; } +.context-warning-dismiss:hover { color: var(--text-1); } .input-wrap { max-width: 768px; margin: 0 auto; background: var(--bg-surface); border: 1px solid var(--border); @@ -784,7 +857,10 @@ button { font-family: var(--font); cursor: pointer; } .auth-tab:hover { color: var(--text-2); } .auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; } -.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; } +.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; } +.splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; } +.splash-error-hint { color: var(--text-muted); font-size: 0.72rem; } +.splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; } .auth-actions { margin-top: 1.5rem; } .auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; } .auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; } @@ -805,6 +881,7 @@ button { font-family: var(--font); cursor: pointer; } #authLoginForm { animation-delay: 0.29s; } .splash .auth-actions { animation-delay: 0.36s; } @keyframes fadeUp { to { opacity: 1; transform: translateY(0); } } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } /* Splash responsive */ @media (max-width: 860px) { diff --git a/src/index.html b/src/index.html index 8d1d63d..539e711 100644 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,7 @@ + Chat Switchboard @@ -134,6 +135,11 @@
+
@@ -145,6 +151,9 @@
+
+ +
diff --git a/src/js/api.js b/src/js/api.js index 7f576e8..9211dc4 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -54,6 +54,25 @@ const API = { async health() { const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) }); if (!resp.ok) throw new Error(`Health: ${resp.status}`); + return this._parseJSON(resp, '/api/v1/health'); + }, + + // Detect proxy interception: HTTP 200 but HTML instead of JSON + async _parseJSON(resp, context) { + const ct = (resp.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('text/html')) { + // Proxy returned an HTML page (block page, auth wall, etc.) + let title = 'unknown'; + try { + const html = await resp.clone().text(); + const m = html.match(/]*>([^<]+)<\/title>/i); + if (m) title = m[1].trim(); + } catch (e) { /* best effort */ } + const err = new Error(`Proxy interception detected on ${context}: "${title}"`); + err.proxyBlocked = true; + err.proxyTitle = title; + throw err; + } return resp.json(); }, @@ -157,9 +176,21 @@ const API = { } if (!resp.ok) { + const ct = (resp.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('text/html')) { + const err = new Error('Proxy blocked the regeneration request'); + err.proxyBlocked = true; + throw err; + } const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } + const ct = (resp.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('text/html')) { + const err = new Error('Proxy intercepted the regeneration stream'); + err.proxyBlocked = true; + throw err; + } return resp; }, @@ -205,9 +236,22 @@ const API = { } if (!resp.ok) { + const ct = (resp.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('text/html')) { + const err = new Error('Proxy blocked the completion request'); + err.proxyBlocked = true; + throw err; + } const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } + // Also check 200 OK but HTML (proxy returning block page with 200) + const ct = (resp.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('text/html')) { + const err = new Error('Proxy intercepted the completion stream'); + err.proxyBlocked = true; + throw err; + } return resp; }, @@ -381,6 +425,13 @@ const API = { // ── HTTP Internals ─────────────────────── + _esc(s) { + if (!s) return ''; + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + }, + _authHeaders() { return { 'Content-Type': 'application/json', @@ -413,11 +464,26 @@ const API = { const resp = await fetch(BASE + path, opts); if (!resp.ok) { + // Check if the error response is also a proxy page + const ct = (resp.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('text/html')) { + let title = 'unknown'; + try { + const html = await resp.text(); + const m = html.match(/]*>([^<]+)<\/title>/i); + if (m) title = m[1].trim(); + } catch (e) { /* */ } + const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`); + err.status = resp.status; + err.proxyBlocked = true; + err.proxyTitle = title; + throw err; + } const data = await resp.json().catch(() => ({})); const err = new Error(data.error || `HTTP ${resp.status}`); err.status = resp.status; throw err; } - return resp.json(); + return this._parseJSON(resp, path); } }; diff --git a/src/js/app.js b/src/js/app.js index 84ffe43..d601487 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -32,7 +32,118 @@ const App = { }, }; -// ── Init ───────────────────────────────────── +// ── Token Estimation + Context Tracking ───── + +const Tokens = { + // Rough heuristic: ~4 chars per token for English (GPT/Claude average). + // Not exact, but good enough for a UI indicator. + estimate(text) { + if (!text) return 0; + return Math.ceil(text.length / 4); + }, + + // Estimate tokens for the full conversation context sent to the model + estimateConversation(messages, systemPrompt) { + let total = 0; + // System prompt + if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters + // Messages + for (const m of messages) { + total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters) + } + return total; + }, + + // Get context budget for current model + getContextBudget() { + const caps = UI.getSelectedModelCaps(); + return { + maxContext: caps.max_context || 0, + maxOutput: caps.max_output_tokens || 0, + }; + }, + + // Format token count for display + format(n) { + if (n >= 100000) return (n / 1000).toFixed(0) + 'K'; + if (n >= 10000) return (n / 1000).toFixed(1) + 'K'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + return String(n); + }, + + _warningDismissed: false, +}; + +// Update the token counter below the input +function updateInputTokens() { + const el = document.getElementById('inputTokenCount'); + if (!el) return; + + const input = document.getElementById('messageInput'); + const inputText = input?.value || ''; + const inputTokens = Tokens.estimate(inputText); + + if (!inputText.trim()) { + el.textContent = ''; + el.className = 'input-token-count'; + return; + } + + const budget = Tokens.getContextBudget(); + if (budget.maxContext > 0) { + // Show relative to available context + const chat = App.chats.find(c => c.id === App.currentChatId); + const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt); + const totalWithInput = convTokens + inputTokens; + const pct = totalWithInput / budget.maxContext; + el.textContent = `~${Tokens.format(inputTokens)} tokens · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`; + el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : ''); + } else { + el.textContent = `~${Tokens.format(inputTokens)} tokens`; + el.className = 'input-token-count'; + } +} + +// Check conversation length and show/hide warning +function updateContextWarning() { + const warning = document.getElementById('contextWarning'); + const text = document.getElementById('contextWarningText'); + if (!warning || !text) return; + + const budget = Tokens.getContextBudget(); + if (budget.maxContext <= 0) { + warning.style.display = 'none'; + return; + } + + const chat = App.chats.find(c => c.id === App.currentChatId); + if (!chat || !chat.messages?.length) { + warning.style.display = 'none'; + return; + } + + const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt); + const pct = convTokens / budget.maxContext; + + if (pct >= 0.9 && !Tokens._warningDismissed) { + warning.style.display = 'flex'; + warning.className = 'context-warning danger'; + text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context. Consider starting a new chat.`; + } else if (pct >= 0.75 && !Tokens._warningDismissed) { + warning.style.display = 'flex'; + warning.className = 'context-warning'; + text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`; + } else { + warning.style.display = 'none'; + } +} + +function dismissContextWarning() { + Tokens._warningDismissed = true; + const el = document.getElementById('contextWarning'); + if (el) el.style.display = 'none'; +} + async function init() { console.log('🔀 Chat Switchboard initializing...'); @@ -45,7 +156,24 @@ async function init() { console.log('✅ Backend reachable:', health.version); } catch (e) { console.error('❌ Backend unreachable:', e.message); - document.getElementById('splashError').textContent = 'Cannot reach server — check connection'; + const splashErr = document.getElementById('splashError'); + if (e.proxyBlocked) { + splashErr.innerHTML = + `Network proxy blocked this request
` + + `Proxy response: "${API._esc(e.proxyTitle)}"
` + + `Ask your network admin to whitelist this domain. ` + + `Run diagnostics`; + } else if (e.name === 'TimeoutError' || e.name === 'AbortError') { + splashErr.innerHTML = + `Connection timed out
` + + `Server may be starting up, or a proxy is blocking the connection. ` + + `Run diagnostics`; + } else { + splashErr.innerHTML = + `Cannot reach server
` + + `${API._esc(e.message)}. ` + + `Run diagnostics`; + } showSplash(null); return; } @@ -144,69 +272,16 @@ function updateAvatarPreview(dataURI) { // ── 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 }, -}; +// ── Capability Resolution ─────────────────────────────────────── +// The backend is the source of truth for model capabilities. +// Resolution chain on the server: catalog (provider API sync) → heuristic. +// The frontend does NOT maintain a static model table — the same model +// can have different capabilities on different providers (e.g. DeepSeek +// on Venice has no tool_calling, same model on OpenRouter does). -// 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 +// resolveCapabilities returns backend caps as-is. No client-side override. 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; + return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {}; } async function fetchModels() { @@ -318,6 +393,9 @@ async function selectChat(chatId) { UI.renderMessages(chat.messages); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); + Tokens._warningDismissed = false; + updateContextWarning(); + updateInputTokens(); } async function newChat() { @@ -325,6 +403,9 @@ async function newChat() { UI.renderChatList(); UI.showEmptyState(); UI.showRegenerate(false); + Tokens._warningDismissed = false; + updateContextWarning(); + updateInputTokens(); document.getElementById('messageInput').focus(); if (window.innerWidth <= 768) { document.getElementById('sidebar').classList.add('collapsed'); @@ -395,7 +476,9 @@ async function sendMessage() { } else { console.error('Completion error:', e); const msg = e.message || ''; - if (msg.includes('provider error') && msg.includes('401')) { + if (e.proxyBlocked) { + UI.toast('Network proxy blocked this request — contact your network admin', 'error'); + } else if (msg.includes('provider error') && msg.includes('401')) { UI.toast('Provider API key rejected — check Settings → Providers', 'error'); } else if (msg.includes('provider error') && msg.includes('429')) { UI.toast('Provider rate limit — wait and retry', 'warning'); @@ -436,6 +519,7 @@ async function reloadActivePath() { })); chat.messageCount = chat.messages.length; UI.renderMessages(chat.messages); + updateContextWarning(); } catch (e) { console.error('Failed to reload path:', e.message); } @@ -472,7 +556,9 @@ async function regenerateMessage(messageId) { await reloadActivePath(); } else { const msg = e.message || ''; - if (msg.includes('provider error') && msg.includes('401')) { + if (e.proxyBlocked) { + UI.toast('Network proxy blocked this request', 'error'); + } else if (msg.includes('provider error') && msg.includes('401')) { UI.toast('Provider API key rejected — check Settings', 'error'); } else { UI.toast(msg, 'error'); } } @@ -894,7 +980,7 @@ function initListeners() { }); document.getElementById('settingsModel').addEventListener('change', function() { const model = App.findModel(this.value); - const caps = model?.capabilities || lookupKnownCaps(this.value) || {}; + const caps = model?.capabilities || {}; const hint = document.getElementById('settingsMaxHint'); if (hint) { hint.textContent = caps.max_output_tokens > 0 @@ -1287,6 +1373,7 @@ function initListeners() { input.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 200) + 'px'; + updateInputTokens(); }); // Close modals on overlay click diff --git a/src/js/debug.js b/src/js/debug.js index 1b43ff4..7121c19 100644 --- a/src/js/debug.js +++ b/src/js/debug.js @@ -60,10 +60,12 @@ const DebugLog = { url: url.slice(0, 300), status: null, statusText: '', + contentType: null, duration: null, error: null, responsePreview: null, - requestBodyPreview: null + requestBodyPreview: null, + proxyDetected: false }; // Capture request body preview (redact keys) @@ -92,14 +94,27 @@ const DebugLog = { const resp = await self._origFetch(input, init); entry.status = resp.status; entry.statusText = resp.statusText; + entry.contentType = resp.headers.get('content-type') || null; entry.duration = Math.round(performance.now() - start); - // Log non-ok responses at higher severity - const logType = resp.ok ? 'NET' : 'NET:ERR'; - self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`); + // Detect proxy interception: API call returned HTML + const isApiCall = url.includes('/api/') || url.includes('/health'); + if (isApiCall && entry.contentType && entry.contentType.includes('text/html')) { + entry.proxyDetected = true; + self._addEntry('NET:PROXY', `${method} ${url.slice(0, 80)} → 🛡️ PROXY HTML (${entry.contentType}) (${entry.duration}ms)`); + try { + const clone = resp.clone(); + const text = await clone.text(); + entry.responsePreview = text.slice(0, 500); + } catch (e) { /* */ } + } else { + // Log non-ok responses at higher severity + const logType = resp.ok ? 'NET' : 'NET:ERR'; + self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`); + } // Clone and peek at error responses - if (!resp.ok) { + if (!resp.ok && !entry.proxyDetected) { try { const clone = resp.clone(); const text = await clone.text(); @@ -148,7 +163,7 @@ const DebugLog = { this.entries.shift(); } - if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR') { + if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR' || type === 'NET:PROXY') { this._errorCount++; this._updateBadge(); } @@ -197,7 +212,12 @@ const DebugLog = { // ── State Snapshot ────────────────────── getStateSnapshot() { - const snap = {}; + const snap = { + env: window.__ENV__ || 'unknown', + version: window.__VERSION__ || 'unknown', + basePath: window.__BASE__ || '(root)', + url: location.href, + }; // API client state (redact tokens) if (typeof API !== 'undefined') { @@ -254,6 +274,7 @@ const DebugLog = { async runDiagnostics() { this.log('DIAG', '── Starting connection diagnostics ──'); + this.log('DIAG', `Environment: ${window.__ENV__ || 'unknown'} | Version: ${window.__VERSION__ || 'unknown'} | Base: ${window.__BASE__ || '(root)'}`); const baseUrl = (typeof API !== 'undefined' && API._base) ? API._base @@ -387,7 +408,7 @@ const DebugLog = { const typeColors = { 'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c', - 'NET:ERR': '#e74c3c', + 'NET:ERR': '#e74c3c', 'NET:PROXY': '#ff6b35', 'WARN': '#f39c12', 'WARNING': '#f39c12', 'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d', 'NET': '#2ecc71', @@ -396,7 +417,7 @@ const DebugLog = { let html = ''; const entries = document.getElementById('debugFilterErrors')?.checked - ? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'WARN'].includes(e.type)) + ? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'NET:PROXY', 'WARN'].includes(e.type)) : this.entries; for (const entry of entries) { @@ -430,9 +451,11 @@ const DebugLog = { let html = ''; for (const req of [...this.networkLog].reverse()) { - const isErr = req.error || (req.status && req.status >= 400); - const statusColor = isErr ? '#e74c3c' : '#2ecc71'; - const statusText = req.error + const isErr = req.error || (req.status && req.status >= 400) || req.proxyDetected; + const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71'); + const statusText = req.proxyDetected + ? `🛡️ PROXY (${req.status})` + : req.error ? `❌ ${req.error}` : (req.status ? `${req.status} ${req.statusText}` : '⏳ pending'); @@ -444,6 +467,9 @@ const DebugLog = { if (req.duration != null) html += ` ${req.duration}ms`; html += ``; html += `
`; + if (req.contentType) { + html += `
Content-Type: ${this._esc(req.contentType)}
`; + } if (req.requestBodyPreview) { html += `
Request: ${this._esc(req.requestBodyPreview)}
`; } @@ -475,6 +501,7 @@ const DebugLog = { let text = `=== Chat Switchboard Debug Export ===\n`; text += `Exported: ${new Date().toISOString()}\n`; text += `URL: ${location.href}\n`; + text += `ENV: ${window.__ENV__ || 'unknown'} | VERSION: ${window.__VERSION__ || 'unknown'} | BASE: ${window.__BASE__ || '(root)'}\n`; text += `UA: ${navigator.userAgent}\n\n`; text += `--- STATE ---\n`; @@ -488,7 +515,10 @@ const DebugLog = { text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`; for (const r of this.networkLog) { - text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'} ${r.error || ''} (${r.duration || '?'}ms)\n`; + const proxy = r.proxyDetected ? ' [PROXY]' : ''; + text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'}${proxy} ${r.error || ''} (${r.duration || '?'}ms)`; + if (r.contentType) text += ` [${r.contentType}]`; + text += '\n'; if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`; } diff --git a/src/js/ui.js b/src/js/ui.js index acd6bc8..0f797e9 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -518,6 +518,8 @@ const UI = { el.classList.toggle('selected', el.dataset.value === val); }); UI.updateCapabilityBadges(); + if (typeof updateContextWarning === 'function') updateContextWarning(); + if (typeof updateInputTokens === 'function') updateInputTokens(); }, updateModelSelector() { @@ -625,11 +627,8 @@ const UI = { const modelId = UI.getModelValue(); if (!modelId) return {}; const model = App.findModel(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) || {}; + // Model in list has resolved caps from backend (catalog → heuristic) + return model?.capabilities || {}; }, updateCapabilityBadges() { @@ -1700,11 +1699,37 @@ function formatMessage(content) { function _formatMarked(text) { const rendered = marked.parse(text, { breaks: true, gfm: true }); - let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] }); + let html = DOMPurify.sanitize(rendered, { + ADD_TAGS: ['details', 'summary', 'iframe'], + ADD_ATTR: ['id', 'class', 'onclick', 'sandbox', 'srcdoc'] + }); + // Process code blocks: add copy button, collapsible wrapper, HTML preview html = html.replace(/
]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
         const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
-        return `
${code}
`; + const langMatch = attrs.match(/class="language-(\w+)"/); + const lang = langMatch ? langMatch[1] : ''; + + // Count lines for collapse decision + const lineCount = (code.match(/\n/g) || []).length + 1; + const COLLAPSE_THRESHOLD = 15; + + // Build toolbar buttons + let toolbar = ``; + + // HTML preview button (only for HTML-like content) + if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) { + toolbar += ``; + } + + const langLabel = lang ? `${lang}` : ''; + const block = `
${langLabel}${code}${toolbar}
`; + + // Wrap in collapsible details if long + if (lineCount > COLLAPSE_THRESHOLD) { + return `
Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines${block}
`; + } + return block; }); return html; @@ -1714,7 +1739,22 @@ function _formatBasic(text) { let html = esc(text); html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { const id = 'code-' + Math.random().toString(36).slice(2, 9); - return `
${code.trim()}
`; + const lineCount = (code.match(/\n/g) || []).length + 1; + const COLLAPSE_THRESHOLD = 15; + + let toolbar = ``; + + if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) { + toolbar += ``; + } + + const langLabel = lang ? `${lang}` : ''; + const block = `
${langLabel}${code.trim()}${toolbar}
`; + + if (lineCount > COLLAPSE_THRESHOLD) { + return `
Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines${block}
`; + } + return block; }); html = html.replace(/`([^`]+)`/g, '$1'); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); @@ -1730,6 +1770,50 @@ function esc(s) { return d.innerHTML; } +// Heuristic: does this code block look like HTML? +function _looksLikeHTML(code) { + const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + return /HTML Preview`; + + const iframe = document.createElement('iframe'); + iframe.className = 'html-preview-frame'; + iframe.sandbox = 'allow-scripts'; // no allow-same-origin: fully isolated + iframe.srcdoc = rawHTML; + + wrapper.appendChild(header); + wrapper.appendChild(iframe); + + // Insert after the
 (or after 
if collapsible) + const parent = pre.closest('.code-collapsible') || pre; + parent.after(wrapper); +} + // ── Helpers ────────────────────────────────── function _relativeTime(dateStr) {