Changeset 0.22.0.1 (#94)

This commit is contained in:
2026-03-02 01:55:19 +00:00
parent 12e03cec8b
commit 06c4e2a5a1
33 changed files with 1929 additions and 418 deletions

View File

@@ -2,6 +2,38 @@
All notable changes to Chat Switchboard.
## [0.22.0] — 2026-03-02
### Added
- **Provider health tracking.** In-memory accumulator records success/error/timeout per provider config, flushes hourly buckets to `provider_health` table every 60s. Status derivation: healthy (<5% errors), degraded (525%), down (>25%). Background prune removes data older than 7 days.
- **Health admin endpoints.** `GET /api/v1/admin/providers/:id/health` returns per-provider summary + hourly windows. `GET /api/v1/admin/providers/health` returns current-hour status for all providers.
- **Capability admin overrides.** `capability_overrides` table supports per-provider or global overrides for any model capability field. Three-tier resolution chain: catalog → heuristic → admin override (highest priority).
- **Capability override endpoints.** `GET /api/v1/admin/models/:id/capabilities` with source annotations (catalog/heuristic/override). `PUT` to set, `DELETE` to remove, `GET /api/v1/admin/capability-overrides` to list all.
- **Workspace pane layout.** Frontend architecture replaces `.chat-area` + `.side-panel` with `.workspace` flex container. Primary and secondary panes are independent with drag-resize handle between them. Surfaces declare layout preferences via `primary`, `secondary`, `secondaryOpts` fields.
### Fixed
- **`scanJSON` driver buffer aliasing** (v0.21.7 bugfix). `safe_json.go` now copies the `[]byte` from the database driver instead of aliasing the slice header. Fixes intermittent `invalid character` errors on multi-row JSON responses caused by driver buffer reuse between `rows.Next()` calls.
- **`CreateChannel` RETURNING path** now uses `scanJSON(&ch.Settings)` instead of bare `&ch.Settings` scan.
- **`UpdateChannel` settings validation** — `json.Valid()` check before JSONB merge, returns 400 on invalid JSON instead of storing corrupt data.
### Changed
- `capabilities/intrinsic.go`: `ResolveIntrinsic()` takes `[]models.CapabilityOverride` as third parameter. `applyOverrides()` flips bool fields and sets int fields.
- `handlers/completion.go`: `HealthRecorder` interface, `recordHealth()` called after every `ChatCompletion` call.
- `handlers/stream_loop.go`: `streamWithToolLoop` and `streamModelResponse` accept `configID` + `HealthRecorder` params. Health recorded on error, timeout, and success paths.
- `store/interfaces.go`: `CapOverrides CapabilityOverrideStore` added to `Stores` struct.
- `models/models.go`: `ProviderStatus`, `ProviderHealthWindow`, `ProviderHealthSummary`, `CapabilityOverride` types.
- `main.go`: Health accumulator lifecycle (start/stop/prune), admin route registration, startup log includes health status.
- `panels.js`: Simplified to single `_active` panel in workspace secondary pane. Removed `_dualMode`, `_splitRatio`, `_secondary`.
- `surfaces.js`: Layout declarations (`primary`, `secondary`, `secondaryOpts`) in surface registration.
- `editor-mode.js`: Layout declarations for editor surface.
- `index.html`: Workspace container structure, removed dual-view split button.
- `styles.css`: Workspace pane CSS grid, handle, responsive rules.
- `app.js`: `_initWorkspaceResize()` replaces `_initSidePanelResize()` + `_initDualDivider()`.
- `ui-settings.js`: Zoom selector targets updated class names.
### Database
- Migration 013 (Postgres) / 012 (SQLite): `provider_health` table (hourly bucketed metrics), `capability_overrides` table (admin corrections).
## [0.21.6] — 2026-03-01
### Added

View File

@@ -1 +1 @@
0.21.7
0.22.0

View File

@@ -43,7 +43,7 @@ Three Docker images support different deployment scenarios:
4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope.
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.
5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a three-tier priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID) → admin overrides (highest priority, from `capability_overrides` table). 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. Admins can correct any field via the override endpoints.
6. **Channels as Execution Context**: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure.
@@ -145,12 +145,15 @@ server/
│ └── ... # Mirror of postgres/ with dialect adaptations
├── models/models.go # Shared domain types
├── capabilities/
│ ├── intrinsic.go # Heuristic detection + resolution
│ ├── intrinsic.go # Heuristic detection + three-tier resolution (catalog → heuristic → admin override)
│ └── resolver.go # ModelsForUser() unified resolver
├── compaction/ # Conversation summarization engine
├── crypto/ # AES-256-GCM API key encryption
├── events/ # EventBus + WebSocket hub
├── extraction/ # Document text extraction pipeline
├── health/ # Provider health tracking (v0.22.0)
│ ├── accumulator.go # In-memory counters, 60s flush to DB, status derivation
│ └── accumulator_test.go # Concurrent recording, flush isolation, threshold tests
├── memory/ # Long-term memory extraction + scanning
│ ├── extractor.go # Conversation analysis → fact extraction
│ └── scanner.go # Background job: find channels needing extraction

View File

