Changeset 0.9.1 (#51)

This commit is contained in:
2026-02-23 13:42:23 +00:00
parent 8264aa6016
commit febcbde3d7
21 changed files with 1881 additions and 423 deletions

View File

@@ -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. 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 ## Package Structure
@@ -70,7 +70,7 @@ server/
│ └── ... │ └── ...
├── models/models.go # Shared domain types ├── models/models.go # Shared domain types
├── capabilities/ ├── capabilities/
│ ├── intrinsic.go # Known model table + heuristics │ ├── intrinsic.go # Heuristic detection + resolution
│ └── resolver.go # ModelsForUser() unified resolver │ └── resolver.go # ModelsForUser() unified resolver
├── handlers/ # HTTP handlers (Gin) ├── handlers/ # HTTP handlers (Gin)
│ ├── auth.go # Login, register, refresh, logout │ ├── 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 ↓ miss
2. model_catalog DB (any provider: same model_id) 2. model_catalog DB (any provider: same model_id)
↓ miss ↓ miss
3. Known model table (static, curated in capabilities/intrinsic.go) 3. Heuristic inference (name-based: "gpt-4-vision" → vision=true)
↓ miss
4. 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. 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 2. Checks which migration files have been applied
3. Applies any new `.sql` files in order 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 ## Frontend Architecture
Vanilla JavaScript, no build step. Five files with clear responsibilities: Vanilla JavaScript, no build step. Five files with clear responsibilities:

68
CHANGELOG.md Normal file
View File

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

685
EXTENSIONS.md Normal file
View File

@@ -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 `<script>` tags for all enabled browser extensions.
Load order respects declared dependencies.
**Init**: Each extension's entry script calls `Extensions.register()`:
```js
Extensions.register({
id: 'cost-tracker',
init(ctx) {
// ctx.events — scoped EventBus (only permitted events)
// ctx.storage — scoped localStorage wrapper
// ctx.settings — this extension's settings values
// ctx.ui — DOM injection points
// ctx.api — proxied fetch() to backend (auth injected)
// ctx.model — current model ID + resolved capabilities
// ctx.user — current user info (id, username, role)
this.ctx = ctx;
ctx.events.on('chat.message.send', (msg) => {
const est = this.estimateTokens(msg.content);
ctx.ui.inject('input-area', this.renderCost(est));
});
},
destroy() {
// Cleanup: remove DOM elements, unsubscribe events
}
});
```
### 4.2 Context Object
The `ctx` object is the extension's API surface. It's scoped — an
extension that didn't declare `dom:sidebar` in permissions gets a `ctx.ui`
that throws on `inject('sidebar', ...)`.
```
ctx.events — EventBus subscribe/publish (filtered by permissions)
ctx.storage — localStorage namespace (extensions::{id}::*)
ctx.settings — Read-only settings values from manifest
ctx.ui — DOM injection into declared surfaces
ctx.api — Proxied fetch() to backend (auth headers injected)
ctx.model — Current model ID + resolved capabilities
ctx.user — Current user info (id, username, role)
```
### 4.3 Admin-Pushed vs User-Installed
- **Admin-pushed**: `extensions` row with `is_system = true`. Loaded for
all users. Users cannot disable.
- **User-installed**: Stored in user settings JSONB. Users can toggle.
- Both use the same runtime.
### 4.4 Security Model
Browser extensions run in the same origin. The security model is:
1. **Permission declaration** — extensions declare what they need; the
loader enforces it via the `ctx` proxy.
2. **Admin review** — admin-pushed extensions are implicitly trusted.
User-installed extensions get a "this extension can access: ..." prompt.
3. **Event scoping** — extensions only see events they declared in
`permissions`.
4. **CSP headers** — strict Content-Security-Policy prevents inline script
injection. Extension scripts are served from known paths only.
---
## 5. Browser-Defined Tools (The Bridge)
This is the critical innovation. The LLM tool call originates from the
provider response, arrives at the Go backend, and needs to execute in the
user's browser. The EventBus + WebSocket bridge makes this transparent.
### 5.1 The Flow
```
User sends message
→ Backend sends to LLM with tools[] from enabled extensions
→ LLM returns tool_call: { name: "read_file", args: {...} }
→ Backend checks tool registry → tool is tier:browser
→ Backend publishes via WebSocket:
event: tool.call.{callId}
data: { tool: "read_file", args: {...}, callId: "uuid" }
→ Browser extension receives event, executes tool handler
→ Extension publishes result via WebSocket:
event: tool.result.{callId}
data: { callId: "uuid", result: "file contents..." }
→ Backend receives result, feeds back to LLM as tool_result
→ LLM continues with the result
→ Response streams to user
```
### 5.2 Tool Registration
Extensions declare tools in their manifest and implement handlers:
```json
{
"tools": [
{
"name": "estimate_cost",
"description": "Estimate the token cost of a given text",
"parameters": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to estimate" }
},
"required": ["text"]
},
"tier": "browser"
}
]
}
```
```js
Extensions.register({
id: 'cost-tracker',
init(ctx) {
ctx.tools.handle('estimate_cost', async (args) => {
const tokens = this.estimateTokens(args.text);
return { tokens, estimated_cost: tokens * this.pricing / 1e6 };
});
}
});
```
### 5.3 Server-Side Tool Router
The completion handler maintains a tool registry. When building the
`tools[]` array for the LLM request:
```go
func (h *CompletionHandler) collectTools(userID string) []ToolSchema {
var tools []ToolSchema
// Tier 1: Starlark tools (server-side, execute inline)
tools = append(tools, h.starlarkTools()...)
// Tier 2: Sidecar tools (server-side, HTTP call)
tools = append(tools, h.sidecarTools()...)
// Tier 0: Browser tools (client-side, routed via WebSocket)
if h.hub.IsConnected(userID) {
tools = append(tools, h.browserTools(userID)...)
}
return tools
}
```
When a `tool_call` arrives and the tool is `tier: browser`:
```go
case "browser":
callId := uuid.New().String()
bus.Publish(events.Event{
Type: "tool.call." + callId,
Data: toolCallData,
Room: "user:" + userID,
})
result, err := bus.WaitFor("tool.result." + callId, 30*time.Second)
```
### 5.4 Timeout and Fallback
Browser tools have a 30-second timeout. If the browser disconnects or the
tool fails, the backend sends a `tool_result` with an error message to the
LLM so it can recover gracefully.
### 5.5 Why This Matters
An extension author can write a tool in 20 lines of JavaScript that the
LLM can call. No Go code. No container. No deployment. The cluster manager
extension defines `kubectl_get`, `ceph_status`, `node_drain` as browser
tools. The editor extension defines `read_file`, `write_file`,
`search_replace`. The LLM doesn't know or care where the tool executes.
---
## 6. Surfaces (Modes)
A "mode" is an extension that registers a **surface** — a UI region that
replaces or augments the default chat area.
### 6.1 Surface Regions
```
┌─────────────────────────────────────────────┐
│ sidebar-top │ surface-header │
│ │ │
│ sidebar-nav │ │
│ (mode selector) │ surface-main │
│ │ (chat, editor, article, │
│ sidebar-content │ cluster, ...) │
│ (context panel) │ │
│ │ │
│ sidebar-bottom │ surface-footer │
│ │ (input area) │
└─────────────────────────────────────────────┘
```
### 6.2 Surface Registration
```json
{
"surfaces": [
{
"id": "editor",
"label": "Editor",
"icon": "code",
"regions": ["surface-main", "surface-footer", "sidebar-content"],
"default": false
}
]
}
```
```js
Extensions.register({
id: 'editor-mode',
init(ctx) {
ctx.surfaces.register('editor', {
activate() {
ctx.ui.replace('surface-main', this.renderEditor());
ctx.ui.replace('surface-footer', this.renderEditorInput());
ctx.ui.replace('sidebar-content', this.renderFileTree());
},
deactivate() {
ctx.ui.restore('surface-main');
ctx.ui.restore('surface-footer');
ctx.ui.restore('sidebar-content');
}
});
}
});
```
### 6.3 Mode Selector
When extensions register surfaces, a mode selector appears in the sidebar.
Clicking a mode calls `activate()` on that surface and `deactivate()` on
the current one. The bus event `surface.activated` fires so other
extensions can react.
### 6.4 Core Surface
Chat mode is the default surface. It's not special architecturally — it's
the surface that's active when no extension surface is selected. Pragmatically
it stays in core because everything depends on it.
### 6.5 Planned Modes
**Editor Mode** (extension)
- File tree in sidebar, code editor in main, AI chat in split/overlay.
- Tools: `read_file`, `write_file`, `search_replace`, `git_status`,
`git_commit` — browser tools backed by a git provider API.
- This is ai-editor rebuilt properly: tool calls are server-side, logged,
token-counted, auditable.
**Article Mode** (extension)
- Outline in sidebar, rich text editor in main, AI assistant in panel.
- Tools: `fetch_url`, `summarize_section`, `check_citation`.
- The conversation is hidden; only the document matters.
**Cluster Manager Mode** (2-3 cooperating extensions)
- `k8s-manager`: kubectl tools, pod/deployment views.
- `ceph-manager`: ceph status, OSD management.
- `node-manager`: SSH commands, system metrics.
- These talk to each other via EventBus. When `k8s-manager` detects a
node is NotReady, it publishes `cluster.node.unhealthy`. `ceph-manager`
subscribes and checks OSD placement.
- The LLM sees ALL tools from ALL active cluster extensions. "Drain node-3,
ensure Ceph rebalances, then cordon it" → `kubectl_drain`, `ceph_osd_out`,
`kubectl_cordon` — each routed to the appropriate extension.
---
## 7. Extension Loader Architecture
### 7.1 Load Order
```html
<!-- Core -->
<script src="js/events.js"></script>
<script src="js/extensions.js"></script> <!-- loader + registry -->
<!-- Extensions (injected by loader) -->
<script src="/api/v1/extensions/cost-tracker/assets/main.js"></script>
<script src="/api/v1/extensions/editor-mode/assets/main.js"></script>
<!-- App (runs after extensions registered) -->
<script src="js/api.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
```
### 7.2 extensions.js (Core)
The extension loader and registry. ~200 lines. Responsibilities:
- Fetch enabled extensions 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 = `
<summary>${lang}${lines.length} lines</summary>
<pre><code class="language-${lang}">${escapeHtml(code)}</code></pre>
`;
container.replaceWith(details);
}
});
}
});
```
Expected first-party renderer extensions (ship with core or as official):
| Renderer | Matches | Renders |
|---|---|---|
| `collapsible-code` | Code blocks > N lines | `<details>` 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.

View File

@@ -90,8 +90,8 @@ docker compose --profile dev up # include Adminer DB UI
Build and push both images: Build and push both images:
```bash ```bash
docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.0 server/ 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.0 . docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 .
``` ```
**Backend deployment:** **Backend deployment:**
@@ -99,7 +99,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.0 .
```yaml ```yaml
containers: containers:
- name: api - name: api
image: your-registry/switchboard-api:0.9.0 image: your-registry/switchboard-api:0.9.1
ports: ports:
- containerPort: 8080 - containerPort: 8080
env: env:
@@ -119,7 +119,7 @@ containers:
```yaml ```yaml
containers: containers:
- name: frontend - name: frontend
image: your-registry/switchboard-fe:0.9.0 image: your-registry/switchboard-fe:0.9.1
ports: ports:
- containerPort: 80 - containerPort: 80
env: env:
@@ -194,9 +194,20 @@ All endpoints under `/api/v1/`. Authentication via `Authorization: Bearer <token
- `POST /channels/:id/messages/:msgId/regenerate` — Regenerate response - `POST /channels/:id/messages/:msgId/regenerate` — Regenerate response
### Models ### Models
- `GET /models/enabled` — Models available to the user - `GET /models/enabled` — Models available to the user (global + team + personal)
- `GET/PUT /models/preferences` — User model visibility settings - `GET/PUT /models/preferences` — User model visibility settings
### Personal Providers (BYOK)
- `GET/POST /api-configs` — User's personal provider configs
- `GET/PUT/DELETE /api-configs/:id` — Provider CRUD
- `POST /api-configs/:id/models/fetch` — Refresh models from provider API
- `GET /api-configs/:id/models` — List models for a personal provider
### Teams
- `GET /teams` — Teams the user belongs to
- `GET/POST /teams/:id/providers` — Team provider management (team admins)
- `GET/POST /teams/:id/presets` — Team preset management (team admins)
### Admin ### Admin
- `GET/POST /admin/users` — User management - `GET/POST /admin/users` — User management
- `GET/PUT /admin/settings/:key` — Global settings and policies - `GET/PUT /admin/settings/:key` — Global settings and policies

