Changeset 0.9.1 (#51)
This commit is contained in:
@@ -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
68
CHANGELOG.md
Normal 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
685
EXTENSIONS.md
Normal 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.
|
||||||
21
README.md
21
README.md
@@ -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
|
||||||
|
|||||||
541
ROADMAP.md
541
ROADMAP.md
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
219
src/js/app.js
219
src/js/app.js
@@ -32,7 +32,118 @@ const App = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Init ─────────────────────────────────────
|
// ── Token Estimation + Context Tracking ─────
|
||||||
|
|
||||||
|
const Tokens = {
|
||||||
|
// Rough heuristic: ~4 chars per token for English (GPT/Claude average).
|
||||||
|
// Not exact, but good enough for a UI indicator.
|
||||||
|
estimate(text) {
|
||||||
|
if (!text) return 0;
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Estimate tokens for the full conversation context sent to the model
|
||||||
|
estimateConversation(messages, systemPrompt) {
|
||||||
|
let total = 0;
|
||||||
|
// System prompt
|
||||||
|
if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters
|
||||||
|
// Messages
|
||||||
|
for (const m of messages) {
|
||||||
|
total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters)
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get context budget for current model
|
||||||
|
getContextBudget() {
|
||||||
|
const caps = UI.getSelectedModelCaps();
|
||||||
|
return {
|
||||||
|
maxContext: caps.max_context || 0,
|
||||||
|
maxOutput: caps.max_output_tokens || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Format token count for display
|
||||||
|
format(n) {
|
||||||
|
if (n >= 100000) return (n / 1000).toFixed(0) + 'K';
|
||||||
|
if (n >= 10000) return (n / 1000).toFixed(1) + 'K';
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||||
|
return String(n);
|
||||||
|
},
|
||||||
|
|
||||||
|
_warningDismissed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the token counter below the input
|
||||||
|
function updateInputTokens() {
|
||||||
|
const el = document.getElementById('inputTokenCount');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const input = document.getElementById('messageInput');
|
||||||
|
const inputText = input?.value || '';
|
||||||
|
const inputTokens = Tokens.estimate(inputText);
|
||||||
|
|
||||||
|
if (!inputText.trim()) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.className = 'input-token-count';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const budget = Tokens.getContextBudget();
|
||||||
|
if (budget.maxContext > 0) {
|
||||||
|
// Show relative to available context
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
|
||||||
|
const totalWithInput = convTokens + inputTokens;
|
||||||
|
const pct = totalWithInput / budget.maxContext;
|
||||||
|
el.textContent = `~${Tokens.format(inputTokens)} tokens · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`;
|
||||||
|
el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
|
||||||
|
} else {
|
||||||
|
el.textContent = `~${Tokens.format(inputTokens)} tokens`;
|
||||||
|
el.className = 'input-token-count';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check conversation length and show/hide warning
|
||||||
|
function updateContextWarning() {
|
||||||
|
const warning = document.getElementById('contextWarning');
|
||||||
|
const text = document.getElementById('contextWarningText');
|
||||||
|
if (!warning || !text) return;
|
||||||
|
|
||||||
|
const budget = Tokens.getContextBudget();
|
||||||
|
if (budget.maxContext <= 0) {
|
||||||
|
warning.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (!chat || !chat.messages?.length) {
|
||||||
|
warning.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt);
|
||||||
|
const pct = convTokens / budget.maxContext;
|
||||||
|
|
||||||
|
if (pct >= 0.9 && !Tokens._warningDismissed) {
|
||||||
|
warning.style.display = 'flex';
|
||||||
|
warning.className = 'context-warning danger';
|
||||||
|
text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context. Consider starting a new chat.`;
|
||||||
|
} else if (pct >= 0.75 && !Tokens._warningDismissed) {
|
||||||
|
warning.style.display = 'flex';
|
||||||
|
warning.className = 'context-warning';
|
||||||
|
text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`;
|
||||||
|
} else {
|
||||||
|
warning.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissContextWarning() {
|
||||||
|
Tokens._warningDismissed = true;
|
||||||
|
const el = document.getElementById('contextWarning');
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function init() {
|
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
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
100
src/js/ui.js
100
src/js/ui.js
@@ -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(/</g, '<').replace(/>/g, '>').replace(/&/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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user