Changeset 0.22.0.1 (#94)
This commit is contained in:
32
CHANGELOG.md
32
CHANGELOG.md
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
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 (5–25%), 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
|
## [0.21.6] — 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Three Docker images support different deployment scenarios:
|
|||||||
|
|
||||||
4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope.
|
4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope.
|
||||||
|
|
||||||
5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (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.
|
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
|
│ └── ... # Mirror of postgres/ with dialect adaptations
|
||||||
├── models/models.go # Shared domain types
|
├── models/models.go # Shared domain types
|
||||||
├── capabilities/
|
├── capabilities/
|
||||||
│ ├── intrinsic.go # Heuristic detection + resolution
|
│ ├── intrinsic.go # Heuristic detection + three-tier resolution (catalog → heuristic → admin override)
|
||||||
│ └── resolver.go # ModelsForUser() unified resolver
|
│ └── resolver.go # ModelsForUser() unified resolver
|
||||||
├── compaction/ # Conversation summarization engine
|
├── compaction/ # Conversation summarization engine
|
||||||
├── crypto/ # AES-256-GCM API key encryption
|
├── crypto/ # AES-256-GCM API key encryption
|
||||||
├── events/ # EventBus + WebSocket hub
|
├── events/ # EventBus + WebSocket hub
|
||||||
├── extraction/ # Document text extraction pipeline
|
├── 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
|
├── memory/ # Long-term memory extraction + scanning
|
||||||
│ ├── extractor.go # Conversation analysis → fact extraction
|
│ ├── extractor.go # Conversation analysis → fact extraction
|
||||||
│ └── scanner.go # Background job: find channels needing extraction
|
│ └── scanner.go # Background job: find channels needing extraction
|
||||||
|
|||||||
173
docs/ROADMAP.md
173
docs/ROADMAP.md
@@ -88,11 +88,20 @@ v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
|
|||||||
v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅
|
v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅
|
||||||
Indexing + Search (Development Mode)
|
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
|
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
|
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.0–v0.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
|
- [ ] 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
|
- [ ] Workspace settings UI: git config section in channel/project settings
|
||||||
- [ ] User Settings: git credentials management UI
|
- [ ] User Settings: git credentials management UI
|
||||||
- [ ] `.gitignore` respect in workspace indexing
|
- [ ] `.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
|
- [ ] PDF/DOCX export via pandoc in article mode
|
||||||
- [ ] Project-specific file uploads (full project-level upload architecture)
|
- [ ] 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
|
## v0.23.0 — Multi-Participant Channels + Presence
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func TestResolveIntrinsic_CatalogWins(t *testing.T) {
|
|||||||
ToolCalling: true,
|
ToolCalling: true,
|
||||||
MaxOutputTokens: 16384,
|
MaxOutputTokens: 16384,
|
||||||
}
|
}
|
||||||
merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps)
|
merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps, nil)
|
||||||
|
|
||||||
if !merged.ToolCalling {
|
if !merged.ToolCalling {
|
||||||
t.Error("tool_calling should be preserved from provider")
|
t.Error("tool_calling should be preserved from provider")
|
||||||
@@ -179,7 +179,7 @@ func TestResolveIntrinsic_ProviderDataPreserved(t *testing.T) {
|
|||||||
MaxOutputTokens: 8192,
|
MaxOutputTokens: 8192,
|
||||||
MaxContext: 65536,
|
MaxContext: 65536,
|
||||||
}
|
}
|
||||||
merged := ResolveIntrinsic("some-unknown-model", &providerCaps)
|
merged := ResolveIntrinsic("some-unknown-model", &providerCaps, nil)
|
||||||
|
|
||||||
if !merged.ToolCalling {
|
if !merged.ToolCalling {
|
||||||
t.Error("tool_calling should be preserved from provider")
|
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) {
|
func TestResolveIntrinsic_NilProvider(t *testing.T) {
|
||||||
// Nil provider caps — falls through entirely to heuristic
|
// Nil provider caps — falls through entirely to heuristic
|
||||||
merged := ResolveIntrinsic("gpt-4o", nil)
|
merged := ResolveIntrinsic("gpt-4o", nil, nil)
|
||||||
|
|
||||||
if !merged.ToolCalling {
|
if !merged.ToolCalling {
|
||||||
t.Error("should get tool_calling from heuristic (gpt-4 pattern)")
|
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) {
|
func TestResolveIntrinsic_HeuristicOnly(t *testing.T) {
|
||||||
// No catalog, no known table — pure heuristic
|
// 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 {
|
if !merged.Reasoning {
|
||||||
t.Error("should infer reasoning from deepseek-r1 pattern")
|
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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,14 +111,16 @@ func InferCapabilities(modelID string) models.ModelCapabilities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResolveIntrinsic determines the intrinsic capabilities of a model.
|
// ResolveIntrinsic determines the intrinsic capabilities of a model.
|
||||||
// Priority:
|
// Priority (highest wins):
|
||||||
// 1. catalogCaps (from model_catalog DB — synced from provider API)
|
// 1. Admin overrides (manual corrections from capability_overrides table)
|
||||||
// 2. Heuristic inference (regex patterns on model ID)
|
// 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
|
// 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.
|
// 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
|
// Start with catalog data if available
|
||||||
var base models.ModelCapabilities
|
var base models.ModelCapabilities
|
||||||
if catalogCaps != nil && catalogCaps.HasProviderData() {
|
if catalogCaps != nil && catalogCaps.HasProviderData() {
|
||||||
@@ -128,9 +130,56 @@ func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) mod
|
|||||||
// Fill gaps from heuristic inference
|
// Fill gaps from heuristic inference
|
||||||
inferred := InferCapabilities(modelID)
|
inferred := InferCapabilities(modelID)
|
||||||
mergeGaps(&base, &inferred)
|
mergeGaps(&base, &inferred)
|
||||||
|
|
||||||
|
// Apply admin overrides (highest priority — can flip any field)
|
||||||
|
applyOverrides(&base, overrides)
|
||||||
|
|
||||||
return base
|
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.
|
// ResolveMaxOutput returns the max output tokens for a model.
|
||||||
// Priority: explicit caps → derive from context → 4096 fallback.
|
// Priority: explicit caps → derive from context → 4096 fallback.
|
||||||
func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {
|
func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
|||||||
|
|
||||||
countGlobal := len(globalModels)
|
countGlobal := len(globalModels)
|
||||||
for _, entry := range globalModels {
|
for _, entry := range globalModels {
|
||||||
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
|
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
|
||||||
prov := providerMap[entry.ProviderConfigID]
|
prov := providerMap[entry.ProviderConfigID]
|
||||||
|
|
||||||
result = append(result, models.UserModel{
|
result = append(result, models.UserModel{
|
||||||
@@ -100,7 +100,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
|
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
|
||||||
result = append(result, models.UserModel{
|
result = append(result, models.UserModel{
|
||||||
ID: entry.ModelID,
|
ID: entry.ModelID,
|
||||||
DisplayName: displayName(entry.DisplayName, entry.ModelID),
|
DisplayName: displayName(entry.DisplayName, entry.ModelID),
|
||||||
@@ -138,7 +138,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
|
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
|
||||||
result = append(result, models.UserModel{
|
result = append(result, models.UserModel{
|
||||||
ID: entry.ModelID,
|
ID: entry.ModelID,
|
||||||
DisplayName: displayName(entry.DisplayName, 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
|
catalogCaps = &entry.Capabilities
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps)
|
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps, nil)
|
||||||
|
|
||||||
// Load tool grants
|
// Load tool grants
|
||||||
toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID)
|
toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID)
|
||||||
@@ -251,7 +251,7 @@ func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models
|
|||||||
catalogCaps = &entry.Capabilities
|
catalogCaps = &entry.Capabilities
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps)
|
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps, nil)
|
||||||
|
|
||||||
toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID)
|
toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
43
server/database/migrations/013_v0220_health.sql
Normal file
43
server/database/migrations/013_v0220_health.sql
Normal 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);
|
||||||
35
server/database/migrations/sqlite/012_v0220_health.sql
Normal file
35
server/database/migrations/sqlite/012_v0220_health.sql
Normal 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);
|
||||||
@@ -94,7 +94,7 @@ func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge with known data to fill gaps
|
// Merge with known data to fill gaps
|
||||||
resolved := capspkg.ResolveIntrinsic(modelID, &caps)
|
resolved := capspkg.ResolveIntrinsic(modelID, &caps, nil)
|
||||||
return resolved, true
|
return resolved, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ func liveQueryModelCaps(c *gin.Context, configID, modelID string) (models.ModelC
|
|||||||
|
|
||||||
for _, m := range modelList {
|
for _, m := range modelList {
|
||||||
if m.ID == modelID {
|
if m.ID == modelID {
|
||||||
resolved := capspkg.ResolveIntrinsic(modelID, &m.Capabilities)
|
resolved := capspkg.ResolveIntrinsic(modelID, &m.Capabilities, nil)
|
||||||
return resolved, true
|
return resolved, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -50,6 +51,15 @@ type CompletionHandler struct {
|
|||||||
hub *events.Hub // WebSocket hub for browser tool bridge
|
hub *events.Hub // WebSocket hub for browser tool bridge
|
||||||
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
|
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
|
||||||
embedder *knowledge.Embedder // for memory semantic recall (v0.18.0)
|
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.
|
// 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}
|
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 ─────────────────────────
|
// ── Chat Completion ─────────────────────────
|
||||||
// POST /api/v1/chat/completions
|
// POST /api/v1/chat/completions
|
||||||
//
|
//
|
||||||
@@ -371,7 +386,7 @@ func (h *CompletionHandler) multiModelStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stream this model's response using the shared streaming core
|
// 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
|
// Persist assistant message with model attribution
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
@@ -536,7 +551,7 @@ func (h *CompletionHandler) streamCompletion(
|
|||||||
req providers.CompletionRequest,
|
req providers.CompletionRequest,
|
||||||
channelID, userID, model, configID, providerScope, personaID, workspaceID string,
|
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
|
// Persist assistant response
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
@@ -566,7 +581,9 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
var allToolActivity []map[string]interface{}
|
var allToolActivity []map[string]interface{}
|
||||||
|
|
||||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||||
|
callStart := time.Now()
|
||||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||||
|
h.recordHealth(configID, callStart, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -1175,3 +1192,21 @@ func calcCost(tokens int, pricePerM *float64) *float64 {
|
|||||||
cost := float64(tokens) * *pricePerM / 1_000_000.0
|
cost := float64(tokens) * *pricePerM / 1_000_000.0
|
||||||
return &cost
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
280
server/handlers/health_admin.go
Normal file
280
server/handlers/health_admin.go
Normal 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
|
||||||
|
}
|
||||||
@@ -516,7 +516,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
// ── 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
|
// Persist as sibling (regen) with tool activity
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ type streamResult struct {
|
|||||||
CacheReadTokens int
|
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.
|
// streamWithToolLoop is the single, canonical streaming implementation.
|
||||||
// It handles SSE setup, multi-round tool execution, reasoning_content
|
// It handles SSE setup, multi-round tool execution, reasoning_content
|
||||||
// forwarding, and client notifications. Returns the accumulated content
|
// forwarding, and client notifications. Returns the accumulated content
|
||||||
@@ -43,8 +57,9 @@ func streamWithToolLoop(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req *providers.CompletionRequest,
|
req *providers.CompletionRequest,
|
||||||
model, userID, channelID, personaID, workspaceID string,
|
model, userID, channelID, personaID, workspaceID, configID string,
|
||||||
hub *events.Hub,
|
hub *events.Hub,
|
||||||
|
health HealthRecorder,
|
||||||
) streamResult {
|
) streamResult {
|
||||||
// Set SSE headers
|
// Set SSE headers
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
@@ -74,8 +89,10 @@ func streamWithToolLoop(
|
|||||||
var result streamResult
|
var result streamResult
|
||||||
|
|
||||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||||
|
callStart := time.Now()
|
||||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
|
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
recordHealthFn(health, configID, callStart, err)
|
||||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -86,6 +103,7 @@ func streamWithToolLoop(
|
|||||||
|
|
||||||
for event := range ch {
|
for event := range ch {
|
||||||
if event.Error != nil {
|
if event.Error != nil {
|
||||||
|
recordHealthFn(health, configID, callStart, event.Error)
|
||||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -107,8 +125,9 @@ func streamWithToolLoop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if event.Done {
|
if event.Done {
|
||||||
|
recordHealthFn(health, configID, callStart, nil)
|
||||||
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
||||||
toolCalls = event.ToolCalls
|
toolCalls = event.ToolCalls
|
||||||
// Accumulate tokens from this tool-call iteration
|
// Accumulate tokens from this tool-call iteration
|
||||||
result.InputTokens += event.InputTokens
|
result.InputTokens += event.InputTokens
|
||||||
result.OutputTokens += event.OutputTokens
|
result.OutputTokens += event.OutputTokens
|
||||||
@@ -268,8 +287,9 @@ func streamModelResponse(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req *providers.CompletionRequest,
|
req *providers.CompletionRequest,
|
||||||
model, displayName, userID, channelID, personaID, workspaceID string,
|
model, displayName, userID, channelID, personaID, workspaceID, configID string,
|
||||||
hub *events.Hub,
|
hub *events.Hub,
|
||||||
|
health HealthRecorder,
|
||||||
) streamResult {
|
) streamResult {
|
||||||
flusher, _ := c.Writer.(http.Flusher)
|
flusher, _ := c.Writer.(http.Flusher)
|
||||||
flush := func() {
|
flush := func() {
|
||||||
@@ -289,8 +309,10 @@ func streamModelResponse(
|
|||||||
var result streamResult
|
var result streamResult
|
||||||
|
|
||||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||||
|
callStart := time.Now()
|
||||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
|
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
recordHealthFn(health, configID, callStart, err)
|
||||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -301,6 +323,7 @@ func streamModelResponse(
|
|||||||
|
|
||||||
for event := range ch {
|
for event := range ch {
|
||||||
if event.Error != nil {
|
if event.Error != nil {
|
||||||
|
recordHealthFn(health, configID, callStart, event.Error)
|
||||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -320,6 +343,7 @@ func streamModelResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if event.Done {
|
if event.Done {
|
||||||
|
recordHealthFn(health, configID, callStart, nil)
|
||||||
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
||||||
toolCalls = event.ToolCalls
|
toolCalls = event.ToolCalls
|
||||||
result.InputTokens += event.InputTokens
|
result.InputTokens += event.InputTokens
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
|||||||
|
|
||||||
out := make([]modelInfo, 0, len(modelList))
|
out := make([]modelInfo, 0, len(modelList))
|
||||||
for _, m := range 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)
|
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
|
||||||
out = append(out, modelInfo{ID: m.ID, Capabilities: caps})
|
out = append(out, modelInfo{ID: m.ID, Capabilities: caps})
|
||||||
}
|
}
|
||||||
|
|||||||
219
server/health/accumulator.go
Normal file
219
server/health/accumulator.go
Normal 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)
|
||||||
|
}
|
||||||
239
server/health/accumulator_test.go
Normal file
239
server/health/accumulator_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/extraction"
|
"git.gobha.me/xcaliber/chat-switchboard/extraction"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
"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/knowledge"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/memory"
|
"git.gobha.me/xcaliber/chat-switchboard/memory"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
@@ -54,6 +55,8 @@ func main() {
|
|||||||
uekCache := crypto.NewUEKCache()
|
uekCache := crypto.NewUEKCache()
|
||||||
var keyResolver *crypto.KeyResolver
|
var keyResolver *crypto.KeyResolver
|
||||||
var objStore storage.ObjectStore
|
var objStore storage.ObjectStore
|
||||||
|
var healthAccum *health.Accumulator
|
||||||
|
var healthStore *postgres.HealthStore
|
||||||
|
|
||||||
if err := database.Connect(cfg); err != nil {
|
if err := database.Connect(cfg); err != nil {
|
||||||
log.Printf("⚠ Database unavailable: %v", err)
|
log.Printf("⚠ Database unavailable: %v", err)
|
||||||
@@ -89,6 +92,26 @@ func main() {
|
|||||||
// Initialize store layer
|
// Initialize store layer
|
||||||
stores = postgres.NewStores(database.DB)
|
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
|
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||||
handlers.BootstrapAdmin(cfg, stores)
|
handlers.BootstrapAdmin(cfg, stores)
|
||||||
|
|
||||||
@@ -350,6 +373,9 @@ func main() {
|
|||||||
|
|
||||||
// Chat Completions
|
// Chat Completions
|
||||||
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
|
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
|
||||||
|
if healthAccum != nil {
|
||||||
|
comp.SetHealthRecorder(healthAccum)
|
||||||
|
}
|
||||||
protected.POST("/chat/completions", comp.Complete)
|
protected.POST("/chat/completions", comp.Complete)
|
||||||
protected.GET("/tools", comp.ListTools)
|
protected.GET("/tools", comp.ListTools)
|
||||||
|
|
||||||
@@ -722,6 +748,18 @@ func main() {
|
|||||||
admin.POST("/extensions", extAdm.AdminInstallExtension)
|
admin.POST("/extensions", extAdm.AdminInstallExtension)
|
||||||
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
|
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
|
||||||
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
|
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(" Storage: disabled")
|
||||||
}
|
}
|
||||||
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
|
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 {
|
if err := r.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -864,3 +864,76 @@ type ChannelKB struct {
|
|||||||
Enabled bool `json:"enabled" db:"enabled"`
|
Enabled bool `json:"enabled" db:"enabled"`
|
||||||
DocumentCount int `json:"document_count" db:"document_count"`
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type Stores struct {
|
|||||||
NotifPrefs NotificationPreferenceStore
|
NotifPrefs NotificationPreferenceStore
|
||||||
Workspaces WorkspaceStore
|
Workspaces WorkspaceStore
|
||||||
GitCredentials GitCredentialStore
|
GitCredentials GitCredentialStore
|
||||||
|
CapOverrides CapabilityOverrideStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -562,6 +563,30 @@ type GitCredentialStore interface {
|
|||||||
Delete(ctx context.Context, id, userID string) error
|
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
|
// SHARED TYPES
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
85
server/store/postgres/cap_override.go
Normal file
85
server/store/postgres/cap_override.go
Normal 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()
|
||||||
|
}
|
||||||
130
server/store/postgres/health.go
Normal file
130
server/store/postgres/health.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -37,5 +37,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
NotifPrefs: NewNotificationPreferenceStore(),
|
NotifPrefs: NewNotificationPreferenceStore(),
|
||||||
Workspaces: NewWorkspaceStore(),
|
Workspaces: NewWorkspaceStore(),
|
||||||
GitCredentials: &GitCredentialStore{},
|
GitCredentials: &GitCredentialStore{},
|
||||||
|
CapOverrides: NewCapOverrideStore(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
server/store/sqlite/cap_override.go
Normal file
90
server/store/sqlite/cap_override.go
Normal 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
|
||||||
|
}
|
||||||
125
server/store/sqlite/health.go
Normal file
125
server/store/sqlite/health.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -37,5 +37,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
NotifPrefs: NewNotificationPreferenceStore(),
|
NotifPrefs: NewNotificationPreferenceStore(),
|
||||||
Workspaces: NewWorkspaceStore(),
|
Workspaces: NewWorkspaceStore(),
|
||||||
GitCredentials: &GitCredentialStore{},
|
GitCredentials: &GitCredentialStore{},
|
||||||
|
CapOverrides: NewCapOverrideStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,40 @@ a:hover { text-decoration: underline; }
|
|||||||
.app { display: flex; height: 100vh; height: 100dvh; flex-direction: column; }
|
.app { display: flex; height: 100vh; height: 100dvh; flex-direction: column; }
|
||||||
.app-body { display: flex; flex: 1; min-height: 0; }
|
.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 ──────────────────── */
|
/* ── Environment Banners ──────────────────── */
|
||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
@@ -379,7 +413,8 @@ a:hover { text-decoration: underline; }
|
|||||||
|
|
||||||
/* ── Chat Area ───────────────────────────── */
|
/* ── 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 {
|
.model-bar {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
@@ -651,27 +686,9 @@ a:hover { text-decoration: underline; }
|
|||||||
/* HTML preview */
|
/* HTML preview */
|
||||||
/* ── Side Panel (Preview + Notes) ──────── */
|
/* ── Side Panel (Preview + Notes) ──────── */
|
||||||
|
|
||||||
.side-panel {
|
/* ── Secondary Pane (was .side-panel, now .workspace-secondary) ── */
|
||||||
width: 0; min-width: 0; overflow: hidden;
|
/* Layout handled by .workspace-secondary above; these are internal styles */
|
||||||
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; }
|
|
||||||
.side-panel-header {
|
.side-panel-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
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;
|
flex: 1; width: 100%; border: none; background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notes inside side panel */
|
/* Notes inside secondary pane */
|
||||||
.notes-actions-bar {
|
.notes-actions-bar {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.side-panel .notes-list { padding: 4px 8px; }
|
.workspace-secondary .notes-list, .side-panel .notes-list { padding: 4px 8px; }
|
||||||
.side-panel .notes-editor { padding: 8px 12px; }
|
.workspace-secondary .notes-editor, .side-panel .notes-editor { padding: 8px 12px; }
|
||||||
.side-panel .note-read-content {
|
.workspace-secondary .note-read-content, .side-panel .note-read-content {
|
||||||
max-height: none; flex: 1; overflow-y: auto;
|
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;
|
min-height: 150px;
|
||||||
}
|
}
|
||||||
.side-panel #notesListView {
|
.workspace-secondary #notesListView, .side-panel #notesListView {
|
||||||
flex: 1; overflow-y: auto; min-height: 0;
|
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;
|
flex: 1; overflow-y: auto; min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,17 +754,11 @@ a:hover { text-decoration: underline; }
|
|||||||
|
|
||||||
/* Mobile: full-width overlay */
|
/* Mobile: full-width overlay */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.side-panel.open {
|
.workspace-secondary.open {
|
||||||
position: fixed; top: 0; right: 0; bottom: 0;
|
position: fixed; top: 0; right: 0; bottom: 0;
|
||||||
width: 100vw; min-width: 100vw; z-index: 200;
|
width: 100vw; min-width: 100vw; z-index: 200;
|
||||||
}
|
}
|
||||||
/* Disable dual-view on mobile — not enough space */
|
.workspace-handle { display: none !important; }
|
||||||
.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; }
|
|
||||||
|
|
||||||
/* Larger touch targets on mobile */
|
/* Larger touch targets on mobile */
|
||||||
.side-panel-close { font-size: 20px; padding: 4px 10px; }
|
.side-panel-close { font-size: 20px; padding: 4px 10px; }
|
||||||
@@ -755,33 +766,8 @@ a:hover { text-decoration: underline; }
|
|||||||
.side-panel-header { padding: 10px 12px; }
|
.side-panel-header { padding: 10px 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dual-View Mode ───────────────────────── */
|
/* Dual-view removed in workspace refactor (v0.22.0) — workspace
|
||||||
.side-panel.dual-open {
|
handles all pane placement via primary/secondary slots. */
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
|
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
|
||||||
.msg-text li { margin: 0.2em 0; }
|
.msg-text li { margin: 0.2em 0; }
|
||||||
|
|||||||
@@ -138,8 +138,11 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||||
|
|
||||||
<!-- Main Chat Area -->
|
<!-- ── Workspace (all content panes) ────── -->
|
||||||
<main class="chat-area">
|
<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">
|
<div class="model-bar" data-surface-region="surface-header">
|
||||||
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
|
<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>
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Side Panel (registry-driven, see panels.js) -->
|
<!-- Workspace Resize Handle (between primary and secondary) -->
|
||||||
<aside class="side-panel" id="sidePanel">
|
<div class="workspace-handle" id="workspaceHandle"></div>
|
||||||
<div class="side-panel-resize" id="sidePanelResize"></div>
|
|
||||||
|
<!-- Secondary Pane (registry-driven, see panels.js) -->
|
||||||
|
<aside class="workspace-pane workspace-secondary" id="workspaceSecondary">
|
||||||
<div class="side-panel-header">
|
<div class="side-panel-header">
|
||||||
<span class="side-panel-label" id="sidePanelLabel"></span>
|
<span class="side-panel-label" id="sidePanelLabel"></span>
|
||||||
<div class="side-panel-actions">
|
<div class="side-panel-actions">
|
||||||
<span id="sidePanelPanelActions"></span>
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -239,8 +241,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="side-panel-body" id="sidePanelBody">
|
<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 -->
|
<!-- Preview panel -->
|
||||||
<div class="side-panel-page" id="sidePanelPreview" style="display:none">
|
<div class="side-panel-page" id="sidePanelPreview" style="display:none">
|
||||||
<div class="side-panel-empty" id="previewEmpty">
|
<div class="side-panel-empty" id="previewEmpty">
|
||||||
@@ -344,6 +344,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
</div><!-- .workspace -->
|
||||||
<div class="side-panel-overlay" id="sidePanelOverlay"></div>
|
<div class="side-panel-overlay" id="sidePanelOverlay"></div>
|
||||||
|
|
||||||
</div><!-- .app-body -->
|
</div><!-- .app-body -->
|
||||||
|
|||||||
@@ -450,10 +450,9 @@ function initListeners() {
|
|||||||
_registerProjectPanel(); // from projects-ui.js — register project detail panel
|
_registerProjectPanel(); // from projects-ui.js — register project detail panel
|
||||||
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
|
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
|
||||||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
|
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
|
||||||
_initSidePanelResize(); // from panels.js
|
_initWorkspaceResize(); // from panels.js — workspace handle drag
|
||||||
_initDualDivider(); // from panels.js — dual-view split drag
|
|
||||||
_initPanelSwipe(); // from panels.js — mobile swipe navigation
|
_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
|
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
|
||||||
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
|
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
|
||||||
}
|
}
|
||||||
@@ -516,19 +515,12 @@ function _initGlobalKeyboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl/Cmd+\: cycle side panels
|
// Ctrl/Cmd+\: cycle side panels
|
||||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === '\\') {
|
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
PanelRegistry.cycle();
|
PanelRegistry.cycle();
|
||||||
return;
|
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
|
// Ctrl/Cmd+Shift+S: focus sidebar search
|
||||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ const EditorMode = {
|
|||||||
regions: ['surface-header', 'surface-main'],
|
regions: ['surface-header', 'surface-main'],
|
||||||
activate: () => this._activate(),
|
activate: () => this._activate(),
|
||||||
deactivate: () => this._deactivate(),
|
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
|
// Show the Files tab in sidebar and populate it
|
||||||
|
|||||||
341
src/js/panels.js
341
src/js/panels.js
@@ -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.
|
// Each panel registers with name, DOM element, and lifecycle hooks.
|
||||||
// The registry owns all open/close/focus logic — individual panels
|
// 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
|
// Layout model (v0.22.0):
|
||||||
// body splits into primary (left) + divider + secondary (right) via
|
// .workspace
|
||||||
// CSS grid. The split ratio is drag-adjustable.
|
// .workspace-primary ← main content (chat / notes / editor)
|
||||||
//
|
// .workspace-handle ← drag-resize between panes
|
||||||
// See DESIGN-0.18.1.md for full spec.
|
// .workspace-secondary ← panel pages (preview / notes / project)
|
||||||
|
|
||||||
// ── Panel Registry ──────────────────────────
|
// ── Panel Registry ──────────────────────────
|
||||||
|
|
||||||
const PanelRegistry = {
|
const PanelRegistry = {
|
||||||
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
|
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
|
||||||
_active: null, // primary (left) panel name
|
_active: null, // currently visible panel name
|
||||||
_secondary: null, // secondary (right) panel name — null when not in dual mode
|
|
||||||
_dualMode: false,
|
|
||||||
_splitRatio: 0.5, // 0–1, fraction of space allocated to primary
|
|
||||||
_order: [], // registration order (for tab rendering + cycle)
|
_order: [], // registration order (for tab rendering + cycle)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +27,7 @@ const PanelRegistry = {
|
|||||||
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
|
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {HTMLElement} opts.element — the .side-panel-page div
|
* @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.onOpen] — called when panel becomes visible
|
||||||
* @param {Function} [opts.onClose] — called when panel is hidden
|
* @param {Function} [opts.onClose] — called when panel is hidden
|
||||||
* @param {Function} [opts.saveState] — returns state snapshot before hiding
|
* @param {Function} [opts.saveState] — returns state snapshot before hiding
|
||||||
@@ -56,12 +56,8 @@ const PanelRegistry = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a panel (and the container if closed).
|
* Open a panel (and the secondary pane if collapsed).
|
||||||
*
|
* Hides current active panel, shows the requested one.
|
||||||
* 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(name) {
|
open(name) {
|
||||||
const panel = this._panels[name];
|
const panel = this._panels[name];
|
||||||
@@ -73,39 +69,21 @@ const PanelRegistry = {
|
|||||||
const container = this._container();
|
const container = this._container();
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (this._dualMode) {
|
// Already active — just refresh label/actions
|
||||||
// Already primary — no-op
|
if (this._active === name) {
|
||||||
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._renderLabel();
|
||||||
this._renderActions();
|
this._renderActions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Single mode ──
|
// Hide current active
|
||||||
if (this._active && this._active !== name) {
|
if (this._active && this._active !== name) {
|
||||||
this._hide(this._active);
|
this._hide(this._active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show the secondary pane
|
||||||
container.classList.add('open');
|
container.classList.add('open');
|
||||||
|
this._showHandle(true);
|
||||||
this._show(name);
|
this._show(name);
|
||||||
this._active = name;
|
this._active = name;
|
||||||
this._showOverlay();
|
this._showOverlay();
|
||||||
@@ -114,38 +92,12 @@ const PanelRegistry = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close a specific panel.
|
* Close a specific panel. If it's active, close the secondary pane.
|
||||||
*
|
|
||||||
* Dual mode: closing either panel exits dual, keeping the other.
|
|
||||||
* Single mode: closes container.
|
|
||||||
*/
|
*/
|
||||||
close(name) {
|
close(name) {
|
||||||
const panel = this._panels[name];
|
const panel = this._panels[name];
|
||||||
if (!panel) return;
|
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) {
|
if (this._active === name) {
|
||||||
this._hide(name);
|
this._hide(name);
|
||||||
this._active = null;
|
this._active = null;
|
||||||
@@ -165,89 +117,34 @@ const PanelRegistry = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close all panels and the container.
|
* Close all panels and the secondary pane.
|
||||||
*/
|
*/
|
||||||
closeAll() {
|
closeAll() {
|
||||||
if (this._secondary) {
|
|
||||||
this._hide(this._secondary);
|
|
||||||
this._secondary = null;
|
|
||||||
}
|
|
||||||
if (this._active) {
|
if (this._active) {
|
||||||
this._hide(this._active);
|
this._hide(this._active);
|
||||||
this._active = null;
|
this._active = null;
|
||||||
}
|
}
|
||||||
if (this._dualMode) this._exitDualLayout();
|
|
||||||
this._closeContainer();
|
this._closeContainer();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Is a specific panel currently visible? */
|
/** Is a specific panel currently visible? */
|
||||||
isOpen(name) {
|
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() {
|
isContainerOpen() {
|
||||||
return this._container()?.classList.contains('open') || false;
|
return this._container()?.classList.contains('open') || false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get the primary panel name (or null). */
|
/** Get the active panel name (or null). */
|
||||||
active() {
|
active() {
|
||||||
return this._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
|
* Cycle to the next registered panel. If container is closed, open
|
||||||
* the first panel. Wraps around.
|
* the first panel. Wraps around.
|
||||||
*
|
|
||||||
* In dual mode, cycles the secondary panel through non-primary panels.
|
|
||||||
*/
|
*/
|
||||||
cycle() {
|
cycle() {
|
||||||
if (this._order.length === 0) return;
|
if (this._order.length === 0) return;
|
||||||
@@ -257,23 +154,6 @@ const PanelRegistry = {
|
|||||||
return;
|
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 idx = this._order.indexOf(this._active);
|
||||||
const next = this._order[(idx + 1) % this._order.length];
|
const next = this._order[(idx + 1) % this._order.length];
|
||||||
this.open(next);
|
this.open(next);
|
||||||
@@ -281,8 +161,9 @@ const PanelRegistry = {
|
|||||||
|
|
||||||
// ── Internal ────────────────────────────
|
// ── Internal ────────────────────────────
|
||||||
|
|
||||||
|
/** The secondary pane element. */
|
||||||
_container() {
|
_container() {
|
||||||
return document.getElementById('sidePanel');
|
return document.getElementById('workspaceSecondary');
|
||||||
},
|
},
|
||||||
|
|
||||||
_body() {
|
_body() {
|
||||||
@@ -304,7 +185,7 @@ const PanelRegistry = {
|
|||||||
if (panel.onOpen) panel.onOpen();
|
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) {
|
_hide(name) {
|
||||||
const panel = this._panels[name];
|
const panel = this._panels[name];
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
@@ -316,68 +197,23 @@ const PanelRegistry = {
|
|||||||
if (panel.onClose) panel.onClose();
|
if (panel.onClose) panel.onClose();
|
||||||
|
|
||||||
panel.element.style.display = 'none';
|
panel.element.style.display = 'none';
|
||||||
panel.element.style.gridColumn = '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Close the <aside> container. */
|
/** Close the secondary pane. */
|
||||||
_closeContainer() {
|
_closeContainer() {
|
||||||
const container = this._container();
|
const container = this._container();
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.classList.remove('open', 'fullscreen', 'dual-open');
|
container.classList.remove('open', 'fullscreen');
|
||||||
container.style.width = '';
|
container.style.width = '';
|
||||||
container.style.minWidth = '';
|
container.style.minWidth = '';
|
||||||
|
this._showHandle(false);
|
||||||
this._hideOverlay();
|
this._hideOverlay();
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Dual layout ─────────────────────────
|
/** Show/hide the workspace resize handle. */
|
||||||
|
_showHandle(show) {
|
||||||
/** Apply CSS grid layout for dual-view. */
|
const handle = document.getElementById('workspaceHandle');
|
||||||
_applyDualLayout() {
|
if (handle) handle.classList.toggle('active', show);
|
||||||
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`;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Mobile overlay ──────────────────────
|
// ── Mobile overlay ──────────────────────
|
||||||
@@ -386,7 +222,7 @@ const PanelRegistry = {
|
|||||||
return window.innerWidth <= 768;
|
return window.innerWidth <= 768;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Show overlay behind panel on mobile (tap-to-close). */
|
/** Show overlay behind secondary on mobile (tap-to-close). */
|
||||||
_showOverlay() {
|
_showOverlay() {
|
||||||
const ov = document.getElementById('sidePanelOverlay');
|
const ov = document.getElementById('sidePanelOverlay');
|
||||||
if (ov && this._isMobile()) ov.style.display = 'block';
|
if (ov && this._isMobile()) ov.style.display = 'block';
|
||||||
@@ -398,28 +234,22 @@ const PanelRegistry = {
|
|||||||
if (ov) ov.style.display = 'none';
|
if (ov) ov.style.display = 'none';
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Tab and action rendering ────────────
|
// ── Label and action rendering ──────────
|
||||||
|
|
||||||
/** Update the header label to show the active panel name. */
|
/** Update the header label to show the active panel name. */
|
||||||
_renderLabel() {
|
_renderLabel() {
|
||||||
const el = document.getElementById('sidePanelLabel');
|
const el = document.getElementById('sidePanelLabel');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
if (this._active) {
|
if (this._active) {
|
||||||
const p = this._panels[this._active];
|
const p = this._panels[this._active];
|
||||||
el.textContent = p ? p.label : '';
|
el.textContent = p ? p.label : '';
|
||||||
} else {
|
} else {
|
||||||
el.textContent = '';
|
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() {
|
_renderActions() {
|
||||||
const slot = document.getElementById('sidePanelPanelActions');
|
const slot = document.getElementById('sidePanelPanelActions');
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
@@ -448,10 +278,10 @@ function _escPanel(s) {
|
|||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Side Panel Container (global operations) ──
|
// ── Secondary Pane Fullscreen ──────────────
|
||||||
|
|
||||||
function toggleSidePanelFullscreen() {
|
function toggleSidePanelFullscreen() {
|
||||||
const panel = document.getElementById('sidePanel');
|
const panel = document.getElementById('workspaceSecondary');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.classList.toggle('fullscreen');
|
panel.classList.toggle('fullscreen');
|
||||||
const btn = document.getElementById('sidePanelFullscreenBtn');
|
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;
|
let startX, startW;
|
||||||
const panel = document.getElementById('sidePanel');
|
const secondary = document.getElementById('workspaceSecondary');
|
||||||
const handle = document.getElementById('sidePanelResize');
|
const handle = document.getElementById('workspaceHandle');
|
||||||
if (!handle || !panel) return;
|
if (!handle || !secondary) return;
|
||||||
|
|
||||||
handle.addEventListener('mousedown', (e) => {
|
const onDown = (e) => {
|
||||||
if (panel.classList.contains('fullscreen')) return;
|
if (!handle.classList.contains('active')) return;
|
||||||
|
if (secondary.classList.contains('fullscreen')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startX = e.clientX;
|
const clientX = e.clientX ?? e.touches?.[0]?.clientX;
|
||||||
startW = panel.getBoundingClientRect().width;
|
startX = clientX;
|
||||||
panel.style.transition = 'none';
|
startW = secondary.getBoundingClientRect().width;
|
||||||
|
secondary.style.transition = 'none';
|
||||||
document.body.style.cursor = 'col-resize';
|
document.body.style.cursor = 'col-resize';
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
const onMove = (e) => {
|
const onMove = (e) => {
|
||||||
const delta = startX - e.clientX;
|
const cx = e.clientX ?? e.touches?.[0]?.clientX ?? startX;
|
||||||
const minW = PanelRegistry.isDual() ? 480 : 280;
|
const delta = startX - cx;
|
||||||
const newW = Math.max(minW, Math.min(window.innerWidth * 0.7, startW + delta));
|
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta));
|
||||||
panel.style.width = newW + 'px';
|
secondary.style.width = newW + 'px';
|
||||||
panel.style.minWidth = newW + 'px';
|
secondary.style.minWidth = newW + 'px';
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
document.removeEventListener('touchmove', onMove);
|
||||||
|
document.removeEventListener('touchend', onUp);
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
panel.style.transition = '';
|
secondary.style.transition = '';
|
||||||
};
|
};
|
||||||
document.addEventListener('mousemove', onMove);
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
});
|
document.addEventListener('touchmove', onMove, { passive: false });
|
||||||
}
|
document.addEventListener('touchend', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Dual-View Divider Resize ────────────────
|
handle.addEventListener('mousedown', onDown);
|
||||||
|
handle.addEventListener('touchstart', onDown, { passive: false });
|
||||||
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();
|
|
||||||
};
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mobile Swipe Navigation ─────────────────
|
// ── Mobile Swipe Navigation ─────────────────
|
||||||
@@ -550,7 +352,6 @@ function _initPanelSwipe() {
|
|||||||
body.addEventListener('touchstart', (e) => {
|
body.addEventListener('touchstart', (e) => {
|
||||||
if (!PanelRegistry.isContainerOpen()) return;
|
if (!PanelRegistry.isContainerOpen()) return;
|
||||||
if (PanelRegistry._order.length < 2) return;
|
if (PanelRegistry._order.length < 2) return;
|
||||||
// Only track single-finger swipes
|
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
|
|
||||||
startX = e.touches[0].clientX;
|
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 (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||||
|
|
||||||
if (dx > 0) {
|
if (dx > 0) {
|
||||||
// Swipe right → previous panel
|
|
||||||
_cyclePanelDirection(-1);
|
_cyclePanelDirection(-1);
|
||||||
} else {
|
} else {
|
||||||
// Swipe left → next panel
|
|
||||||
_cyclePanelDirection(1);
|
_cyclePanelDirection(1);
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
@@ -595,7 +394,6 @@ function _cyclePanelDirection(direction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Responsive Resize Handler ───────────────
|
// ── Responsive Resize Handler ───────────────
|
||||||
// Auto-exit dual mode when viewport shrinks below threshold.
|
|
||||||
|
|
||||||
function _initPanelResponsive() {
|
function _initPanelResponsive() {
|
||||||
let wasWide = window.innerWidth > 768;
|
let wasWide = window.innerWidth > 768;
|
||||||
@@ -603,11 +401,6 @@ function _initPanelResponsive() {
|
|||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
const isWide = window.innerWidth > 768;
|
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
|
// Update overlay visibility: show on mobile if panel open, hide on desktop
|
||||||
if (PanelRegistry.isContainerOpen()) {
|
if (PanelRegistry.isContainerOpen()) {
|
||||||
const ov = document.getElementById('sidePanelOverlay');
|
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) {
|
function openSidePanel(tab) {
|
||||||
PanelRegistry.open(tab);
|
PanelRegistry.open(tab);
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ const Surfaces = {
|
|||||||
activate: null, // chat activation is implicit (restore regions)
|
activate: null, // chat activation is implicit (restore regions)
|
||||||
deactivate: null,
|
deactivate: null,
|
||||||
_isDefault: true,
|
_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)`);
|
console.log(`[Surfaces] Initialized with ${this._regionEls.size} region(s)`);
|
||||||
@@ -51,7 +55,13 @@ const Surfaces = {
|
|||||||
/**
|
/**
|
||||||
* Register a new surface (mode).
|
* Register a new surface (mode).
|
||||||
* @param {string} id — unique surface identifier
|
* @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 = {}) {
|
register(id, opts = {}) {
|
||||||
if (this._registry.has(id)) {
|
if (this._registry.has(id)) {
|
||||||
@@ -65,6 +75,9 @@ const Surfaces = {
|
|||||||
regions: opts.regions || [],
|
regions: opts.regions || [],
|
||||||
activate: opts.activate || null,
|
activate: opts.activate || null,
|
||||||
deactivate: opts.deactivate || null,
|
deactivate: opts.deactivate || null,
|
||||||
|
primary: opts.primary || id,
|
||||||
|
secondary: opts.secondary || null,
|
||||||
|
secondaryOpts: opts.secondaryOpts || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
|
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
|
||||||
@@ -167,6 +180,20 @@ const Surfaces = {
|
|||||||
return this._registry.size > 1;
|
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.
|
* Get a saved DocumentFragment for a specific surface + region.
|
||||||
* Used by surfaces like editor-mode that want to embed chat DOM
|
* Used by surfaces like editor-mode that want to embed chat DOM
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ Object.assign(UI, {
|
|||||||
applyAppearance(scale, msgFont) {
|
applyAppearance(scale, msgFont) {
|
||||||
const z = scale === 100 ? '' : scale / 100;
|
const z = scale === 100 ? '' : scale / 100;
|
||||||
// Zoom content areas + modals + panels (but NOT .app or banners)
|
// 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');
|
const splash = document.getElementById('splashGate');
|
||||||
if (splash) splash.style.zoom = z;
|
if (splash) splash.style.zoom = z;
|
||||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||||
|
|||||||
Reference in New Issue
Block a user