View File

@@ -1,44 +1,521 @@
# Roadmap — Chat Switchboard # Roadmap — Chat Switchboard
## v0.9.0 (Current) **See also:**
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design, store layer, scope model
- [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers,
manifests, browser tool bridge, surfaces/modes, model roles)
- [x] Consolidated schema (21 migrations → 1) **Versioning (pre-1.0):** `0.<major>.<minor>` — hotfixes use quad: `0.x.y.z`
- [x] Store layer abstraction (all DB access via typed interfaces) No compatibility guarantees before 1.0.
- [x] Persona-as-trust-boundary model (replaces presets)
---
## 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] Scope model (global/team/personal) for configs, personas, models
- [x] Capabilities resolver chain (catalog → known → heuristic) - [x] Backward-compatible API routes (v0.8 → v0.9 field aliases)
- [x] Three-state model visibility (enabled/disabled/team-only)
- [x] User model preferences (hide, sort)
- [x] Audit log foundation
- [x] Backward-compatible API routes
## 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) **BYOK (Bring Your Own Key)**
- [ ] Keyboard shortcuts + command palette (Ctrl+K) - [x] Auto-fetch models on provider creation
- [ ] PWA enhancements (offline indicator, install prompt) - [x] User-facing refresh endpoint + "Refresh Models" button
- [ ] API key at-rest encryption - [x] Personal models in selector with `scope=personal` badge
- [ ] v0.8 → v0.9 migration script (002_v08_to_v09.sql) - [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) **Testing**
- [ ] Model visibility toggle in admin panel - [x] Journey-based integration tests (API-only, 4-actor × 3-provider matrix)
- [ ] Team/group notes (shared note folders) - [x] Live Venice API integration test
- [ ] Conversation export (markdown, JSON) - [x] Frontend JS test suite (107 tests, 27 suites)
- [ ] Plugin system (tool registration API)
## v0.11 ---
- [ ] OIDC/Keycloak authentication mode ## ✅ v0.9.1 — Capability Architecture + Docs
- [ ] mTLS client certificate authentication
- [ ] SSO integration patterns
- [ ] Rate limiting per user/team (configurable quotas)
## 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) ## v0.9.2 — Quick UX Wins + Hardening
- [ ] Agent mode (multi-step tool use with human-in-the-loop)
- [ ] Model cost tracking and reporting Polish that doesn't need new infrastructure. Ship fast, stabilize.
**UX**
- [x] Collapsible code blocks (long outputs get `<details>` 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)