@@ -88,11 +88,20 @@ v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅
Indexing + Search (Development Mode)
│ │
v0.21.4 Git ✅ v0.21.6 Article Surface ✅
v0.21.4 Git ✅ v0.21.6 Editor Surface ✅
Integration + Document Output
└───────┬──────────────┘
v0.22.0 Smart Routing + Provider Extensions
v0.21.7 Bugfix: scanJSON driver buffer aliasing ✅
v0.22.0 Provider Health + Capability Overrides ✅
+ Workspace Pane Refactor (FE)
v0.22.1 Provider Extensions (declarative config)
v0.22.2 Routing Policies + Fallback Chains
v0.22.3 Provider Admin UI + Deferred Polish
v0.23.0 Multi-Participant Channels + Presence
@@ -832,14 +841,141 @@ Writing-focused surface. Document is artifact, conversation is secondary.
---
## v0.22.0 — Smart Routing + Provider Extensions
### v0.21.7 — Bugfix: scanJSON Driver Buffer Aliasing ✅
### Deferred from v0.21.x
Critical fix for intermittent 500 errors on paginated list endpoints.
Items moved from earlier sub-releases for proper scoping:
- [x] `scanJSON` []byte path: copy driver buffer instead of aliasing (`json.RawMessage(v)``make+copy`)
- [x] `CreateChannel` Postgres RETURNING path: use `scanJSON(&ch.Settings)` instead of bare `&ch.Settings`
- [x] `UpdateChannel` settings write: validate `json.Valid()` before JSONB merge, reject with 400
- [x] New tests: `TestScanJSON_ByteSliceNotAliased`, `TestScanJSON_ByteSliceIsolation_MultiRow`
**Root cause:** `json.RawMessage(v)` is a type conversion, not a copy — it aliases
the `[]byte` buffer owned by `database/sql`. Per the `Scanner` interface docs, `[]byte`
values are only valid until the next `rows.Scan()` call. In paginated list endpoints,
row N's Settings slice header pointed into memory that row N+1's scan overwrote,
producing corrupt JSON that passed `json.Valid()` at scan time but failed at marshal
time when `SafeJSON` serialized the full response.
---
## v0.22.0 — Provider Health + Capability Overrides ✅
Smallest shippable unit that unblocks routing. Track provider error rates
and latency, add admin override layer for model capabilities.
Depends on: usage tracking (v0.10.0), capabilities resolver (v0.9.1).
**Provider Health Tracking**
- [x] `provider_health` table: `provider_config_id` FK, `window_start` (hourly buckets), `request_count`, `error_count`, `timeout_count`, `total_latency_ms`, `max_latency_ms`, `last_error`, `last_error_at`
- [x] In-memory health accumulator: goroutine-safe counters per provider, flushed to DB on interval (60s)
- [x] `RecordSuccess`/`RecordError`/`RecordTimeout` called from completion handler after every provider call (sync + streaming)
- [x] Provider status derivation: `healthy` / `degraded` / `down` based on error rate thresholds (5% = degraded, 25% = down)
- [x] `GET /api/v1/admin/providers/:id/health` endpoint: current status + hourly windows (configurable hours param)
- [x] `GET /api/v1/admin/providers/health` endpoint: summary status for all providers
- [ ] _(deferred → v0.22.3)_ Health status included in `GET /api/v1/models/enabled` response (new `provider_status` field)
- [ ] _(deferred → v0.22.2)_ Auto-disable: optional policy to mark provider inactive when `down` for N consecutive windows
- [x] Background cleanup: prune health rows older than 7 days (6-hour cycle)
**Capability Admin Overrides**
- [x] `capability_overrides` table: `provider_config_id` (nullable for global), `model_id`, `field`, `value`, `set_by`, `created_at`
- [x] Three-tier resolution in `ResolveIntrinsic()`: catalog → heuristic → **admin override** (highest priority)
- [x] `PUT /api/v1/admin/models/:id/capabilities` endpoint: set individual capability overrides
- [x] `DELETE /api/v1/admin/models/:id/capabilities/:overrideId` endpoint: remove override
- [x] `GET /api/v1/admin/models/:id/capabilities` endpoint: show resolved caps with source annotation (catalog/heuristic/override)
- [x] `GET /api/v1/admin/capability-overrides` endpoint: list all overrides
- [x] Postgres + SQLite migrations (013_v0220_health.sql / sqlite 012_v0220_health.sql)
**Search Provider Health** _(deferred → v0.22.1)_
- [ ] `RecordOutcome` for web_search and url_fetch tool calls (search provider tracking)
- [ ] Rate limit tracking per provider config (requests/minute counter in health accumulator)
**Workspace Pane Refactor** (frontend)
- [x] `.workspace` flex container replaces `.chat-area` + `.side-panel` layout
- [x] `.workspace-primary` / `.workspace-secondary` independent pane model
- [x] `.workspace-handle` drag-resize between panes (replaces old side-panel-resize)
- [x] Surface layout declarations: `primary`, `secondary`, `secondaryOpts` per surface
- [x] Dual-view mode removed (was `_dualMode`, `_splitRatio`, `_secondary` in PanelRegistry)
- [x] Zoom selector updated for new class names
**Bugfix (v0.21.7)**
- [x] `scanJSON` driver buffer aliasing — copy `[]byte` from database driver instead of aliasing slice header
- [x] `CreateChannel` RETURNING path uses `scanJSON(&ch.Settings)` instead of bare scan
- [x] `UpdateChannel` validates `json.Valid()` on settings before JSONB merge
---
## v0.22.1 — Provider Extensions (Declarative Config)
Replace hardcoded provider-specific behavior with data-driven configuration.
Same model, different provider → different parameters.
Depends on: provider health (v0.22.0).
**Provider Profiles**
- [ ] `provider_profiles` JSONB column on `provider_configs` table (or separate table if schema cleaner)
- [ ] Profile schema per provider type: defines available settings, types, defaults, validation
- [ ] Built-in profile schemas for: openai, anthropic, venice, openrouter (others inherit openai-compatible)
- [ ] System prompt injection: provider-specific preambles (e.g. Venice character system prompt)
- [ ] Thinking mode toggle: per-provider `think` parameter mapping (Venice `venice_parameters.include_venice_system_prompt`, Anthropic `thinking.type=enabled`, etc.)
**Request/Response Transforms**
- [ ] `PreRequestHook(providerType string, profile JSONB, req *CompletionRequest)` — decorate request before dispatch
- [ ] `PostResponseHook(providerType string, profile JSONB, resp *StreamEvent)` — normalize provider-specific response fields
- [ ] Hook implementations: Venice thinking extraction, Anthropic extended thinking, OpenRouter model routing headers
- [ ] Preset-level overrides: Persona can override provider-specific settings ("always enable thinking for this Persona")
- [ ] `ProviderConfig.Settings` fully utilized: hooks read from config, not hardcoded switch statements
**Provider Type Registry** (prep for v0.22.2)
- [ ] `providers.Init()` reads provider type → adapter mapping from registry instead of hardcoded list
- [ ] New provider types registrable via config (openai-compatible endpoint + custom profile schema)
- [ ] `GET /api/v1/admin/provider-types` endpoint: list available provider types with their profile schemas
---
## v0.22.2 — Routing Policies + Fallback Chains
Rules-based routing engine — policy, not ML. Evaluated in `resolveConfig()`
between "what model was requested" and "which provider serves it."
Depends on: provider health (v0.22.0), provider extensions (v0.22.1).
**Routing Policies**
- [ ] `routing_policies` table: `id`, `name`, `scope` (global/team), `team_id`, `priority` (lower wins), `policy_type`, `config` (JSONB), `is_active`
- [ ] Policy types: `capability_match` ("cheapest model with tool_calling"), `provider_prefer` ("prefer provider X, fallback Y"), `team_route` ("team X uses provider Y"), `cost_limit` ("max $X/request")
- [ ] Policy evaluator: takes requested model + user context → returns ranked list of `(providerConfigID, modelID)` candidates
- [ ] Integration point: `resolveConfig()` calls evaluator after preset unwrap, before provider dispatch
**Fallback Chains**
- [ ] Fallback chain configuration: ordered list of providers for a given model or model family
- [ ] Health-aware: skip providers with status `down`, prefer `healthy` over `degraded`
- [ ] Automatic retry on provider error: next provider in chain, transparent to client (configurable max retries)
- [ ] Latency-aware preference: track response time percentiles, prefer faster providers (weight configurable)
- [ ] Cost-aware preference: factor pricing into routing decisions (cheapest-first, budget ceiling per request)
**Routing Metadata**
- [ ] `X-Switchboard-Provider` response header: which provider actually served the request (debug mode)
- [ ] `routing_decision` field in usage_log: selected provider, policy that matched, fallback depth
- [ ] `GET /api/v1/admin/routing/test` endpoint: dry-run policy evaluation for a given model + user
---
## v0.22.3 — Provider Admin UI + Deferred Polish
Admin interfaces for v0.22.0v0.22.2 features, plus deferred items from v0.21.x.
Depends on: routing policies (v0.22.2).
**Admin UI**
- [ ] Provider health dashboard: status badges, error rate sparklines, latency charts (last 24h)
- [ ] Capability override editor: per-model toggle grid with source indicators
- [ ] Provider profile editor: key-value config per provider type, preview of effective settings
- [ ] Routing policy builder: CRUD with priority ordering, dry-run test panel
- [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family
**Deferred from v0.21.x**
- [ ] Mobile: mode selector collapses to hamburger/bottom nav
- [ ] Integration with extension loader (surfaces from manifest.json — requires extension loader update)
- [ ] Integration with extension loader (surfaces from manifest.json)
- [ ] Workspace settings UI: git config section in channel/project settings
- [ ] User Settings: git credentials management UI
- [ ] `.gitignore` respect in workspace indexing
@@ -849,31 +985,6 @@ Items moved from earlier sub-releases for proper scoping:
- [ ] PDF/DOCX export via pandoc in article mode
- [ ] Project-specific file uploads (full project-level upload architecture)
Depends on: model roles (v0.10.0), usage tracking (v0.10.0).
Rules-based routing engine — policy, not ML. Plus declarative provider
configuration to replace hardcoded provider-specific behavior.
_(Shifted from v0.19.0 — routing content unchanged, provider extensions added)_
**Routing**
- [ ] 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
**Provider Extensions (declarative provider config)**
- [ ] `provider_profiles` table (or JSONB on `api_configs`): per-provider configuration schema
- [ ] System prompt injection: provider-specific preambles (e.g. Venice character system prompt)
- [ ] Thinking mode toggle: per-provider `think` parameter support (Venice `venice_parameters.include_venice_system_prompt`, Anthropic `thinking`, etc.)
- [ ] Request transform hooks: provider-specific parameter mapping before API call
- [ ] Response transform hooks: normalize provider-specific response fields
- [ ] Admin UI: provider profile editor (key-value config per provider type)
- [ ] Preset-level overrides: Persona can override provider-specific settings (e.g. "always enable thinking for this Persona")
- [ ] Provider health: track error rates, latency percentiles, auto-disable unhealthy providers
- [ ] Search/URL fetch rate limiting per provider
- [ ] Capability admin/user override: manual correction for model capabilities (`intrinsic.go` admin override layer)
---
## v0.23.0 — Multi-Participant Channels + Presence

View File

@@ -154,7 +154,7 @@ func TestResolveIntrinsic_CatalogWins(t *testing.T) {
ToolCalling: true,
MaxOutputTokens: 16384,
}
merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps)
merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps, nil)
if !merged.ToolCalling {
t.Error("tool_calling should be preserved from provider")
@@ -179,7 +179,7 @@ func TestResolveIntrinsic_ProviderDataPreserved(t *testing.T) {
MaxOutputTokens: 8192,
MaxContext: 65536,
}
merged := ResolveIntrinsic("some-unknown-model", &providerCaps)
merged := ResolveIntrinsic("some-unknown-model", &providerCaps, nil)
if !merged.ToolCalling {
t.Error("tool_calling should be preserved from provider")
@@ -191,7 +191,7 @@ func TestResolveIntrinsic_ProviderDataPreserved(t *testing.T) {
func TestResolveIntrinsic_NilProvider(t *testing.T) {
// Nil provider caps — falls through entirely to heuristic
merged := ResolveIntrinsic("gpt-4o", nil)
merged := ResolveIntrinsic("gpt-4o", nil, nil)
if !merged.ToolCalling {
t.Error("should get tool_calling from heuristic (gpt-4 pattern)")
@@ -203,7 +203,7 @@ func TestResolveIntrinsic_NilProvider(t *testing.T) {
func TestResolveIntrinsic_HeuristicOnly(t *testing.T) {
// No catalog, no known table — pure heuristic
merged := ResolveIntrinsic("deepseek-r1-distill-llama-70b", nil)
merged := ResolveIntrinsic("deepseek-r1-distill-llama-70b", nil, nil)
if !merged.Reasoning {
t.Error("should infer reasoning from deepseek-r1 pattern")
@@ -229,3 +229,71 @@ func TestHasProviderData(t *testing.T) {
t.Error("caps with max_context should have provider data")
}
}
func TestResolveIntrinsic_AdminOverrides(t *testing.T) {
// Catalog says no vision, heuristic says no vision,
// but admin override forces vision=true.
catalogCaps := &models.ModelCapabilities{
ToolCalling: true,
Vision: false,
}
overrides := []models.CapabilityOverride{
{Field: "vision", Value: "true"},
}
merged := ResolveIntrinsic("some-custom-model", catalogCaps, overrides)
if !merged.Vision {
t.Error("admin override should force vision=true")
}
if !merged.ToolCalling {
t.Error("catalog tool_calling should be preserved")
}
}
func TestResolveIntrinsic_AdminOverrides_DisableCapability(t *testing.T) {
// Heuristic infers tool_calling for gpt-4o, but admin says no.
overrides := []models.CapabilityOverride{
{Field: "tool_calling", Value: "false"},
}
merged := ResolveIntrinsic("gpt-4o", nil, overrides)
if merged.ToolCalling {
t.Error("admin override should disable tool_calling")
}
}
func TestResolveIntrinsic_AdminOverrides_IntValues(t *testing.T) {
overrides := []models.CapabilityOverride{
{Field: "max_context", Value: "200000"},
{Field: "max_output_tokens", Value: "16384"},
}
merged := ResolveIntrinsic("some-model", nil, overrides)
if merged.MaxContext != 200000 {
t.Errorf("expected max_context=200000, got %d", merged.MaxContext)
}
if merged.MaxOutputTokens != 16384 {
t.Errorf("expected max_output_tokens=16384, got %d", merged.MaxOutputTokens)
}
}
func TestResolveIntrinsic_OverridePriority(t *testing.T) {
// Catalog says max_context=128000, admin overrides to 256000.
// Admin should win.
catalogCaps := &models.ModelCapabilities{
MaxContext: 128000,
}
overrides := []models.CapabilityOverride{
{Field: "max_context", Value: "256000"},
}
merged := ResolveIntrinsic("some-model", catalogCaps, overrides)
if merged.MaxContext != 256000 {
t.Errorf("admin override should win over catalog: expected 256000, got %d", merged.MaxContext)
}
}

View File

@@ -111,14 +111,16 @@ func InferCapabilities(modelID string) models.ModelCapabilities {
}
// ResolveIntrinsic determines the intrinsic capabilities of a model.
// Priority:
// 1. catalogCaps (from model_catalog DB — synced from provider API)
// 2. Heuristic inference (regex patterns on model ID)
// Priority (highest wins):
// 1. Admin overrides (manual corrections from capability_overrides table)
// 2. Catalog (from model_catalog DB — synced from provider API)
// 3. Heuristic inference (regex patterns on model ID)
//
// The catalog is the source of truth per provider. Heuristics fill gaps
// for models that haven't been synced yet. No hardcoded model table —
// for models that haven't been synced yet. Admin overrides correct
// provider-reported or heuristic errors. 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, overrides []models.CapabilityOverride) models.ModelCapabilities {
// Start with catalog data if available
var base models.ModelCapabilities
if catalogCaps != nil && catalogCaps.HasProviderData() {
@@ -128,9 +130,56 @@ func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) mod
// Fill gaps from heuristic inference
inferred := InferCapabilities(modelID)
mergeGaps(&base, &inferred)
// Apply admin overrides (highest priority — can flip any field)
applyOverrides(&base, overrides)
return base
}
// applyOverrides applies admin capability corrections to the resolved capabilities.
func applyOverrides(caps *models.ModelCapabilities, overrides []models.CapabilityOverride) {
for _, o := range overrides {
boolVal := o.Value == "true" || o.Value == "1"
switch o.Field {
case "streaming":
caps.Streaming = boolVal
case "tool_calling":
caps.ToolCalling = boolVal
case "vision":
caps.Vision = boolVal
case "thinking":
caps.Thinking = boolVal
case "reasoning":
caps.Reasoning = boolVal
case "code_optimized":
caps.CodeOptimized = boolVal
case "web_search":
caps.WebSearch = boolVal
case "max_context":
if n := parseInt(o.Value); n > 0 {
caps.MaxContext = n
}
case "max_output_tokens":
if n := parseInt(o.Value); n > 0 {
caps.MaxOutputTokens = n
}
}
}
}
// parseInt parses a string to int, returning 0 on failure.
func parseInt(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}
// ResolveMaxOutput returns the max output tokens for a model.
// Priority: explicit caps → derive from context → 4096 fallback.
func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {

View File

@@ -63,7 +63,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
countGlobal := len(globalModels)
for _, entry := range globalModels {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
prov := providerMap[entry.ProviderConfigID]
result = append(result, models.UserModel{
@@ -100,7 +100,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
@@ -138,7 +138,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
@@ -181,7 +181,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps)
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps, nil)
// Load tool grants
toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID)
@@ -251,7 +251,7 @@ func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps)
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps, nil)
toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID)
if err != nil {

View File

@@ -0,0 +1,43 @@
-- v0.22.0: Provider health tracking + capability admin overrides.
-- ── Provider Health ─────────────────────────
-- Hourly bucketed health metrics per provider config.
-- In-memory accumulator flushes to this table every 60s.
-- Background job prunes rows older than 7 days.
CREATE TABLE IF NOT EXISTS provider_health (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
window_start TIMESTAMPTZ NOT NULL, -- hourly bucket floor
request_count INT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
timeout_count INT NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0, -- sum for avg calculation
max_latency_ms INT NOT NULL DEFAULT 0,
last_error TEXT,
last_error_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (provider_config_id, window_start)
);
CREATE INDEX IF NOT EXISTS idx_provider_health_window
ON provider_health (provider_config_id, window_start DESC);
-- ── Capability Overrides ────────────────────
-- Admin corrections for model capabilities. Highest priority in the
-- three-tier resolution chain: catalog → heuristic → admin override.
CREATE TABLE IF NOT EXISTS capability_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
field TEXT NOT NULL, -- tool_calling, vision, thinking, reasoning, etc.
value TEXT NOT NULL, -- "true"/"false" for bools, numeric string for ints
set_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Unique per (provider, model, field). NULL provider = applies to model globally.
UNIQUE (provider_config_id, model_id, field)
);
CREATE INDEX IF NOT EXISTS idx_capability_overrides_model
ON capability_overrides (model_id);

View File

@@ -0,0 +1,35 @@
-- v0.22.0: Provider health tracking + capability admin overrides.
CREATE TABLE IF NOT EXISTS provider_health (
id TEXT PRIMARY KEY,
provider_config_id TEXT NOT NULL,
window_start TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
timeout_count INTEGER NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
max_latency_ms INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
last_error_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (provider_config_id, window_start)
);
CREATE INDEX IF NOT EXISTS idx_provider_health_window
ON provider_health (provider_config_id, window_start);
CREATE TABLE IF NOT EXISTS capability_overrides (
id TEXT PRIMARY KEY,
provider_config_id TEXT,
model_id TEXT NOT NULL,
field TEXT NOT NULL,
value TEXT NOT NULL,
set_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (provider_config_id, model_id, field)
);
CREATE INDEX IF NOT EXISTS idx_capability_overrides_model
ON capability_overrides (model_id);

View File

@@ -94,7 +94,7 @@ func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool)
}
// Merge with known data to fill gaps
resolved := capspkg.ResolveIntrinsic(modelID, &caps)
resolved := capspkg.ResolveIntrinsic(modelID, &caps, nil)
return resolved, true
}
@@ -140,7 +140,7 @@ func liveQueryModelCaps(c *gin.Context, configID, modelID string) (models.ModelC
for _, m := range modelList {
if m.ID == modelID {
resolved := capspkg.ResolveIntrinsic(modelID, &m.Capabilities)
resolved := capspkg.ResolveIntrinsic(modelID, &m.Capabilities, nil)
return resolved, true
}
}

View File

@@ -10,6 +10,7 @@ import (
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -50,6 +51,15 @@ type CompletionHandler struct {
hub *events.Hub // WebSocket hub for browser tool bridge
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
embedder *knowledge.Embedder // for memory semantic recall (v0.18.0)
health HealthRecorder // provider health tracking (v0.22.0, nil = disabled)
}
// HealthRecorder is the interface for recording provider call outcomes.
// Matches health.Accumulator methods without importing the package.
type HealthRecorder interface {
RecordSuccess(providerConfigID string, latencyMs int)
RecordError(providerConfigID string, latencyMs int, errMsg string)
RecordTimeout(providerConfigID string, latencyMs int, errMsg string)
}
// NewCompletionHandler creates a new handler.
@@ -57,6 +67,11 @@ func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *e
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore, embedder: embedder}
}
// SetHealthRecorder attaches the provider health accumulator.
func (h *CompletionHandler) SetHealthRecorder(hr HealthRecorder) {
h.health = hr
}
// ── Chat Completion ─────────────────────────
// POST /api/v1/chat/completions
//
@@ -371,7 +386,7 @@ func (h *CompletionHandler) multiModelStream(
}
// Stream this model's response using the shared streaming core
result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, workspaceID, h.hub)
result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, workspaceID, configID, h.hub, h.health)
// Persist assistant message with model attribution
if result.Content != "" {
@@ -536,7 +551,7 @@ func (h *CompletionHandler) streamCompletion(
req providers.CompletionRequest,
channelID, userID, model, configID, providerScope, personaID, workspaceID string,
) {
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, personaID, workspaceID, h.hub)
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, personaID, workspaceID, configID, h.hub, h.health)
// Persist assistant response
if result.Content != "" {
@@ -566,7 +581,9 @@ func (h *CompletionHandler) syncCompletion(
var allToolActivity []map[string]interface{}
for iteration := 0; iteration < maxToolIterations; iteration++ {
callStart := time.Now()
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
h.recordHealth(configID, callStart, err)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
return
@@ -1175,3 +1192,21 @@ func calcCost(tokens int, pricePerM *float64) *float64 {
cost := float64(tokens) * *pricePerM / 1_000_000.0
return &cost
}
// recordHealth records provider call outcome for health tracking (v0.22.0).
func (h *CompletionHandler) recordHealth(configID string, start time.Time, err error) {
if h.health == nil || configID == "" {
return
}
latencyMs := int(time.Since(start).Milliseconds())
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") {
h.health.RecordTimeout(configID, latencyMs, errMsg)
} else {
h.health.RecordError(configID, latencyMs, errMsg)
}
} else {
h.health.RecordSuccess(configID, latencyMs)
}
}

View File

@@ -0,0 +1,280 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Health Admin Handler ────────────────────
type HealthAdminHandler struct {
healthStore health.Store
stores store.Stores
}
func NewHealthAdminHandler(hs health.Store, stores store.Stores) *HealthAdminHandler {
return &HealthAdminHandler{healthStore: hs, stores: stores}
}
// GetProviderHealth returns health metrics for a single provider.
// GET /api/v1/admin/providers/:id/health?hours=24
func (h *HealthAdminHandler) GetProviderHealth(c *gin.Context) {
providerID := c.Param("id")
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
if hours < 1 || hours > 168 {
hours = 24
}
windows, err := h.healthStore.ListWindows(c.Request.Context(), providerID, hours)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch health data"})
return
}
// Derive current status from most recent window
var summary models.ProviderHealthSummary
summary.ProviderConfigID = providerID
if len(windows) > 0 {
latest := windows[0]
summary.RequestCount = latest.RequestCount
summary.ErrorRate = latest.ErrorRate()
summary.AvgLatencyMs = latest.AvgLatencyMs()
summary.MaxLatencyMs = latest.MaxLatencyMs
summary.LastError = latest.LastError
summary.LastErrorAt = latest.LastErrorAt
summary.Status = health.DeriveStatus(summary.ErrorRate, summary.RequestCount)
} else {
summary.Status = models.StatusUnknown
}
SafeJSON(c, http.StatusOK, gin.H{
"summary": summary,
"windows": windows,
})
}
// GetAllProviderHealth returns current health for all providers.
// GET /api/v1/admin/providers/health
func (h *HealthAdminHandler) GetAllProviderHealth(c *gin.Context) {
windows, err := h.healthStore.ListAllCurrent(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch health data"})
return
}
// Build summaries
summaries := make([]models.ProviderHealthSummary, 0, len(windows))
for _, w := range windows {
s := models.ProviderHealthSummary{
ProviderConfigID: w.ProviderConfigID,
Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount),
RequestCount: w.RequestCount,
ErrorRate: w.ErrorRate(),
AvgLatencyMs: w.AvgLatencyMs(),
MaxLatencyMs: w.MaxLatencyMs,
LastError: w.LastError,
LastErrorAt: w.LastErrorAt,
}
summaries = append(summaries, s)
}
SafeJSON(c, http.StatusOK, gin.H{
"data": summaries,
})
}
// ── Capability Override Admin Handler ────────
type CapOverrideAdminHandler struct {
stores store.Stores
}
func NewCapOverrideAdminHandler(stores store.Stores) *CapOverrideAdminHandler {
return &CapOverrideAdminHandler{stores: stores}
}
// GetModelCapabilities returns resolved capabilities with source annotation.
// GET /api/v1/admin/models/:id/capabilities?provider_config_id=...
func (h *CapOverrideAdminHandler) GetModelCapabilities(c *gin.Context) {
modelID := c.Param("id")
providerConfigID := c.Query("provider_config_id")
// Get catalog capabilities
var catalogCaps *models.ModelCapabilities
if providerConfigID != "" {
if entry, err := h.stores.Catalog.GetByModelID(c.Request.Context(), providerConfigID, modelID); err == nil {
catalogCaps = &entry.Capabilities
}
} else {
if entry, err := h.stores.Catalog.GetByModelIDAny(c.Request.Context(), modelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Get admin overrides
var overrides []models.CapabilityOverride
var err error
if providerConfigID != "" {
overrides, err = h.stores.CapOverrides.ListForProviderModel(c.Request.Context(), providerConfigID, modelID)
} else {
overrides, err = h.stores.CapOverrides.ListForModel(c.Request.Context(), modelID)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overrides"})
return
}
// Resolve through three tiers
heuristic := capspkg.InferCapabilities(modelID)
resolved := capspkg.ResolveIntrinsic(modelID, catalogCaps, overrides)
// Build source annotations
sources := buildSourceAnnotations(catalogCaps, &heuristic, overrides)
SafeJSON(c, http.StatusOK, gin.H{
"model_id": modelID,
"resolved": resolved,
"catalog": catalogCaps,
"heuristic": heuristic,
"overrides": overrides,
"sources": sources,
})
}
// SetModelCapability sets an admin override for a specific capability.
// PUT /api/v1/admin/models/:id/capabilities
func (h *CapOverrideAdminHandler) SetModelCapability(c *gin.Context) {
modelID := c.Param("id")
userID := c.GetString("user_id")
var req struct {
ProviderConfigID *string `json:"provider_config_id"`
Field string `json:"field" binding:"required"`
Value string `json:"value" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate field name
validFields := map[string]bool{
"streaming": true, "tool_calling": true, "vision": true,
"thinking": true, "reasoning": true, "code_optimized": true,
"web_search": true, "max_context": true, "max_output_tokens": true,
}
if !validFields[req.Field] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capability field: " + req.Field})
return
}
// Validate value
switch req.Field {
case "max_context", "max_output_tokens":
if _, err := strconv.Atoi(req.Value); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "value must be a number for " + req.Field})
return
}
default:
if req.Value != "true" && req.Value != "false" {
c.JSON(http.StatusBadRequest, gin.H{"error": "value must be 'true' or 'false' for " + req.Field})
return
}
}
override := &models.CapabilityOverride{
ProviderConfigID: req.ProviderConfigID,
ModelID: modelID,
Field: req.Field,
Value: req.Value,
SetBy: &userID,
}
if err := h.stores.CapOverrides.Set(c.Request.Context(), override); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set override"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// DeleteModelCapability removes a specific override.
// DELETE /api/v1/admin/models/:id/capabilities/:overrideId
func (h *CapOverrideAdminHandler) DeleteModelCapability(c *gin.Context) {
overrideID := c.Param("overrideId")
if err := h.stores.CapOverrides.Delete(c.Request.Context(), overrideID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete override"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ListAllOverrides returns all capability overrides (admin view).
// GET /api/v1/admin/capability-overrides
func (h *CapOverrideAdminHandler) ListAllOverrides(c *gin.Context) {
overrides, err := h.stores.CapOverrides.ListAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overrides"})
return
}
SafeJSON(c, http.StatusOK, gin.H{
"data": overrides,
"total": len(overrides),
})
}
// ── Source Annotation Helpers ────────────────
type capSource struct {
Field string `json:"field"`
Source string `json:"source"` // "catalog", "heuristic", "override"
}
func buildSourceAnnotations(catalog *models.ModelCapabilities, heuristic *models.ModelCapabilities, overrides []models.CapabilityOverride) []capSource {
// Build override set for quick lookup
overrideSet := make(map[string]bool, len(overrides))
for _, o := range overrides {
overrideSet[o.Field] = true
}
fields := []struct {
name string
hasCatalog bool
hasHeur bool
}{
{"streaming", catalog != nil && catalog.Streaming, heuristic.Streaming},
{"tool_calling", catalog != nil && catalog.ToolCalling, heuristic.ToolCalling},
{"vision", catalog != nil && catalog.Vision, heuristic.Vision},
{"thinking", catalog != nil && catalog.Thinking, heuristic.Thinking},
{"reasoning", catalog != nil && catalog.Reasoning, heuristic.Reasoning},
{"code_optimized", catalog != nil && catalog.CodeOptimized, heuristic.CodeOptimized},
{"web_search", catalog != nil && catalog.WebSearch, heuristic.WebSearch},
{"max_context", catalog != nil && catalog.MaxContext > 0, heuristic.MaxContext > 0},
{"max_output_tokens", catalog != nil && catalog.MaxOutputTokens > 0, heuristic.MaxOutputTokens > 0},
}
var sources []capSource
for _, f := range fields {
src := "default"
if overrideSet[f.name] {
src = "override"
} else if f.hasCatalog {
src = "catalog"
} else if f.hasHeur {
src = "heuristic"
}
sources = append(sources, capSource{Field: f.name, Source: src})
}
return sources
}

View File

@@ -516,7 +516,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, personaID, workspaceID, h.hub)
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, personaID, workspaceID, configID, h.hub, comp.health)
// Persist as sibling (regen) with tool activity
if result.Content != "" {

View File

@@ -27,6 +27,20 @@ type streamResult struct {
CacheReadTokens int
}
// recordHealthFn records provider health from standalone streaming functions.
func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err error) {
if hr == nil || configID == "" {
return
}
latencyMs := int(time.Since(start).Milliseconds())
if err != nil {
errMsg := err.Error()
hr.RecordError(configID, latencyMs, errMsg)
} else {
hr.RecordSuccess(configID, latencyMs)
}
}
// streamWithToolLoop is the single, canonical streaming implementation.
// It handles SSE setup, multi-round tool execution, reasoning_content
// forwarding, and client notifications. Returns the accumulated content
@@ -43,8 +57,9 @@ func streamWithToolLoop(
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, userID, channelID, personaID, workspaceID string,
model, userID, channelID, personaID, workspaceID, configID string,
hub *events.Hub,
health HealthRecorder,
) streamResult {
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
@@ -74,8 +89,10 @@ func streamWithToolLoop(
var result streamResult
for iteration := 0; iteration < maxToolIterations; iteration++ {
callStart := time.Now()
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
if err != nil {
recordHealthFn(health, configID, callStart, err)
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
return result
}
@@ -86,6 +103,7 @@ func streamWithToolLoop(
for event := range ch {
if event.Error != nil {
recordHealthFn(health, configID, callStart, event.Error)
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
return result
}
@@ -107,6 +125,7 @@ func streamWithToolLoop(
}
if event.Done {
recordHealthFn(health, configID, callStart, nil)
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
// Accumulate tokens from this tool-call iteration
@@ -268,8 +287,9 @@ func streamModelResponse(
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, displayName, userID, channelID, personaID, workspaceID string,
model, displayName, userID, channelID, personaID, workspaceID, configID string,
hub *events.Hub,
health HealthRecorder,
) streamResult {
flusher, _ := c.Writer.(http.Flusher)
flush := func() {
@@ -289,8 +309,10 @@ func streamModelResponse(
var result streamResult
for iteration := 0; iteration < maxToolIterations; iteration++ {
callStart := time.Now()
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
if err != nil {
recordHealthFn(health, configID, callStart, err)
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
return result
}
@@ -301,6 +323,7 @@ func streamModelResponse(
for event := range ch {
if event.Error != nil {
recordHealthFn(health, configID, callStart, event.Error)
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
return result
}
@@ -320,6 +343,7 @@ func streamModelResponse(
}
if event.Done {
recordHealthFn(health, configID, callStart, nil)
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
result.InputTokens += event.InputTokens

View File

@@ -310,7 +310,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
out := make([]modelInfo, 0, len(modelList))
for _, m := range modelList {
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities)
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities, nil)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
out = append(out, modelInfo{ID: m.ID, Capabilities: caps})
}

View File

@@ -0,0 +1,219 @@
// Package health provides in-memory accumulation of provider health
// metrics with periodic flush to the database. The accumulator is
// goroutine-safe and designed for high-throughput recording from
// concurrent completion handlers.
package health
import (
"context"
"log"
"sync"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Thresholds ──────────────────────────────
const (
// DegradedThreshold — error rate above this = degraded.
DegradedThreshold = 0.05 // 5%
// DownThreshold — error rate above this = down.
DownThreshold = 0.25 // 25%
// FlushInterval — how often in-memory counters are flushed to DB.
FlushInterval = 60 * time.Second
// PruneAge — health windows older than this are deleted.
PruneAge = 7 * 24 * time.Hour
)
// ── Store Interface ─────────────────────────
// Store is the persistence layer for health data.
type Store interface {
// UpsertWindow merges in-memory counters into the hourly bucket row.
UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error
// GetCurrentWindow returns the current hourly bucket for a provider.
GetCurrentWindow(ctx context.Context, providerConfigID string) (*models.ProviderHealthWindow, error)
// ListWindows returns the last N hourly buckets for a provider, newest first.
ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error)
// ListAllCurrent returns the current-hour bucket for every provider.
ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error)
// Prune deletes rows older than the given time.
Prune(ctx context.Context, before time.Time) (int64, error)
}
// ── In-Memory Bucket ────────────────────────
// bucket holds counters for one provider within one flush interval.
type bucket struct {
providerConfigID string
requestCount int
errorCount int
timeoutCount int
totalLatencyMs int64
maxLatencyMs int
lastError string
lastErrorAt time.Time
}
// ── Accumulator ─────────────────────────────
// Accumulator collects health metrics in memory and periodically
// flushes them to the database. One instance per process.
type Accumulator struct {
mu sync.Mutex
buckets map[string]*bucket // keyed by provider_config_id
store Store
stopCh chan struct{}
wg sync.WaitGroup
}
// NewAccumulator creates and starts the background flush loop.
func NewAccumulator(store Store) *Accumulator {
a := &Accumulator{
buckets: make(map[string]*bucket),
store: store,
stopCh: make(chan struct{}),
}
a.wg.Add(1)
go a.flushLoop()
return a
}
// RecordSuccess records a successful provider call.
func (a *Accumulator) RecordSuccess(providerConfigID string, latencyMs int) {
a.mu.Lock()
defer a.mu.Unlock()
b := a.getBucket(providerConfigID)
b.requestCount++
b.totalLatencyMs += int64(latencyMs)
if latencyMs > b.maxLatencyMs {
b.maxLatencyMs = latencyMs
}
}
// RecordError records a failed provider call.
func (a *Accumulator) RecordError(providerConfigID string, latencyMs int, errMsg string) {
a.mu.Lock()
defer a.mu.Unlock()
b := a.getBucket(providerConfigID)
b.requestCount++
b.errorCount++
b.totalLatencyMs += int64(latencyMs)
if latencyMs > b.maxLatencyMs {
b.maxLatencyMs = latencyMs
}
b.lastError = errMsg
b.lastErrorAt = time.Now().UTC()
}
// RecordTimeout records a timed-out provider call (counted as both error and timeout).
func (a *Accumulator) RecordTimeout(providerConfigID string, latencyMs int, errMsg string) {
a.mu.Lock()
defer a.mu.Unlock()
b := a.getBucket(providerConfigID)
b.requestCount++
b.errorCount++
b.timeoutCount++
b.totalLatencyMs += int64(latencyMs)
if latencyMs > b.maxLatencyMs {
b.maxLatencyMs = latencyMs
}
b.lastError = errMsg
b.lastErrorAt = time.Now().UTC()
}
// Stop halts the flush loop and performs a final flush.
func (a *Accumulator) Stop() {
close(a.stopCh)
a.wg.Wait()
}
// DeriveStatus computes the provider status from an error rate.
func DeriveStatus(errorRate float64, requestCount int) models.ProviderStatus {
if requestCount == 0 {
return models.StatusUnknown
}
if errorRate >= DownThreshold {
return models.StatusDown
}
if errorRate >= DegradedThreshold {
return models.StatusDegraded
}
return models.StatusHealthy
}
// ── Internal ────────────────────────────────
func (a *Accumulator) getBucket(providerConfigID string) *bucket {
b, ok := a.buckets[providerConfigID]
if !ok {
b = &bucket{providerConfigID: providerConfigID}
a.buckets[providerConfigID] = b
}
return b
}
func (a *Accumulator) flushLoop() {
defer a.wg.Done()
ticker := time.NewTicker(FlushInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.flush()
case <-a.stopCh:
a.flush() // final flush
return
}
}
}
func (a *Accumulator) flush() {
a.mu.Lock()
if len(a.buckets) == 0 {
a.mu.Unlock()
return
}
// Swap out current buckets so we don't hold the lock during DB writes.
snap := a.buckets
a.buckets = make(map[string]*bucket, len(snap))
a.mu.Unlock()
windowStart := hourFloor(time.Now().UTC())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, b := range snap {
if b.requestCount == 0 {
continue
}
w := &models.ProviderHealthWindow{
ProviderConfigID: b.providerConfigID,
WindowStart: windowStart,
RequestCount: b.requestCount,
ErrorCount: b.errorCount,
TimeoutCount: b.timeoutCount,
TotalLatencyMs: b.totalLatencyMs,
MaxLatencyMs: b.maxLatencyMs,
}
if b.lastError != "" {
w.LastError = &b.lastError
ts := b.lastErrorAt.Format(time.RFC3339)
w.LastErrorAt = &ts
}
if err := a.store.UpsertWindow(ctx, w); err != nil {
log.Printf("⚠ health: flush failed for provider %s: %v", b.providerConfigID, err)
}
}
}
// hourFloor truncates a time to the start of its hour.
func hourFloor(t time.Time) time.Time {
return t.Truncate(time.Hour)
}

View File

@@ -0,0 +1,239 @@
package health
import (
"context"
"sync"
"testing"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Mock Store ──────────────────────────────
type mockStore struct {
mu sync.Mutex
windows map[string]*models.ProviderHealthWindow // keyed by provider_config_id
}
func newMockStore() *mockStore {
return &mockStore{windows: make(map[string]*models.ProviderHealthWindow)}
}
func (m *mockStore) UpsertWindow(_ context.Context, w *models.ProviderHealthWindow) error {
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.windows[w.ProviderConfigID]
if ok {
existing.RequestCount += w.RequestCount
existing.ErrorCount += w.ErrorCount
existing.TimeoutCount += w.TimeoutCount
existing.TotalLatencyMs += w.TotalLatencyMs
if w.MaxLatencyMs > existing.MaxLatencyMs {
existing.MaxLatencyMs = w.MaxLatencyMs
}
if w.LastError != nil {
existing.LastError = w.LastError
existing.LastErrorAt = w.LastErrorAt
}
} else {
cp := *w
m.windows[w.ProviderConfigID] = &cp
}
return nil
}
func (m *mockStore) GetCurrentWindow(_ context.Context, id string) (*models.ProviderHealthWindow, error) {
m.mu.Lock()
defer m.mu.Unlock()
w, ok := m.windows[id]
if !ok {
return nil, nil
}
cp := *w
return &cp, nil
}
func (m *mockStore) ListWindows(_ context.Context, _ string, _ int) ([]models.ProviderHealthWindow, error) {
return nil, nil
}
func (m *mockStore) ListAllCurrent(_ context.Context) ([]models.ProviderHealthWindow, error) {
m.mu.Lock()
defer m.mu.Unlock()
var result []models.ProviderHealthWindow
for _, w := range m.windows {
result = append(result, *w)
}
return result, nil
}
func (m *mockStore) Prune(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
// ── Tests ───────────────────────────────────
func TestRecordSuccess(t *testing.T) {
ms := newMockStore()
a := &Accumulator{
buckets: make(map[string]*bucket),
store: ms,
stopCh: make(chan struct{}),
}
a.RecordSuccess("prov-1", 100)
a.RecordSuccess("prov-1", 200)
a.RecordSuccess("prov-2", 50)
a.flush()
w1, _ := ms.GetCurrentWindow(context.Background(), "prov-1")
if w1 == nil {
t.Fatal("expected prov-1 window")
}
if w1.RequestCount != 2 {
t.Fatalf("expected 2 requests, got %d", w1.RequestCount)
}
if w1.ErrorCount != 0 {
t.Fatalf("expected 0 errors, got %d", w1.ErrorCount)
}
if w1.TotalLatencyMs != 300 {
t.Fatalf("expected 300ms total latency, got %d", w1.TotalLatencyMs)
}
if w1.MaxLatencyMs != 200 {
t.Fatalf("expected 200ms max latency, got %d", w1.MaxLatencyMs)
}
w2, _ := ms.GetCurrentWindow(context.Background(), "prov-2")
if w2 == nil {
t.Fatal("expected prov-2 window")
}
if w2.RequestCount != 1 {
t.Fatalf("expected 1 request, got %d", w2.RequestCount)
}
}
func TestRecordError(t *testing.T) {
ms := newMockStore()
a := &Accumulator{
buckets: make(map[string]*bucket),
store: ms,
stopCh: make(chan struct{}),
}
a.RecordSuccess("prov-1", 100)
a.RecordError("prov-1", 500, "connection refused")
a.RecordSuccess("prov-1", 150)
a.flush()
w, _ := ms.GetCurrentWindow(context.Background(), "prov-1")
if w.RequestCount != 3 {
t.Fatalf("expected 3 requests, got %d", w.RequestCount)
}
if w.ErrorCount != 1 {
t.Fatalf("expected 1 error, got %d", w.ErrorCount)
}
if w.LastError == nil || *w.LastError != "connection refused" {
t.Fatalf("expected last_error='connection refused', got %v", w.LastError)
}
}
func TestRecordTimeout(t *testing.T) {
ms := newMockStore()
a := &Accumulator{
buckets: make(map[string]*bucket),
store: ms,
stopCh: make(chan struct{}),
}
a.RecordTimeout("prov-1", 30000, "context deadline exceeded")
a.flush()
w, _ := ms.GetCurrentWindow(context.Background(), "prov-1")
if w.RequestCount != 1 {
t.Fatalf("expected 1 request, got %d", w.RequestCount)
}
if w.ErrorCount != 1 {
t.Fatalf("expected 1 error, got %d", w.ErrorCount)
}
if w.TimeoutCount != 1 {
t.Fatalf("expected 1 timeout, got %d", w.TimeoutCount)
}
}
func TestDeriveStatus(t *testing.T) {
tests := []struct {
rate float64
count int
expected models.ProviderStatus
}{
{0, 0, models.StatusUnknown},
{0, 100, models.StatusHealthy},
{0.03, 100, models.StatusHealthy},
{0.05, 100, models.StatusDegraded},
{0.15, 100, models.StatusDegraded},
{0.25, 100, models.StatusDown},
{0.50, 100, models.StatusDown},
{1.0, 1, models.StatusDown},
}
for _, tt := range tests {
got := DeriveStatus(tt.rate, tt.count)
if got != tt.expected {
t.Errorf("DeriveStatus(%v, %d) = %s, want %s", tt.rate, tt.count, got, tt.expected)
}
}
}
func TestFlushClearsBuckets(t *testing.T) {
ms := newMockStore()
a := &Accumulator{
buckets: make(map[string]*bucket),
store: ms,
stopCh: make(chan struct{}),
}
a.RecordSuccess("prov-1", 100)
a.flush()
// After flush, buckets should be empty
a.mu.Lock()
count := len(a.buckets)
a.mu.Unlock()
if count != 0 {
t.Fatalf("expected 0 buckets after flush, got %d", count)
}
}
func TestConcurrentRecording(t *testing.T) {
ms := newMockStore()
a := &Accumulator{
buckets: make(map[string]*bucket),
store: ms,
stopCh: make(chan struct{}),
}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
a.RecordSuccess("prov-1", 50)
}()
}
wg.Wait()
a.flush()
w, _ := ms.GetCurrentWindow(context.Background(), "prov-1")
if w.RequestCount != 100 {
t.Fatalf("expected 100 concurrent requests, got %d", w.RequestCount)
}
}
func TestHourFloor(t *testing.T) {
ts := time.Date(2026, 3, 1, 14, 37, 42, 0, time.UTC)
floor := hourFloor(ts)
expected := time.Date(2026, 3, 1, 14, 0, 0, 0, time.UTC)
if !floor.Equal(expected) {
t.Fatalf("expected %v, got %v", expected, floor)
}
}

View File

@@ -18,6 +18,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/extraction"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/memory"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
@@ -54,6 +55,8 @@ func main() {
uekCache := crypto.NewUEKCache()
var keyResolver *crypto.KeyResolver
var objStore storage.ObjectStore
var healthAccum *health.Accumulator
var healthStore *postgres.HealthStore
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
@@ -89,6 +92,26 @@ func main() {
// Initialize store layer
stores = postgres.NewStores(database.DB)
// Provider health accumulator (v0.22.0)
healthStore = postgres.NewHealthStore(database.DB)
healthAccum = health.NewAccumulator(healthStore)
defer healthAccum.Stop()
// Background health cleanup: prune windows older than 7 days
go func() {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if n, err := healthStore.Prune(ctx, time.Now().UTC().Add(-health.PruneAge)); err != nil {
log.Printf("⚠ health prune failed: %v", err)
} else if n > 0 {
log.Printf("🧹 health: pruned %d old windows", n)
}
cancel()
}
}()
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores)
@@ -350,6 +373,9 @@ func main() {
// Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
if healthAccum != nil {
comp.SetHealthRecorder(healthAccum)
}
protected.POST("/chat/completions", comp.Complete)
protected.GET("/tools", comp.ListTools)
@@ -722,6 +748,18 @@ func main() {
admin.POST("/extensions", extAdm.AdminInstallExtension)
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
// Provider Health (admin — v0.22.0)
healthAdm := handlers.NewHealthAdminHandler(healthStore, stores)
admin.GET("/providers/health", healthAdm.GetAllProviderHealth)
admin.GET("/providers/:id/health", healthAdm.GetProviderHealth)
// Capability Overrides (admin — v0.22.0)
capAdm := handlers.NewCapOverrideAdminHandler(stores)
admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities)
admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability)
admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability)
admin.GET("/capability-overrides", capAdm.ListAllOverrides)
}
}
@@ -744,6 +782,7 @@ func main() {
log.Printf(" Storage: disabled")
}
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
log.Printf(" Health: provider tracking active (flush=%s, prune=%s)", health.FlushInterval, health.PruneAge)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}

View File

@@ -864,3 +864,76 @@ type ChannelKB struct {
Enabled bool `json:"enabled" db:"enabled"`
DocumentCount int `json:"document_count" db:"document_count"`
}
// =========================================
// PROVIDER HEALTH (v0.22.0)
// =========================================
// ProviderStatus represents the derived health state.
type ProviderStatus string
const (
StatusHealthy ProviderStatus = "healthy"
StatusDegraded ProviderStatus = "degraded"
StatusDown ProviderStatus = "down"
StatusUnknown ProviderStatus = "unknown"
)
// ProviderHealthWindow is one hourly bucket of health metrics.
type ProviderHealthWindow struct {
ID string `json:"id" db:"id"`
ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"`
WindowStart time.Time `json:"window_start" db:"window_start"`
RequestCount int `json:"request_count" db:"request_count"`
ErrorCount int `json:"error_count" db:"error_count"`
TimeoutCount int `json:"timeout_count" db:"timeout_count"`
TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"`
MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"`
LastError *string `json:"last_error,omitempty" db:"last_error"`
LastErrorAt *string `json:"last_error_at,omitempty" db:"last_error_at"`
}
// AvgLatencyMs returns the average latency, or 0 if no requests.
func (w *ProviderHealthWindow) AvgLatencyMs() int {
if w.RequestCount == 0 {
return 0
}
return int(w.TotalLatencyMs / int64(w.RequestCount))
}
// ErrorRate returns the error fraction, or 0 if no requests.
func (w *ProviderHealthWindow) ErrorRate() float64 {
if w.RequestCount == 0 {
return 0
}
return float64(w.ErrorCount) / float64(w.RequestCount)
}
// ProviderHealthSummary is the API response for a provider's current health.
type ProviderHealthSummary struct {
ProviderConfigID string `json:"provider_config_id"`
ProviderName string `json:"provider_name,omitempty"`
Status ProviderStatus `json:"status"`
RequestCount int `json:"request_count"` // last hour
ErrorRate float64 `json:"error_rate"` // last hour
AvgLatencyMs int `json:"avg_latency_ms"` // last hour
MaxLatencyMs int `json:"max_latency_ms"` // last hour
LastError *string `json:"last_error,omitempty"`
LastErrorAt *string `json:"last_error_at,omitempty"`
}
// =========================================
// CAPABILITY OVERRIDES (v0.22.0)
// =========================================
// CapabilityOverride is an admin correction for a model's capabilities.
type CapabilityOverride struct {
ID string `json:"id" db:"id"`
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
ModelID string `json:"model_id" db:"model_id"`
Field string `json:"field" db:"field"`
Value string `json:"value" db:"value"`
SetBy *string `json:"set_by,omitempty" db:"set_by"`
CreatedAt string `json:"created_at" db:"created_at"`
}

View File

@@ -44,6 +44,7 @@ type Stores struct {
NotifPrefs NotificationPreferenceStore
Workspaces WorkspaceStore
GitCredentials GitCredentialStore
CapOverrides CapabilityOverrideStore
}
// =========================================
@@ -562,6 +563,30 @@ type GitCredentialStore interface {
Delete(ctx context.Context, id, userID string) error
}
// =========================================
// CAPABILITY OVERRIDES (v0.22.0)
// =========================================
type CapabilityOverrideStore interface {
// Set creates or updates an override for (provider, model, field).
Set(ctx context.Context, o *models.CapabilityOverride) error
// Delete removes a specific override.
Delete(ctx context.Context, id string) error
// ListForModel returns all overrides for a model ID (across all providers + global).
ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error)
// ListForProviderModel returns overrides for a specific provider+model combination.
ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error)
// ListAll returns every override (admin view).
ListAll(ctx context.Context) ([]models.CapabilityOverride, error)
// DeleteForProvider removes all overrides for a provider (cascade cleanup).
DeleteForProvider(ctx context.Context, providerConfigID string) error
}
// =========================================
// SHARED TYPES
// =========================================

View File

@@ -0,0 +1,85 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// CapOverrideStore implements store.CapabilityOverrideStore for Postgres.
type CapOverrideStore struct {
db *sql.DB
}
func NewCapOverrideStore(db *sql.DB) *CapOverrideStore {
return &CapOverrideStore{db: db}
}
func (s *CapOverrideStore) Set(ctx context.Context, o *models.CapabilityOverride) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO capability_overrides (provider_config_id, model_id, field, value, set_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (provider_config_id, model_id, field) DO UPDATE SET
value = EXCLUDED.value,
set_by = EXCLUDED.set_by,
created_at = now()
`, o.ProviderConfigID, o.ModelID, o.Field, o.Value, o.SetBy)
return err
}
func (s *CapOverrideStore) Delete(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM capability_overrides WHERE id = $1`, id)
return err
}
func (s *CapOverrideStore) ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error) {
return s.query(ctx, `
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
FROM capability_overrides
WHERE model_id = $1
ORDER BY provider_config_id NULLS LAST, field
`, modelID)
}
func (s *CapOverrideStore) ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error) {
return s.query(ctx, `
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
FROM capability_overrides
WHERE model_id = $1 AND (provider_config_id = $2 OR provider_config_id IS NULL)
ORDER BY provider_config_id NULLS LAST, field
`, modelID, providerConfigID)
}
func (s *CapOverrideStore) ListAll(ctx context.Context) ([]models.CapabilityOverride, error) {
return s.query(ctx, `
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
FROM capability_overrides
ORDER BY model_id, provider_config_id NULLS LAST, field
`)
}
func (s *CapOverrideStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
_, err := s.db.ExecContext(ctx, `
DELETE FROM capability_overrides WHERE provider_config_id = $1
`, providerConfigID)
return err
}
func (s *CapOverrideStore) query(ctx context.Context, q string, args ...interface{}) ([]models.CapabilityOverride, error) {
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.CapabilityOverride
for rows.Next() {
var o models.CapabilityOverride
if err := rows.Scan(&o.ID, &o.ProviderConfigID, &o.ModelID, &o.Field, &o.Value, &o.SetBy, &o.CreatedAt); err != nil {
return nil, err
}
result = append(result, o)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,130 @@
package postgres
import (
"context"
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// HealthStore implements health.Store for Postgres.
type HealthStore struct {
db *sql.DB
}
func NewHealthStore(db *sql.DB) *HealthStore {
return &HealthStore{db: db}
}
func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO provider_health (provider_config_id, window_start,
request_count, error_count, timeout_count,
total_latency_ms, max_latency_ms, last_error, last_error_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
ON CONFLICT (provider_config_id, window_start) DO UPDATE SET
request_count = provider_health.request_count + EXCLUDED.request_count,
error_count = provider_health.error_count + EXCLUDED.error_count,
timeout_count = provider_health.timeout_count + EXCLUDED.timeout_count,
total_latency_ms = provider_health.total_latency_ms + EXCLUDED.total_latency_ms,
max_latency_ms = GREATEST(provider_health.max_latency_ms, EXCLUDED.max_latency_ms),
last_error = COALESCE(EXCLUDED.last_error, provider_health.last_error),
last_error_at = COALESCE(EXCLUDED.last_error_at, provider_health.last_error_at),
updated_at = now()
`, w.ProviderConfigID, w.WindowStart,
w.RequestCount, w.ErrorCount, w.TimeoutCount,
w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt,
)
return err
}
func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID string) (*models.ProviderHealthWindow, error) {
windowStart := time.Now().UTC().Truncate(time.Hour)
var w models.ProviderHealthWindow
err := s.db.QueryRowContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE provider_config_id = $1 AND window_start = $2
`, providerConfigID, windowStart).Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return &w, err
}
func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE provider_config_id = $1
ORDER BY window_start DESC
LIMIT $2
`, providerConfigID, hours)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ProviderHealthWindow
for rows.Next() {
var w models.ProviderHealthWindow
if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
}
result = append(result, w)
}
return result, rows.Err()
}
func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) {
windowStart := time.Now().UTC().Truncate(time.Hour)
rows, err := s.db.QueryContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE window_start = $1
ORDER BY provider_config_id
`, windowStart)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ProviderHealthWindow
for rows.Next() {
var w models.ProviderHealthWindow
if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
}
result = append(result, w)
}
return result, rows.Err()
}
func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) {
result, err := s.db.ExecContext(ctx, `
DELETE FROM provider_health WHERE window_start < $1
`, before)
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@@ -37,5 +37,6 @@ func NewStores(db *sql.DB) store.Stores {
NotifPrefs: NewNotificationPreferenceStore(),
Workspaces: NewWorkspaceStore(),
GitCredentials: &GitCredentialStore{},
CapOverrides: NewCapOverrideStore(db),
}
}