View File

@@ -1 +1 @@
0.9.0 0.9.1

View File

@@ -36,11 +36,15 @@ else
echo " No branding mount — using defaults" echo " No branding mount — using defaults"
fi fi
# ── Read environment name ─────────────────
ENVIRONMENT="${ENVIRONMENT:-production}"
# ── Inject into index.html ────────────────── # ── Inject into index.html ──────────────────
sed -i \ sed -i \
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \ -e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
-e "s|%%BASE_HREF%%|${BASE_HREF}|g" \ -e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \ -e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
-e "s|%%ENVIRONMENT%%|${ENVIRONMENT}|g" \
-e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \ -e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \
/usr/share/nginx/html/index.html /usr/share/nginx/html/index.html
@@ -49,7 +53,7 @@ sed -i \
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \ -e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
/usr/share/nginx/html/sw.js /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 ─────────────────── # ── Generate nginx config ───────────────────
# If BASE_PATH is set, serve under that prefix with alias. # If BASE_PATH is set, serve under that prefix with alias.

View File

@@ -38,6 +38,8 @@ spec:
env: env:
- name: BASE_PATH - name: BASE_PATH
value: "${BASE_PATH}" value: "${BASE_PATH}"
- name: ENVIRONMENT
value: "${ENVIRONMENT}"
volumeMounts: volumeMounts:
- name: branding - name: branding
mountPath: /branding mountPath: /branding

View File