View File

@@ -0,0 +1,90 @@
package sqlite
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type CapOverrideStore struct{}
func NewCapOverrideStore() *CapOverrideStore { return &CapOverrideStore{} }
func (s *CapOverrideStore) Set(ctx context.Context, o *models.CapabilityOverride) error {
id := store.NewID()
_, err := DB.ExecContext(ctx, `
INSERT INTO capability_overrides (id, provider_config_id, model_id, field, value, set_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (provider_config_id, model_id, field) DO UPDATE SET
value = excluded.value,
set_by = excluded.set_by,
created_at = datetime('now')
`, id, o.ProviderConfigID, o.ModelID, o.Field, o.Value, o.SetBy)
return err
}
func (s *CapOverrideStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM capability_overrides WHERE id = ?`, id)
return err
}
func (s *CapOverrideStore) ListForModel(ctx context.Context, modelID string) ([]models.CapabilityOverride, error) {
return s.query(ctx, `
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
FROM capability_overrides
WHERE model_id = ?
ORDER BY provider_config_id, field
`, modelID)
}
func (s *CapOverrideStore) ListForProviderModel(ctx context.Context, providerConfigID, modelID string) ([]models.CapabilityOverride, error) {
return s.query(ctx, `
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
FROM capability_overrides
WHERE model_id = ? AND (provider_config_id = ? OR provider_config_id IS NULL)
ORDER BY provider_config_id, field
`, modelID, providerConfigID)
}
func (s *CapOverrideStore) ListAll(ctx context.Context) ([]models.CapabilityOverride, error) {
return s.query(ctx, `
SELECT id, provider_config_id, model_id, field, value, set_by, created_at
FROM capability_overrides
ORDER BY model_id, provider_config_id, field
`)
}
func (s *CapOverrideStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM capability_overrides WHERE provider_config_id = ?
`, providerConfigID)
return err
}
func (s *CapOverrideStore) query(ctx context.Context, q string, args ...interface{}) ([]models.CapabilityOverride, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.CapabilityOverride
for rows.Next() {
var o models.CapabilityOverride
if err := rows.Scan(&o.ID, &o.ProviderConfigID, &o.ModelID, &o.Field, &o.Value, &o.SetBy, &o.CreatedAt); err != nil {
return nil, err
}
result = append(result, o)
}
return result, rows.Err()
}
// ensure interface compliance (compile-time check)
var _ store.CapabilityOverrideStore = (*CapOverrideStore)(nil)
// also check the health store interface at this package level
// (health.Store is in the health package, but we verify via the sql.Rows pattern)
func init() {
// compile-time interface checks happen via the var _ lines above
}

View File

@@ -0,0 +1,125 @@
package sqlite
import (
"context"
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type HealthStore struct{}
func NewHealthStore() *HealthStore { return &HealthStore{} }
func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error {
id := store.NewID()
windowStr := w.WindowStart.Format(timeFmt)
_, err := DB.ExecContext(ctx, `
INSERT INTO provider_health (id, provider_config_id, window_start,
request_count, error_count, timeout_count,
total_latency_ms, max_latency_ms, last_error, last_error_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (provider_config_id, window_start) DO UPDATE SET
request_count = provider_health.request_count + excluded.request_count,
error_count = provider_health.error_count + excluded.error_count,
timeout_count = provider_health.timeout_count + excluded.timeout_count,
total_latency_ms = provider_health.total_latency_ms + excluded.total_latency_ms,
max_latency_ms = MAX(provider_health.max_latency_ms, excluded.max_latency_ms),
last_error = COALESCE(excluded.last_error, provider_health.last_error),
last_error_at = COALESCE(excluded.last_error_at, provider_health.last_error_at),
updated_at = datetime('now')
`, id, w.ProviderConfigID, windowStr,
w.RequestCount, w.ErrorCount, w.TimeoutCount,
w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt,
)
return err
}
func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID string) (*models.ProviderHealthWindow, error) {
windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt)
var w models.ProviderHealthWindow
var windowStartStr string
err := DB.QueryRowContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE provider_config_id = ? AND window_start = ?
`, providerConfigID, windowStr).Scan(
&w.ID, &w.ProviderConfigID, &windowStartStr,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
w.WindowStart, _ = time.Parse(timeFmt, windowStartStr)
return &w, nil
}
func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE provider_config_id = ?
ORDER BY window_start DESC
LIMIT ?
`, providerConfigID, hours)
if err != nil {
return nil, err
}
defer rows.Close()
return scanHealthRows(rows)
}
func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) {
windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt)
rows, err := DB.QueryContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE window_start = ?
ORDER BY provider_config_id
`, windowStr)
if err != nil {
return nil, err
}
defer rows.Close()
return scanHealthRows(rows)
}
func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) {
result, err := DB.ExecContext(ctx, `
DELETE FROM provider_health WHERE window_start < ?
`, before.Format(timeFmt))
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func scanHealthRows(rows *sql.Rows) ([]models.ProviderHealthWindow, error) {
var result []models.ProviderHealthWindow
for rows.Next() {
var w models.ProviderHealthWindow
var windowStartStr string
if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &windowStartStr,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
}
w.WindowStart, _ = time.Parse(timeFmt, windowStartStr)
result = append(result, w)
}
return result, rows.Err()
}

View File

@@ -37,5 +37,6 @@ func NewStores(db *sql.DB) store.Stores {
NotifPrefs: NewNotificationPreferenceStore(),
Workspaces: NewWorkspaceStore(),
GitCredentials: &GitCredentialStore{},
CapOverrides: NewCapOverrideStore(),
}
}

View File

@@ -117,6 +117,40 @@ a:hover { text-decoration: underline; }
.app { display: flex; height: 100vh; height: 100dvh; flex-direction: column; }
.app-body { display: flex; flex: 1; min-height: 0; }
/* ── Workspace (everything right of sidebar) ── */
.workspace {
display: flex; flex: 1; min-width: 0; min-height: 0;
overflow: hidden; position: relative;
}
.workspace-pane { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.workspace-primary { flex: 1; min-width: 0; background: var(--bg); }
.workspace-secondary {
width: 0; min-width: 0; overflow: hidden;
background: var(--bg); border-left: 1px solid var(--border);
transition: width 0.25s ease, min-width 0.25s ease;
}
.workspace-secondary.open {
width: 480px; min-width: 480px;
}
.workspace-secondary.fullscreen {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100% !important; min-width: 100% !important;
z-index: 100; transition: none;
}
.workspace-handle {
width: 0; flex-shrink: 0; position: relative; z-index: 10;
}
.workspace-handle.active {
width: 6px; cursor: col-resize;
}
.workspace-handle.active::after {
content: ''; position: absolute; inset: 0;
transition: background 0.15s;
}
.workspace-handle.active:hover::after { background: var(--accent); opacity: 0.3; }
.workspace-secondary.fullscreen ~ .workspace-handle { display: none; }
/* ── Environment Banners ──────────────────── */
.banner {
@@ -379,7 +413,8 @@ a:hover { text-decoration: underline; }
/* ── Chat Area ───────────────────────────── */
.chat-area { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
/* .chat-area is now .workspace-primary — alias kept for compat */
.chat-area, .workspace-primary { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
.model-bar {
display: flex; align-items: center; gap: 6px;
@@ -651,27 +686,9 @@ a:hover { text-decoration: underline; }
/* HTML preview */
/* ── Side Panel (Preview + Notes) ──────── */
.side-panel {
width: 0; min-width: 0; overflow: hidden;
background: var(--bg); border-left: 1px solid var(--border);
display: flex; flex-direction: column; position: relative;
transition: width 0.25s ease, min-width 0.25s ease;
}
.side-panel.open {
width: 480px; min-width: 480px;
}
.side-panel.fullscreen {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100% !important; min-width: 100% !important;
z-index: 100;
transition: none;
}
.side-panel-resize {
position: absolute; left: -3px; top: 0; bottom: 0; width: 6px;
cursor: col-resize; z-index: 10;
}
.side-panel-resize:hover { background: var(--accent); opacity: 0.3; }
.side-panel.fullscreen .side-panel-resize { display: none; }
/* ── Secondary Pane (was .side-panel, now .workspace-secondary) ── */
/* Layout handled by .workspace-secondary above; these are internal styles */
.side-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid var(--border);
@@ -709,23 +726,23 @@ a:hover { text-decoration: underline; }
flex: 1; width: 100%; border: none; background: #fff;
}
/* Notes inside side panel */
/* Notes inside secondary pane */
.notes-actions-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
.side-panel .notes-list { padding: 4px 8px; }
.side-panel .notes-editor { padding: 8px 12px; }
.side-panel .note-read-content {
.workspace-secondary .notes-list, .side-panel .notes-list { padding: 4px 8px; }
.workspace-secondary .notes-editor, .side-panel .notes-editor { padding: 8px 12px; }
.workspace-secondary .note-read-content, .side-panel .note-read-content {
max-height: none; flex: 1; overflow-y: auto;
}
.side-panel .notes-content-input {
.workspace-secondary .notes-content-input, .side-panel .notes-content-input {
min-height: 150px;
}
.side-panel #notesListView {
.workspace-secondary #notesListView, .side-panel #notesListView {
flex: 1; overflow-y: auto; min-height: 0;
}
.side-panel #notesEditorView {
.workspace-secondary #notesEditorView, .side-panel #notesEditorView {
flex: 1; overflow-y: auto; min-height: 0;
}
@@ -737,17 +754,11 @@ a:hover { text-decoration: underline; }
/* Mobile: full-width overlay */
@media (max-width: 768px) {
.side-panel.open {
.workspace-secondary.open {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100vw; min-width: 100vw; z-index: 200;
}
/* Disable dual-view on mobile — not enough space */
.side-panel-body.dual {
display: flex; flex-direction: column;
grid-template-columns: unset !important;
}
.side-panel-divider { display: none !important; }
.side-panel-dual-btn { display: none; }
.workspace-handle { display: none !important; }
/* Larger touch targets on mobile */
.side-panel-close { font-size: 20px; padding: 4px 10px; }
@@ -755,33 +766,8 @@ a:hover { text-decoration: underline; }
.side-panel-header { padding: 10px 12px; }
}
/* ── Dual-View Mode ───────────────────────── */
.side-panel.dual-open {
width: 680px; min-width: 680px;
}
.side-panel-body.dual {
display: grid;
grid-template-columns: 1fr 6px 1fr;
overflow: hidden;
}
.side-panel-body.dual > .side-panel-page {
overflow-y: auto; min-width: 0; min-height: 0;
}
.side-panel-divider {
grid-column: 2;
width: 6px; cursor: col-resize;
background: var(--border);
transition: background 0.15s;
flex-shrink: 0;
}
.side-panel-divider:hover,
.side-panel-divider:active {
background: var(--accent); opacity: 0.5;
}
.side-panel-dual-btn.active {
background: var(--accent); color: var(--bg);
}
/* Dual-view removed in workspace refactor (v0.22.0) — workspace
handles all pane placement via primary/secondary slots. */
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
.msg-text li { margin: 0.2em 0; }

View File

@@ -138,8 +138,11 @@
</aside>
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Main Chat Area -->
<main class="chat-area">
<!-- ── Workspace (all content panes) ────── -->
<div class="workspace" id="workspace">
<!-- Primary Pane (chat by default, surfaces can swap) -->
<main class="workspace-pane workspace-primary" id="workspacePrimary">
<div class="model-bar" data-surface-region="surface-header">
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
@@ -222,16 +225,15 @@
</div>
</main>
<!-- Side Panel (registry-driven, see panels.js) -->
<aside class="side-panel" id="sidePanel">
<div class="side-panel-resize" id="sidePanelResize"></div>
<!-- Workspace Resize Handle (between primary and secondary) -->
<div class="workspace-handle" id="workspaceHandle"></div>
<!-- Secondary Pane (registry-driven, see panels.js) -->
<aside class="workspace-pane workspace-secondary" id="workspaceSecondary">
<div class="side-panel-header">
<span class="side-panel-label" id="sidePanelLabel"></span>
<div class="side-panel-actions">
<span id="sidePanelPanelActions"></span>
<button class="side-panel-action-btn side-panel-dual-btn" id="sidePanelDualBtn" onclick="PanelRegistry.toggleDual()" title="Toggle split view" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
</button>
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" onclick="toggleSidePanelFullscreen()" title="Toggle fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
@@ -239,8 +241,6 @@
</div>
</div>
<div class="side-panel-body" id="sidePanelBody">
<!-- Dual-view divider (hidden by default, shown in dual mode) -->
<div class="side-panel-divider" id="sidePanelDivider" style="display:none"></div>
<!-- Preview panel -->
<div class="side-panel-page" id="sidePanelPreview" style="display:none">
<div class="side-panel-empty" id="previewEmpty">
@@ -344,6 +344,8 @@
</div>
</div>
</aside>
</div><!-- .workspace -->
<div class="side-panel-overlay" id="sidePanelOverlay"></div>
</div><!-- .app-body -->

View File

@@ -450,10 +450,9 @@ function initListeners() {
_registerProjectPanel(); // from projects-ui.js — register project detail panel
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
_initSidePanelResize(); // from panels.js
_initDualDivider(); // from panels.js — dual-view split drag
_initWorkspaceResize(); // from panels.js — workspace handle drag
_initPanelSwipe(); // from panels.js — mobile swipe navigation
_initPanelResponsive(); // from panels.js — auto-collapse dual on resize
_initPanelResponsive(); // from panels.js — responsive overlay
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
}
@@ -516,19 +515,12 @@ function _initGlobalKeyboard() {
}
// Ctrl/Cmd+\: cycle side panels
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === '\\') {
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
e.preventDefault();
PanelRegistry.cycle();
return;
}
// Ctrl/Cmd+Shift+\: toggle dual-view split
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '\\') {
e.preventDefault();
PanelRegistry.toggleDual();
return;
}
// Ctrl/Cmd+Shift+S: focus sidebar search
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
e.preventDefault();

View File

@@ -143,6 +143,10 @@ const EditorMode = {
regions: ['surface-header', 'surface-main'],
activate: () => this._activate(),
deactivate: () => this._deactivate(),
// Layout: editor is primary, chat in secondary (handled internally)
primary: 'editor',
secondary: 'chat',
secondaryOpts: ['chat'],
});
// Show the Files tab in sidebar and populate it

View File

@@ -1,25 +1,25 @@
// ==========================================
// Chat Switchboard Panel Registry
// Chat Switchboard Panel Registry (v0.22.0)
// ==========================================
// Independent panel system replacing the hardcoded two-tab side panel.
// Workspace-aware panel system. Panels are shown inside the
// workspace secondary pane (.workspace-secondary). The workspace
// owns the resize handle between primary and secondary.
//
// Each panel registers with name, DOM element, and lifecycle hooks.
// The registry owns all open/close/focus logic — individual panels
// never touch the <aside> container directly.
// never touch the secondary container directly.
//
// Supports single-panel and dual-view modes. In dual mode the panel
// body splits into primary (left) + divider + secondary (right) via
// CSS grid. The split ratio is drag-adjustable.
//
// See DESIGN-0.18.1.md for full spec.
// Layout model (v0.22.0):
// .workspace
// .workspace-primary ← main content (chat / notes / editor)
// .workspace-handle ← drag-resize between panes
// .workspace-secondary ← panel pages (preview / notes / project)
// ── Panel Registry ──────────────────────────
const PanelRegistry = {
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
_active: null, // primary (left) panel name
_secondary: null, // secondary (right) panel name — null when not in dual mode
_dualMode: false,
_splitRatio: 0.5, // 01, fraction of space allocated to primary
_active: null, // currently visible panel name
_order: [], // registration order (for tab rendering + cycle)
/**
@@ -27,7 +27,7 @@ const PanelRegistry = {
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
* @param {object} opts
* @param {HTMLElement} opts.element — the .side-panel-page div
* @param {string} opts.label — display name for tab button
* @param {string} opts.label — display name
* @param {Function} [opts.onOpen] — called when panel becomes visible
* @param {Function} [opts.onClose] — called when panel is hidden
* @param {Function} [opts.saveState] — returns state snapshot before hiding
@@ -56,12 +56,8 @@ const PanelRegistry = {
},
/**
* Open a panel (and the container if closed).
*
* Single mode: hides current active, shows requested panel.
* Dual mode:
* - Already visible (active or secondary): swap it to primary.
* - Not visible: replaces secondary panel.
* Open a panel (and the secondary pane if collapsed).
* Hides current active panel, shows the requested one.
*/
open(name) {
const panel = this._panels[name];
@@ -73,39 +69,21 @@ const PanelRegistry = {
const container = this._container();
if (!container) return;
if (this._dualMode) {
// Already primary — no-op
// Already active — just refresh label/actions
if (this._active === name) {
this._renderLabel();
this._renderActions();
return;
}
// Currently secondary — promote to primary, swap
if (this._secondary === name) {
const oldActive = this._active;
this._active = name;
this._secondary = oldActive;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// New panel — replace secondary
if (this._secondary) this._hide(this._secondary);
this._show(name);
this._secondary = name;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// ── Single mode ──
// Hide current active
if (this._active && this._active !== name) {
this._hide(this._active);
}
// Show the secondary pane
container.classList.add('open');
this._showHandle(true);
this._show(name);
this._active = name;
this._showOverlay();
@@ -114,38 +92,12 @@ const PanelRegistry = {
},
/**
* Close a specific panel.
*
* Dual mode: closing either panel exits dual, keeping the other.
* Single mode: closes container.
* Close a specific panel. If it's active, close the secondary pane.
*/
close(name) {
const panel = this._panels[name];
if (!panel) return;
if (this._dualMode) {
if (name === this._secondary) {
this._hide(this._secondary);
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
if (name === this._active) {
this._hide(this._active);
// Promote secondary to primary
this._active = this._secondary;
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
return; // not visible — nothing to close
}
// ── Single mode ──
if (this._active === name) {
this._hide(name);
this._active = null;
@@ -165,89 +117,34 @@ const PanelRegistry = {
},
/**
* Close all panels and the container.
* Close all panels and the secondary pane.
*/
closeAll() {
if (this._secondary) {
this._hide(this._secondary);
this._secondary = null;
}
if (this._active) {
this._hide(this._active);
this._active = null;
}
if (this._dualMode) this._exitDualLayout();
this._closeContainer();
},
/** Is a specific panel currently visible? */
isOpen(name) {
return this._active === name || this._secondary === name;
return this._active === name;
},
/** Is the panel container open at all? */
/** Is the secondary pane open at all? */
isContainerOpen() {
return this._container()?.classList.contains('open') || false;
},
/** Get the primary panel name (or null). */
/** Get the active panel name (or null). */
active() {
return this._active;
},
/** Is dual-view mode active? */
isDual() {
return this._dualMode;
},
/**
* Toggle dual-view mode.
*
* Entering: picks the next registered panel as secondary. If
* container is closed, opens first two panels. Needs ≥ 2 panels.
* Exiting: hides secondary, keeps primary.
*/
toggleDual() {
if (this._dualMode) {
// Exit dual
if (this._secondary) this._hide(this._secondary);
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// Enter dual — need at least 2 panels
if (this._order.length < 2) return;
const container = this._container();
if (!container) return;
// If container not open, open first panel as primary
if (!this._active) {
container.classList.add('open');
this._show(this._order[0]);
this._active = this._order[0];
}
// Pick secondary: first registered panel that isn't active
const secondary = this._order.find(n => n !== this._active);
if (!secondary) return;
this._show(secondary);
this._secondary = secondary;
this._dualMode = true;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
},
/**
* Cycle to the next registered panel. If container is closed, open
* the first panel. Wraps around.
*
* In dual mode, cycles the secondary panel through non-primary panels.
*/
cycle() {
if (this._order.length === 0) return;
@@ -257,23 +154,6 @@ const PanelRegistry = {
return;
}
if (this._dualMode && this._secondary) {
// Cycle secondary through panels that aren't the primary
const others = this._order.filter(n => n !== this._active);
if (others.length < 2) return; // only one option, already showing
const idx = others.indexOf(this._secondary);
const next = others[(idx + 1) % others.length];
if (next === this._secondary) return;
this._hide(this._secondary);
this._show(next);
this._secondary = next;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// Single mode: cycle active
const idx = this._order.indexOf(this._active);
const next = this._order[(idx + 1) % this._order.length];
this.open(next);
@@ -281,8 +161,9 @@ const PanelRegistry = {
// ── Internal ────────────────────────────
/** The secondary pane element. */
_container() {
return document.getElementById('sidePanel');
return document.getElementById('workspaceSecondary');
},
_body() {
@@ -304,7 +185,7 @@ const PanelRegistry = {
if (panel.onOpen) panel.onOpen();
},
/** Hide a panel: save state, call onClose, set display:none, clear grid placement. */
/** Hide a panel: save state, call onClose, set display:none. */
_hide(name) {
const panel = this._panels[name];
if (!panel) return;
@@ -316,68 +197,23 @@ const PanelRegistry = {
if (panel.onClose) panel.onClose();
panel.element.style.display = 'none';
panel.element.style.gridColumn = '';
},
/** Close the <aside> container. */
/** Close the secondary pane. */
_closeContainer() {
const container = this._container();
if (!container) return;
container.classList.remove('open', 'fullscreen', 'dual-open');
container.classList.remove('open', 'fullscreen');
container.style.width = '';
container.style.minWidth = '';
this._showHandle(false);
this._hideOverlay();
},
// ── Dual layout ─────────────────────────
/** Apply CSS grid layout for dual-view. */
_applyDualLayout() {
this._dualMode = true;
const body = this._body();
const container = this._container();
const divider = document.getElementById('sidePanelDivider');
if (!body || !container) return;
body.classList.add('dual');
container.classList.add('dual-open');
if (divider) divider.style.display = '';
// Assign grid columns: primary=1, divider=2, secondary=3
const primary = this._panels[this._active];
const secondary = this._panels[this._secondary];
if (primary) primary.element.style.gridColumn = '1';
if (secondary) secondary.element.style.gridColumn = '3';
this._applySplitRatio();
},
/** Remove dual layout, return to single-panel flexbox. */
_exitDualLayout() {
this._dualMode = false;
const body = this._body();
const container = this._container();
const divider = document.getElementById('sidePanelDivider');
if (body) {
body.classList.remove('dual');
body.style.gridTemplateColumns = '';
}
if (container) container.classList.remove('dual-open');
if (divider) divider.style.display = 'none';
// Clear grid-column on all panels
for (const name of this._order) {
const p = this._panels[name];
if (p) p.element.style.gridColumn = '';
}
},
/** Set grid-template-columns based on current _splitRatio. */
_applySplitRatio() {
const body = this._body();
if (!body || !this._dualMode) return;
const r = this._splitRatio;
body.style.gridTemplateColumns = `${r}fr 6px ${1 - r}fr`;
/** Show/hide the workspace resize handle. */
_showHandle(show) {
const handle = document.getElementById('workspaceHandle');
if (handle) handle.classList.toggle('active', show);
},
// ── Mobile overlay ──────────────────────
@@ -386,7 +222,7 @@ const PanelRegistry = {
return window.innerWidth <= 768;
},
/** Show overlay behind panel on mobile (tap-to-close). */
/** Show overlay behind secondary on mobile (tap-to-close). */
_showOverlay() {
const ov = document.getElementById('sidePanelOverlay');
if (ov && this._isMobile()) ov.style.display = 'block';
@@ -398,28 +234,22 @@ const PanelRegistry = {
if (ov) ov.style.display = 'none';
},
// ── Tab and action rendering ────────────
// ── Label and action rendering ──────────
/** Update the header label to show the active panel name. */
_renderLabel() {
const el = document.getElementById('sidePanelLabel');
if (!el) return;
if (this._active) {
const p = this._panels[this._active];
el.textContent = p ? p.label : '';
} else {
el.textContent = '';
}
// Update dual-view toggle button visibility
const dualBtn = document.getElementById('sidePanelDualBtn');
if (dualBtn) {
dualBtn.style.display = this._order.length >= 2 ? '' : 'none';
dualBtn.classList.toggle('active', this._dualMode);
}
},
/** Render per-panel action buttons for the primary (active) panel. */
/** Render per-panel action buttons for the active panel. */
_renderActions() {
const slot = document.getElementById('sidePanelPanelActions');
if (!slot) return;
@@ -448,10 +278,10 @@ function _escPanel(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Side Panel Container (global operations) ──
// ── Secondary Pane Fullscreen ──────────────
function toggleSidePanelFullscreen() {
const panel = document.getElementById('sidePanel');
const panel = document.getElementById('workspaceSecondary');
if (!panel) return;
panel.classList.toggle('fullscreen');
const btn = document.getElementById('sidePanelFullscreenBtn');
@@ -464,77 +294,49 @@ function toggleSidePanelFullscreen() {
}
}
// ── Side Panel Resize (outer edge) ──────────
// ── Workspace Resize Handle ────────────────
function _initSidePanelResize() {
function _initWorkspaceResize() {
let startX, startW;
const panel = document.getElementById('sidePanel');
const handle = document.getElementById('sidePanelResize');
if (!handle || !panel) return;
const secondary = document.getElementById('workspaceSecondary');
const handle = document.getElementById('workspaceHandle');
if (!handle || !secondary) return;
handle.addEventListener('mousedown', (e) => {
if (panel.classList.contains('fullscreen')) return;
const onDown = (e) => {
if (!handle.classList.contains('active')) return;
if (secondary.classList.contains('fullscreen')) return;
e.preventDefault();
startX = e.clientX;
startW = panel.getBoundingClientRect().width;
panel.style.transition = 'none';
const clientX = e.clientX ?? e.touches?.[0]?.clientX;
startX = clientX;
startW = secondary.getBoundingClientRect().width;
secondary.style.transition = 'none';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (e) => {
const delta = startX - e.clientX;
const minW = PanelRegistry.isDual() ? 480 : 280;
const newW = Math.max(minW, Math.min(window.innerWidth * 0.7, startW + delta));
panel.style.width = newW + 'px';
panel.style.minWidth = newW + 'px';
const cx = e.clientX ?? e.touches?.[0]?.clientX ?? startX;
const delta = startX - cx;
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta));
secondary.style.width = newW + 'px';
secondary.style.minWidth = newW + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
panel.style.transition = '';
secondary.style.transition = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ── Dual-View Divider Resize ────────────────
function _initDualDivider() {
const divider = document.getElementById('sidePanelDivider');
if (!divider) return;
divider.addEventListener('mousedown', (e) => {
if (!PanelRegistry.isDual()) return;
e.preventDefault();
const body = document.getElementById('sidePanelBody');
if (!body) return;
const bodyRect = body.getBoundingClientRect();
body.style.cursor = 'col-resize';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (e) => {
const x = e.clientX - bodyRect.left;
const total = bodyRect.width - 6; // subtract divider width
const ratio = Math.max(0.2, Math.min(0.8, x / (total + 6)));
PanelRegistry._splitRatio = ratio;
PanelRegistry._applySplitRatio();
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onUp);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
body.style.cursor = '';
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
handle.addEventListener('mousedown', onDown);
handle.addEventListener('touchstart', onDown, { passive: false });
}
// ── Mobile Swipe Navigation ─────────────────
@@ -550,7 +352,6 @@ function _initPanelSwipe() {
body.addEventListener('touchstart', (e) => {
if (!PanelRegistry.isContainerOpen()) return;
if (PanelRegistry._order.length < 2) return;
// Only track single-finger swipes
if (e.touches.length !== 1) return;
startX = e.touches[0].clientX;
@@ -572,10 +373,8 @@ function _initPanelSwipe() {
if (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0) {
// Swipe right → previous panel
_cyclePanelDirection(-1);
} else {
// Swipe left → next panel
_cyclePanelDirection(1);
}
}, { passive: true });
@@ -595,7 +394,6 @@ function _cyclePanelDirection(direction) {
}
// ── Responsive Resize Handler ───────────────
// Auto-exit dual mode when viewport shrinks below threshold.
function _initPanelResponsive() {
let wasWide = window.innerWidth > 768;
@@ -603,11 +401,6 @@ function _initPanelResponsive() {
window.addEventListener('resize', () => {
const isWide = window.innerWidth > 768;
// Viewport shrank to mobile while dual was active → collapse
if (wasWide && !isWide && PanelRegistry.isDual()) {
PanelRegistry.toggleDual(); // exits dual, keeps primary
}
// Update overlay visibility: show on mobile if panel open, hide on desktop
if (PanelRegistry.isContainerOpen()) {
const ov = document.getElementById('sidePanelOverlay');
@@ -631,7 +424,7 @@ function _initPanelOverlay() {
});
}
// ── Legacy Compat (thin wrappers, remove after full migration) ──
// ── Legacy Compat (thin wrappers) ───────────
function openSidePanel(tab) {
PanelRegistry.open(tab);

View File

@@ -41,6 +41,10 @@ const Surfaces = {
activate: null, // chat activation is implicit (restore regions)
deactivate: null,
_isDefault: true,
// Layout declarations (v0.22.0)
primary: 'chat',
secondary: 'preview',
secondaryOpts: ['preview', 'notes', 'project'],
});
console.log(`[Surfaces] Initialized with ${this._regionEls.size} region(s)`);
@@ -51,7 +55,13 @@ const Surfaces = {
/**
* Register a new surface (mode).
* @param {string} id — unique surface identifier
* @param {object} opts — { label, icon, regions[], activate(), deactivate() }
* @param {object} opts — { label, icon, regions[], activate(), deactivate(),
* primary?, secondary?, secondaryOpts? }
*
* Layout declarations (v0.22.0):
* primary — pane id that owns the workspace-primary slot ('chat', 'notes', 'editor')
* secondary — default pane id for workspace-secondary (null = hidden)
* secondaryOpts — array of pane ids allowed in secondary for this surface
*/
register(id, opts = {}) {
if (this._registry.has(id)) {
@@ -65,6 +75,9 @@ const Surfaces = {
regions: opts.regions || [],
activate: opts.activate || null,
deactivate: opts.deactivate || null,
primary: opts.primary || id,
secondary: opts.secondary || null,
secondaryOpts: opts.secondaryOpts || [],
});
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
@@ -167,6 +180,20 @@ const Surfaces = {
return this._registry.size > 1;
},
/**
* Get the layout declarations for the current surface.
* Returns { primary, secondary, secondaryOpts } or defaults.
*/
getLayout(id) {
const def = this._registry.get(id || this._current);
if (!def) return { primary: 'chat', secondary: null, secondaryOpts: [] };
return {
primary: def.primary || id || 'chat',
secondary: def.secondary || null,
secondaryOpts: def.secondaryOpts || [],
};
},
/**
* Get a saved DocumentFragment for a specific surface + region.
* Used by surfaces like editor-mode that want to embed chat DOM

View File

@@ -118,7 +118,7 @@ Object.assign(UI, {
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas + modals + panels (but NOT .app or banners)
document.querySelectorAll('.sidebar, .chat-area, .modal-overlay, .side-panel, .admin-panel').forEach(el => el.style.zoom = z);
document.querySelectorAll('.sidebar, .workspace-primary, .workspace-secondary, .modal-overlay, .admin-panel').forEach(el => el.style.zoom = z);
const splash = document.getElementById('splashGate');
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');