@@ -6,46 +6,15 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
) )
func TestLookupKnownModel_ExactMatch(t *testing.T) { func TestLookupKnownModel_AlwaysFalse(t *testing.T) {
caps, ok := LookupKnownModel("gpt-4o") // Known table removed in 0.9.1 — function is a no-op stub for interface compat
if !ok { _, ok := LookupKnownModel("gpt-4o")
t.Fatal("expected gpt-4o to be found")
}
if caps.MaxOutputTokens != 16384 {
t.Errorf("gpt-4o max output: got %d, want 16384", caps.MaxOutputTokens)
}
if !caps.ToolCalling {
t.Error("gpt-4o should support tool calling")
}
if !caps.Vision {
t.Error("gpt-4o should support vision")
}
}
func TestLookupKnownModel_PrefixMatch(t *testing.T) {
caps, ok := LookupKnownModel("claude-sonnet-4-20250514")
if !ok {
t.Fatal("expected claude-sonnet-4-20250514 to match prefix claude-sonnet-4")
}
if caps.MaxOutputTokens != 16000 {
t.Errorf("claude-sonnet-4 max output: got %d, want 16000", caps.MaxOutputTokens)
}
}
func TestLookupKnownModel_ProviderPrefix(t *testing.T) {
caps, ok := LookupKnownModel("anthropic/claude-sonnet-4-20250514")
if !ok {
t.Fatal("expected provider-prefixed model to match")
}
if caps.MaxOutputTokens != 16000 {
t.Errorf("got %d, want 16000", caps.MaxOutputTokens)
}
}
func TestLookupKnownModel_NotFound(t *testing.T) {
_, ok := LookupKnownModel("totally-unknown-model-xyz")
if ok { 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}, {"llama-3.1-70b-instruct", true},
{"mistral-7b", true}, {"mistral-7b", true},
{"qwen-2.5-72b", true}, {"qwen-2.5-72b", true},
{"qwen3-235b-a22b-thinking", true},
{"phi-3-mini", 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}, {"random-smallmodel", false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -69,16 +45,67 @@ func TestInferCapabilities_ToolCalling(t *testing.T) {
} }
func TestInferCapabilities_Vision(t *testing.T) { func TestInferCapabilities_Vision(t *testing.T) {
caps := InferCapabilities("llava-v1.6-34b") tests := []struct {
if !caps.Vision { model string
t.Error("llava should infer vision capability") 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) { func TestInferCapabilities_Reasoning(t *testing.T) {
caps := InferCapabilities("deepseek-r1-distill-llama-70b") tests := []struct {
if !caps.Reasoning { model string
t.Error("deepseek-r1 should infer reasoning capability") 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) { func TestResolveMaxOutput_FromContext(t *testing.T) {
caps := models.ModelCapabilities{MaxContext: 32768} caps := models.ModelCapabilities{MaxContext: 32768}
got := ResolveMaxOutput("unknown-model-abc", caps) got := ResolveMaxOutput("unknown-model-abc", caps)
@@ -128,9 +147,9 @@ func TestResolveMaxOutput_LastResort(t *testing.T) {
} }
} }
func TestResolveIntrinsic(t *testing.T) { func TestResolveIntrinsic_CatalogWins(t *testing.T) {
// Provider reports tool_calling and max_output — these should be authoritative. // Provider reports tool_calling and max_output — authoritative.
// Known table for claude-sonnet-4 has vision, thinking, etc — should fill gaps. // Heuristic should fill vision for claude (regex match).
providerCaps := models.ModelCapabilities{ providerCaps := models.ModelCapabilities{
ToolCalling: true, ToolCalling: true,
MaxOutputTokens: 16384, MaxOutputTokens: 16384,
@@ -144,14 +163,16 @@ func TestResolveIntrinsic(t *testing.T) {
t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens) t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens)
} }
if !merged.Vision { if !merged.Vision {
t.Error("vision should be filled from known table") t.Error("vision should be filled from heuristic (claude-sonnet matches)")
}
if merged.MaxContext == 0 {
t.Error("max_context should be filled from known table")
} }
} }
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{ providerCaps := models.ModelCapabilities{
ToolCalling: true, ToolCalling: true,
Vision: false, Vision: false,
@@ -160,20 +181,35 @@ func TestResolveIntrinsic_ProviderFalseWins(t *testing.T) {
} }
merged := ResolveIntrinsic("some-unknown-model", &providerCaps) merged := ResolveIntrinsic("some-unknown-model", &providerCaps)
if merged.Vision { if !merged.ToolCalling {
t.Error("vision should remain false — provider is authoritative") 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) { 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) merged := ResolveIntrinsic("gpt-4o", nil)
if !merged.ToolCalling { 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 { 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)")
} }
} }

View File

@@ -8,185 +8,23 @@ import (
) )
// ── Known Model Defaults ──────────────────── // ── Known Model Defaults ────────────────────
// Authoritative capabilities for models where the provider API
// doesn't report them. Keyed by exact model ID or prefix.
// //
// Sources: // REMOVED in 0.9.1: The static known model table was removed because the same
// Anthropic: https://docs.anthropic.com/en/docs/about-claude/models // model ID can have different capabilities depending on the provider hosting it
// OpenAI: https://platform.openai.com/docs/models // (e.g. DeepSeek on Venice has no tool_calling, same model on OpenRouter does).
// Meta: Model cards on Hugging Face //
// Google: https://ai.google.dev/gemini-api/docs/models // 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{ // LookupKnownModel always returns false — kept for interface compatibility.
// ── Anthropic ──────────────────────────── // Callers fall through to heuristic inference.
"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.
func LookupKnownModel(modelID string) (models.ModelCapabilities, bool) { 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 return models.ModelCapabilities{}, false
} }
@@ -197,17 +35,21 @@ var (
regexp.MustCompile(`(?i)llama[_-]?[34]`), regexp.MustCompile(`(?i)llama[_-]?[34]`),
regexp.MustCompile(`(?i)granite[_-]?[34]`), regexp.MustCompile(`(?i)granite[_-]?[34]`),
regexp.MustCompile(`(?i)hermes[_-]?[23]?`), regexp.MustCompile(`(?i)hermes[_-]?[23]?`),
regexp.MustCompile(`(?i)qwen[_-]?2`), regexp.MustCompile(`(?i)qwen[_-]?[23]`),
regexp.MustCompile(`(?i)mistral|mixtral`), regexp.MustCompile(`(?i)mistral|mixtral`),
regexp.MustCompile(`(?i)command[_-]?r`), regexp.MustCompile(`(?i)command[_-]?r`),
regexp.MustCompile(`(?i)phi[_-]?[34]`), regexp.MustCompile(`(?i)phi[_-]?[34]`),
regexp.MustCompile(`(?i)deepseek[_-]?v[23]`), regexp.MustCompile(`(?i)deepseek[_-]?v[23]`),
regexp.MustCompile(`(?i)functionary`), 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)^claude`),
regexp.MustCompile(`(?i)gemma[_-]?2`), regexp.MustCompile(`(?i)gemma[_-]?[23]`),
regexp.MustCompile(`(?i)glm[_-]?4`), regexp.MustCompile(`(?i)glm[_-]?[45]`),
regexp.MustCompile(`(?i)gemini`), regexp.MustCompile(`(?i)gemini`),
regexp.MustCompile(`(?i)grok`),
regexp.MustCompile(`(?i)kimi`),
regexp.MustCompile(`(?i)minimax`),
} }
visionPatterns = []*regexp.Regexp{ visionPatterns = []*regexp.Regexp{
@@ -216,18 +58,24 @@ var (
regexp.MustCompile(`(?i)llama.*vision|vision.*llama`), regexp.MustCompile(`(?i)llama.*vision|vision.*llama`),
regexp.MustCompile(`(?i)minicpm[_-]?v`), regexp.MustCompile(`(?i)minicpm[_-]?v`),
regexp.MustCompile(`(?i)^gpt[_-]?4[_-]?o|^gpt[_-]?4[_-]?turbo`), 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)gemini`),
regexp.MustCompile(`(?i)qwen.*vl|vl.*qwen`), regexp.MustCompile(`(?i)qwen.*vl|vl.*qwen`),
regexp.MustCompile(`(?i)phi[_-]?[34].*vision`), regexp.MustCompile(`(?i)phi[_-]?[34].*vision`),
regexp.MustCompile(`(?i)internvl`), regexp.MustCompile(`(?i)internvl`),
regexp.MustCompile(`(?i)glm[_-]?4v`), regexp.MustCompile(`(?i)glm[_-]?4v`),
regexp.MustCompile(`(?i)gemma[_-]?3`),
regexp.MustCompile(`(?i)grok[_-]?4`),
regexp.MustCompile(`(?i)mistral.*24b`),
} }
reasoningPatterns = []*regexp.Regexp{ reasoningPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)deepseek[_-]?r1`), regexp.MustCompile(`(?i)deepseek[_-]?r1`),
regexp.MustCompile(`(?i)qwq`), regexp.MustCompile(`(?i)qwq`),
regexp.MustCompile(`(?i)^o[1234][_-]|^o[1234]$`), regexp.MustCompile(`(?i)^o[1234][_-]|^o[1234]$`),
regexp.MustCompile(`(?i)thinking`),
regexp.MustCompile(`(?i)grok`),
regexp.MustCompile(`(?i)glm[_-]?[45]`),
} }
codePatterns = []*regexp.Regexp{ codePatterns = []*regexp.Regexp{
@@ -250,7 +98,7 @@ func matchesAny(id string, patterns []*regexp.Regexp) bool {
} }
// InferCapabilities guesses model capabilities from the model ID string. // 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 { func InferCapabilities(modelID string) models.ModelCapabilities {
id := normalizeModelID(modelID) id := normalizeModelID(modelID)
return models.ModelCapabilities{ return models.ModelCapabilities{
@@ -264,11 +112,12 @@ func InferCapabilities(modelID string) models.ModelCapabilities {
// ResolveIntrinsic determines the intrinsic capabilities of a model. // ResolveIntrinsic determines the intrinsic capabilities of a model.
// Priority: // Priority:
// 1. catalogCaps (from model_catalog DB — provider API data) // 1. catalogCaps (from model_catalog DB — synced from provider API)
// 2. Known model table (static, compiled-in) // 2. Heuristic inference (regex patterns on model ID)
// 3. Heuristic inference (regex patterns)
// //
// 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 { func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) models.ModelCapabilities {
// Start with catalog data if available // Start with catalog data if available
var base models.ModelCapabilities var base models.ModelCapabilities
@@ -276,12 +125,6 @@ func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) mod
base = *catalogCaps base = *catalogCaps
} }
// Fill gaps from known model table
if known, found := LookupKnownModel(modelID); found {
mergeGaps(&base, &known)
return base
}
// Fill gaps from heuristic inference // Fill gaps from heuristic inference
inferred := InferCapabilities(modelID) inferred := InferCapabilities(modelID)
mergeGaps(&base, &inferred) mergeGaps(&base, &inferred)
@@ -289,14 +132,11 @@ func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) mod
} }
// ResolveMaxOutput returns the max output tokens for a model. // 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 { func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {
if caps.MaxOutputTokens > 0 { if caps.MaxOutputTokens > 0 {
return caps.MaxOutputTokens return caps.MaxOutputTokens
} }
if known, ok := LookupKnownModel(modelID); ok && known.MaxOutputTokens > 0 {
return known.MaxOutputTokens
}
if caps.MaxContext > 0 { if caps.MaxContext > 0 {
derived := caps.MaxContext / 8 derived := caps.MaxContext / 8
if derived < 2048 { if derived < 2048 {

View File

@@ -42,8 +42,11 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
// Priority chain: // Priority chain:
// 1. model_catalog DB — exact match (model_id + provider_config_id) // 1. model_catalog DB — exact match (model_id + provider_config_id)
// 2. model_catalog DB — any provider (same model, different config) // 2. model_catalog DB — any provider (same model, different config)
// 3. Known model table (static, curated) // 3. Heuristic inference (name-based fallback)
// 4. 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 { func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
// 1. Exact match: model_id + provider_config_id // 1. Exact match: model_id + provider_config_id
if configID != "" { if configID != "" {
@@ -61,14 +64,7 @@ func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapa
return caps return caps
} }
// 3. Known model table (static, curated) // 3. Heuristic inference
caps, found := capspkg.LookupKnownModel(modelID)
if found {
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// 4. Heuristic inference
caps = capspkg.InferCapabilities(modelID) caps = capspkg.InferCapabilities(modelID)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps return caps

View File

@@ -170,7 +170,7 @@ func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]M
out := make([]Model, 0, len(modelIDs)) out := make([]Model, 0, len(modelIDs))
for _, m := range modelIDs { for _, m := range modelIDs {
caps, _ := capabilities.LookupKnownModel(m.id) caps := capabilities.InferCapabilities(m.id)
out = append(out, Model{ out = append(out, Model{
ID: m.id, ID: m.id,
Name: m.name, Name: m.name,

View File

@@ -193,11 +193,8 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
out := make([]Model, 0, len(result.Data)) out := make([]Model, 0, len(result.Data))
for _, m := range result.Data { for _, m := range result.Data {
// Try known table first, then heuristic // Heuristic inference from model ID
caps, found := capabilities.LookupKnownModel(m.ID) caps := capabilities.InferCapabilities(m.ID)
if !found {
caps = capabilities.InferCapabilities(m.ID)
}
// Use context_length from API if available and we don't have it // Use context_length from API if available and we don't have it
if m.ContextLength > 0 && caps.MaxContext == 0 { if m.ContextLength > 0 && caps.MaxContext == 0 {
caps.MaxContext = m.ContextLength caps.MaxContext = m.ContextLength

View File

@@ -69,11 +69,8 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
out := make([]Model, 0, len(result.Data)) out := make([]Model, 0, len(result.Data))
for _, m := range result.Data { for _, m := range result.Data {
// Start with known table, fall back to heuristic // Heuristic inference from model ID
caps, found := capabilities.LookupKnownModel(m.ID) caps := capabilities.InferCapabilities(m.ID)
if !found {
caps = capabilities.InferCapabilities(m.ID)
}
// Overlay context length from OpenRouter metadata // Overlay context length from OpenRouter metadata
if m.ContextLength > 0 && caps.MaxContext == 0 { if m.ContextLength > 0 && caps.MaxContext == 0 {

View File

@@ -1,7 +1,6 @@
package providers package providers
import ( import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"context" "context"
"encoding/json" "encoding/json"
@@ -80,10 +79,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
} }
// Venice doesn't report max output tokens directly. // Venice doesn't report max output tokens directly.
// Try known table, then derive from context. // ResolveMaxOutput will derive from context window at resolution time.
if known, ok := capabilities.LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 {
caps.MaxOutputTokens = known.MaxOutputTokens
}
var pricing *models.ModelPricing var pricing *models.ModelPricing
if spec.Pricing.Input.USD > 0 { if spec.Pricing.Input.USD > 0 {

View File

@@ -531,15 +531,65 @@ a:hover { text-decoration: underline; }
border-radius: 4px; border-radius: 4px;
} }
.msg-text pre code { background: none; padding: 0; font-size: inherit; } .msg-text pre code { background: none; padding: 0; font-size: inherit; }
/* Code block toolbar */
.copy-code-btn { .copy-code-btn {
position: absolute; top: 6px; right: 6px; background: var(--bg-raised); position: absolute; top: 6px; right: 6px; background: var(--bg-raised);
border: 1px solid var(--border); color: var(--text-3); border-radius: 4px; border: 1px solid var(--border); color: var(--text-3); border-radius: 4px;
padding: 2px 10px; font-size: 11px; cursor: pointer; padding: 2px 10px; font-size: 11px; cursor: pointer;
opacity: 0; transition: opacity var(--transition); opacity: 0; transition: opacity var(--transition);
} }
.preview-code-btn { right: 56px; }
.msg-text pre:hover .copy-code-btn { opacity: 1; } .msg-text pre:hover .copy-code-btn { opacity: 1; }
.copy-code-btn:hover { color: var(--text); border-color: var(--border-light); } .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 ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
.msg-text li { margin: 0.2em 0; } .msg-text li { margin: 0.2em 0; }
.msg-text li > p { 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 ──────────────────────────── */
.input-area { padding: 0 1rem 1rem; flex-shrink: 0; } .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 { .input-wrap {
max-width: 768px; margin: 0 auto; max-width: 768px; margin: 0 auto;
background: var(--bg-surface); border: 1px solid var(--border); 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:hover { color: var(--text-2); }
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .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; } .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-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 { 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; } .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; } #authLoginForm { animation-delay: 0.29s; }
.splash .auth-actions { animation-delay: 0.36s; } .splash .auth-actions { animation-delay: 0.36s; }
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } } @keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* Splash responsive */ /* Splash responsive */
@media (max-width: 860px) { @media (max-width: 860px) {

View File

@@ -6,6 +6,7 @@
<base href="%%BASE_HREF%%"> <base href="%%BASE_HREF%%">
<script>window.__BASE__ = '%%BASE_PATH%%';</script> <script>window.__BASE__ = '%%BASE_PATH%%';</script>
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script> <script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script>
<script>window.__ENV__ = '%%ENVIRONMENT%%'; if (window.__ENV__.includes('%%')) window.__ENV__ = 'development';</script>
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script> <script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
<title>Chat Switchboard</title> <title>Chat Switchboard</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg"> <link rel="icon" type="image/svg+xml" href="favicon.svg">
@@ -134,6 +135,11 @@
</div> </div>
<div class="input-area"> <div class="input-area">
<div class="context-warning" id="contextWarning" style="display:none">
<span class="context-warning-icon">⚠️</span>
<span class="context-warning-text" id="contextWarningText"></span>
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss"></button>
</div>
<div class="input-wrap"> <div class="input-wrap">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea> <textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
<div class="input-actions"> <div class="input-actions">
@@ -145,6 +151,9 @@
</button> </button>
</div> </div>
</div> </div>
<div class="input-meta" id="inputMeta">
<span class="input-token-count" id="inputTokenCount"></span>
</div>
</div> </div>
</main> </main>
</div><!-- .app-body --> </div><!-- .app-body -->

View File

@@ -54,6 +54,25 @@ const API = {
async health() { async health() {
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) }); const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`); 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[^>]*>([^<]+)<\/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(); return resp.json();
}, },
@@ -157,9 +176,21 @@ const API = {
} }
if (!resp.ok) { 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(() => ({})); const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`); 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; return resp;
}, },
@@ -205,9 +236,22 @@ const API = {
} }
if (!resp.ok) { 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(() => ({})); const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`); 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; return resp;
}, },
@@ -381,6 +425,13 @@ const API = {
// ── HTTP Internals ─────────────────────── // ── HTTP Internals ───────────────────────
_esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
_authHeaders() { _authHeaders() {
return { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -413,11 +464,26 @@ const API = {
const resp = await fetch(BASE + path, opts); const resp = await fetch(BASE + path, opts);
if (!resp.ok) { 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[^>]*>([^<]+)<\/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 data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`); const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status; err.status = resp.status;
throw err; throw err;
} }
return resp.json(); return this._parseJSON(resp, path);
} }
}; };

View File

@@ -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() { async function init() {
console.log('🔀 Chat Switchboard initializing...'); console.log('🔀 Chat Switchboard initializing...');
@@ -45,7 +156,24 @@ async function init() {
console.log('✅ Backend reachable:', health.version); console.log('✅ Backend reachable:', health.version);
} catch (e) { } catch (e) {
console.error('❌ Backend unreachable:', e.message); 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 =
`<strong>Network proxy blocked this request</strong><br>` +
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
`<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
} else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
splashErr.innerHTML =
`<strong>Connection timed out</strong><br>` +
`<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
} else {
splashErr.innerHTML =
`<strong>Cannot reach server</strong><br>` +
`<span class="splash-error-hint">${API._esc(e.message)}. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
}
showSplash(null); showSplash(null);
return; return;
} }
@@ -144,69 +272,16 @@ function updateAvatarPreview(dataURI) {
// ── Models ─────────────────────────────────── // ── Models ───────────────────────────────────
// Client-side known model capabilities — used when backend hasn't synced yet. // ── Capability Resolution ───────────────────────────────────────
// Mirrors server/providers/capabilities.go knownModels table. // The backend is the source of truth for model capabilities.
const KNOWN_MODELS = { // Resolution chain on the server: catalog (provider API sync) → heuristic.
'claude-opus-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:32000 }, // The frontend does NOT maintain a static model table — the same model
'claude-sonnet-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:16000 }, // can have different capabilities on different providers (e.g. DeepSeek
'claude-3-5-sonnet': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 }, // on Venice has no tool_calling, same model on OpenRouter does).
'claude-3-5-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
'claude-3-opus': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
'claude-3-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
'gpt-4o': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
'gpt-4o-mini': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
'gpt-4-turbo': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:4096 },
'o1': { streaming:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
'o3': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
'o3-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:65536 },
'o4-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
'gemini-2.5-pro': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
'gemini-2.5-flash': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
'gemini-2.0-flash': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:8192 },
'deepseek-r1': { streaming:true, reasoning:true, max_context:65536, max_output_tokens:8192 },
'deepseek-v3': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
'deepseek-chat': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
'llama-3.1-405b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
'llama-3.1-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
'llama-3.3-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
'llama-4-maverick': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:16384 },
'llama-4-scout': { streaming:true, tool_calling:true, vision:true, max_context:524288, max_output_tokens:16384 },
'mistral-large': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
'codestral': { streaming:true, tool_calling:true, code_optimized:true, max_context:262144, max_output_tokens:8192 },
'qwen-2.5-72b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
'qwq-32b': { streaming:true, reasoning:true, max_context:131072, max_output_tokens:8192 },
};
// Look up client-side capabilities by model ID with prefix matching // resolveCapabilities returns backend caps as-is. No client-side override.
function lookupKnownCaps(modelId) {
const id = modelId.toLowerCase().replace(/^[^/]+\//, ''); // strip provider prefix
// Exact match
if (KNOWN_MODELS[id]) return { ...KNOWN_MODELS[id] };
// Prefix match (longest wins)
let best = null, bestLen = 0;
for (const key of Object.keys(KNOWN_MODELS)) {
if (id.startsWith(key) && key.length > bestLen) {
best = key; bestLen = key.length;
}
}
return best ? { ...KNOWN_MODELS[best] } : null;
}
// Merge: backend/provider caps are authoritative, client-side fills gaps only
function resolveCapabilities(backendCaps, modelId) { function resolveCapabilities(backendCaps, modelId) {
const known = lookupKnownCaps(modelId) || {}; return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
if (!backendCaps || Object.keys(backendCaps).length === 0) return known;
// Backend is authoritative — start with it
const caps = { ...backendCaps };
// Fill gaps (fields backend didn't report) from known table
for (const [k, v] of Object.entries(known)) {
if (caps[k] === undefined || caps[k] === null) {
caps[k] = v;
}
}
return caps;
} }
async function fetchModels() { async function fetchModels() {
@@ -318,6 +393,9 @@ async function selectChat(chatId) {
UI.renderMessages(chat.messages); UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
Tokens._warningDismissed = false;
updateContextWarning();
updateInputTokens();
} }
async function newChat() { async function newChat() {
@@ -325,6 +403,9 @@ async function newChat() {
UI.renderChatList(); UI.renderChatList();
UI.showEmptyState(); UI.showEmptyState();
UI.showRegenerate(false); UI.showRegenerate(false);
Tokens._warningDismissed = false;
updateContextWarning();
updateInputTokens();
document.getElementById('messageInput').focus(); document.getElementById('messageInput').focus();
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed'); document.getElementById('sidebar').classList.add('collapsed');
@@ -395,7 +476,9 @@ async function sendMessage() {
} else { } else {
console.error('Completion error:', e); console.error('Completion error:', e);
const msg = e.message || ''; 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'); UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) { } else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit — wait and retry', 'warning'); UI.toast('Provider rate limit — wait and retry', 'warning');
@@ -436,6 +519,7 @@ async function reloadActivePath() {
})); }));
chat.messageCount = chat.messages.length; chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages); UI.renderMessages(chat.messages);
updateContextWarning();
} catch (e) { } catch (e) {
console.error('Failed to reload path:', e.message); console.error('Failed to reload path:', e.message);
} }
@@ -472,7 +556,9 @@ async function regenerateMessage(messageId) {
await reloadActivePath(); await reloadActivePath();
} else { } else {
const msg = e.message || ''; 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'); UI.toast('Provider API key rejected — check Settings', 'error');
} else { UI.toast(msg, 'error'); } } else { UI.toast(msg, 'error'); }
} }
@@ -894,7 +980,7 @@ function initListeners() {
}); });
document.getElementById('settingsModel').addEventListener('change', function() { document.getElementById('settingsModel').addEventListener('change', function() {
const model = App.findModel(this.value); const model = App.findModel(this.value);
const caps = model?.capabilities || lookupKnownCaps(this.value) || {}; const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint'); const hint = document.getElementById('settingsMaxHint');
if (hint) { if (hint) {
hint.textContent = caps.max_output_tokens > 0 hint.textContent = caps.max_output_tokens > 0
@@ -1287,6 +1373,7 @@ function initListeners() {
input.addEventListener('input', function() { input.addEventListener('input', function() {
this.style.height = 'auto'; this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px'; this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
}); });
// Close modals on overlay click // Close modals on overlay click

View File

@@ -60,10 +60,12 @@ const DebugLog = {
url: url.slice(0, 300), url: url.slice(0, 300),
status: null, status: null,
statusText: '', statusText: '',
contentType: null,
duration: null, duration: null,
error: null, error: null,
responsePreview: null, responsePreview: null,
requestBodyPreview: null requestBodyPreview: null,
proxyDetected: false
}; };
// Capture request body preview (redact keys) // Capture request body preview (redact keys)
@@ -92,14 +94,27 @@ const DebugLog = {
const resp = await self._origFetch(input, init); const resp = await self._origFetch(input, init);
entry.status = resp.status; entry.status = resp.status;
entry.statusText = resp.statusText; entry.statusText = resp.statusText;
entry.contentType = resp.headers.get('content-type') || null;
entry.duration = Math.round(performance.now() - start); entry.duration = Math.round(performance.now() - start);
// Log non-ok responses at higher severity // Detect proxy interception: API call returned HTML
const logType = resp.ok ? 'NET' : 'NET:ERR'; const isApiCall = url.includes('/api/') || url.includes('/health');
self._addEntry(logType, `${method} ${url.slice(0, 80)}${resp.status} (${entry.duration}ms)`); 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 // Clone and peek at error responses
if (!resp.ok) { if (!resp.ok && !entry.proxyDetected) {
try { try {
const clone = resp.clone(); const clone = resp.clone();
const text = await clone.text(); const text = await clone.text();
@@ -148,7 +163,7 @@ const DebugLog = {
this.entries.shift(); 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._errorCount++;
this._updateBadge(); this._updateBadge();
} }
@@ -197,7 +212,12 @@ const DebugLog = {
// ── State Snapshot ────────────────────── // ── State Snapshot ──────────────────────
getStateSnapshot() { 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) // API client state (redact tokens)
if (typeof API !== 'undefined') { if (typeof API !== 'undefined') {
@@ -254,6 +274,7 @@ const DebugLog = {
async runDiagnostics() { async runDiagnostics() {
this.log('DIAG', '── Starting connection diagnostics ──'); 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) const baseUrl = (typeof API !== 'undefined' && API._base)
? API._base ? API._base
@@ -387,7 +408,7 @@ const DebugLog = {
const typeColors = { const typeColors = {
'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c', 'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c',
'NET:ERR': '#e74c3c', 'NET:ERR': '#e74c3c', 'NET:PROXY': '#ff6b35',
'WARN': '#f39c12', 'WARNING': '#f39c12', 'WARN': '#f39c12', 'WARNING': '#f39c12',
'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d', 'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d',
'NET': '#2ecc71', 'NET': '#2ecc71',
@@ -396,7 +417,7 @@ const DebugLog = {
let html = ''; let html = '';
const entries = document.getElementById('debugFilterErrors')?.checked 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; : this.entries;
for (const entry of entries) { for (const entry of entries) {
@@ -430,9 +451,11 @@ const DebugLog = {
let html = ''; let html = '';
for (const req of [...this.networkLog].reverse()) { for (const req of [...this.networkLog].reverse()) {
const isErr = req.error || (req.status && req.status >= 400); const isErr = req.error || (req.status && req.status >= 400) || req.proxyDetected;
const statusColor = isErr ? '#e74c3c' : '#2ecc71'; const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71');
const statusText = req.error const statusText = req.proxyDetected
? `🛡️ PROXY (${req.status})`
: req.error
? `${req.error}` ? `${req.error}`
: (req.status ? `${req.status} ${req.statusText}` : '⏳ pending'); : (req.status ? `${req.status} ${req.statusText}` : '⏳ pending');
@@ -444,6 +467,9 @@ const DebugLog = {
if (req.duration != null) html += ` <span class="debug-time">${req.duration}ms</span>`; if (req.duration != null) html += ` <span class="debug-time">${req.duration}ms</span>`;
html += `</summary>`; html += `</summary>`;
html += `<div class="debug-net-detail">`; html += `<div class="debug-net-detail">`;
if (req.contentType) {
html += `<div><strong>Content-Type:</strong> <code>${this._esc(req.contentType)}</code></div>`;
}
if (req.requestBodyPreview) { if (req.requestBodyPreview) {
html += `<div><strong>Request:</strong> <code>${this._esc(req.requestBodyPreview)}</code></div>`; html += `<div><strong>Request:</strong> <code>${this._esc(req.requestBodyPreview)}</code></div>`;
} }
@@ -475,6 +501,7 @@ const DebugLog = {
let text = `=== Chat Switchboard Debug Export ===\n`; let text = `=== Chat Switchboard Debug Export ===\n`;
text += `Exported: ${new Date().toISOString()}\n`; text += `Exported: ${new Date().toISOString()}\n`;
text += `URL: ${location.href}\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 += `UA: ${navigator.userAgent}\n\n`;
text += `--- STATE ---\n`; text += `--- STATE ---\n`;
@@ -488,7 +515,10 @@ const DebugLog = {
text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`; text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`;
for (const r of this.networkLog) { 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`; if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`;
} }

View File

@@ -518,6 +518,8 @@ const UI = {
el.classList.toggle('selected', el.dataset.value === val); el.classList.toggle('selected', el.dataset.value === val);
}); });
UI.updateCapabilityBadges(); UI.updateCapabilityBadges();
if (typeof updateContextWarning === 'function') updateContextWarning();
if (typeof updateInputTokens === 'function') updateInputTokens();
}, },
updateModelSelector() { updateModelSelector() {
@@ -625,11 +627,8 @@ const UI = {
const modelId = UI.getModelValue(); const modelId = UI.getModelValue();
if (!modelId) return {}; if (!modelId) return {};
const model = App.findModel(modelId); const model = App.findModel(modelId);
// Model in list has resolved caps; fallback to client-side lookup // Model in list has resolved caps from backend (catalog → heuristic)
if (model?.capabilities && Object.keys(model.capabilities).length > 0) { return model?.capabilities || {};
return model.capabilities;
}
return (typeof lookupKnownCaps === 'function' ? lookupKnownCaps(modelId) : null) || {};
}, },
updateCapabilityBadges() { updateCapabilityBadges() {
@@ -1700,11 +1699,37 @@ function formatMessage(content) {
function _formatMarked(text) { function _formatMarked(text) {
const rendered = marked.parse(text, { breaks: true, gfm: true }); 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(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => { html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
const codeId = 'code-' + Math.random().toString(36).slice(2, 9); const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
return `<pre><code id="${codeId}"${attrs}>${code}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`; 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 = `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
// HTML preview button (only for HTML-like content)
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${codeId}')">Preview</button>`;
}
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
const block = `<pre class="code-block">${langLabel}<code id="${codeId}"${attrs}>${code}</code>${toolbar}</pre>`;
// Wrap in collapsible details if long
if (lineCount > COLLAPSE_THRESHOLD) {
return `<details class="code-collapsible"><summary class="code-collapse-summary">Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines</summary>${block}</details>`;
}
return block;
}); });
return html; return html;
@@ -1714,7 +1739,22 @@ function _formatBasic(text) {
let html = esc(text); let html = esc(text);
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const id = 'code-' + Math.random().toString(36).slice(2, 9); const id = 'code-' + Math.random().toString(36).slice(2, 9);
return `<pre><code id="${id}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`; const lineCount = (code.match(/\n/g) || []).length + 1;
const COLLAPSE_THRESHOLD = 15;
let toolbar = `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${id}')">Preview</button>`;
}
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
const block = `<pre class="code-block">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code>${toolbar}</pre>`;
if (lineCount > COLLAPSE_THRESHOLD) {
return `<details class="code-collapsible"><summary class="code-collapse-summary">Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines</summary>${block}</details>`;
}
return block;
}); });
html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
@@ -1730,6 +1770,50 @@ function esc(s) {
return d.innerHTML; return d.innerHTML;
} }
// Heuristic: does this code block look like HTML?
function _looksLikeHTML(code) {
const decoded = code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
return /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
}
// Toggle sandboxed HTML preview below a code block
function toggleHTMLPreview(codeId) {
const existing = document.getElementById('preview-' + codeId);
if (existing) {
existing.remove();
return;
}
const codeEl = document.getElementById(codeId);
if (!codeEl) return;
const pre = codeEl.closest('pre');
if (!pre) return;
// Decode HTML entities back to real HTML for the preview
const rawHTML = codeEl.textContent;
const wrapper = document.createElement('div');
wrapper.id = 'preview-' + codeId;
wrapper.className = 'html-preview-wrap';
const header = document.createElement('div');
header.className = 'html-preview-header';
header.innerHTML = `<span>HTML Preview</span><button class="html-preview-close" onclick="document.getElementById('preview-${codeId}').remove()">✕</button>`;
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 <pre> (or after <details> if collapsible)
const parent = pre.closest('.code-collapsible') || pre;
parent.after(wrapper);
}
// ── Helpers ────────────────────────────────── // ── Helpers ──────────────────────────────────
function _relativeTime(dateStr) { function _relativeTime(dateStr) {