diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 346e9d7..76f516f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -87,6 +87,14 @@ jobs: exit 1 fi + - name: Syntax check (all JS) + run: | + echo "Checking JS syntax..." + for f in src/js/*.js; do + node --check "$f" || exit 1 + echo " ✓ $f" + done + - name: Run frontend tests run: node --test src/js/__tests__/*.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f9d7f..58f41c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,61 @@ All notable changes to Chat Switchboard. +## [0.10.2] — 2026-02-24 + +### Added +- **Summarize & Continue** — User-triggered conversation compaction using the + utility model role. Button appears in context warning bar at ≥75% context + usage. `POST /channels/:id/summarize` calls the utility role to generate a + summary, inserts it as a tree node with `metadata.type = "summary"`, and + updates the cursor. Subsequent completions use the summary as a context + boundary — messages before it are replaced by the summary as a system message. + Multiple summaries stack. Fork-aware (summaries are tree nodes with their own + branch position). "Show full history" toggle reveals collapsed earlier messages. +- **BYOK Role Overrides** — Users with personal providers can override the org's + utility and embedding model roles. Resolution chain: personal → team → global. + New "Model Roles" tab in Settings (visible when BYOK is enabled). Stored in + `user.settings` JSONB under `model_roles` key, same shape as team overrides. +- **Utility Rate Limiting** — `utility_rate_limit` global setting (default: 20 + calls/hour/user, 0 = unlimited). Org-funded utility calls check against + `usage_log` before executing. BYOK calls exempt (user pays their own way). + Returns 429 with clear message when exceeded. +- **Message metadata in path** — `PathMessage` now includes `metadata` JSONB + field, enabling summary detection and future message-type extensibility. +- **Per-chat model/preset restore** — Switching between chats now restores the + last-used model or preset in the selector. Stored in localStorage keyed by + channel ID. Falls back to the channel's base model, then the global default, + if the original selection is no longer available. Cleaned up on chat deletion. + +### Changed +- **Generation role removed** — `RoleGeneration` ("generation") removed from + `ValidRoles` and admin Roles tab. Image/media generation will be + extension-managed (v0.11.0) with its own provider config, not a role slot. + The current completion/embedding abstraction doesn't fit image gen's + fundamentally different API surface. +- **Resolver.Complete/Embed signatures** — Now accept `userID` parameter for + personal role override resolution. Empty string skips personal lookup. +- **Proactive token refresh** — Access token is now refreshed at 80% of its + lifetime (~12min for 15min tokens). On page reload, a conservative 60s refresh + is scheduled since token age is unknown. Eliminates the race condition where + profile succeeds on a near-expired token but subsequent calls fail. +- **Auth guard in `startApp()`** — If token is invalidated during startup + (e.g., 401 on loadChats after profile succeeded), app returns to login + instead of rendering a broken UI with cascading 401 errors. +- **Per-chat model restore fallback** — When the stored model/preset is removed, + falls back to admin default → first visible model (was keeping stale selection). + Also cleans up the stale localStorage entry. +- **BYOK Role Overrides** — Fixed response parsing (`configs.configs` not + `configs.data`) and model field mapping (`configId`/`baseModelId` not + `provider_config_id`/`model_id`). +- **`GetRole` endpoint integration test** — `GET /admin/roles/:role` now + exercised by CI via `TestIntegration_Roles_GetSingleRole`, catching the + `GetConfig` signature mismatch that caused the build failure. +- **Auth resilience frontend tests** — 10 tests covering startup auth guard, + token refresh lifecycle, and the profile-success-then-401 edge case. +- **JS syntax lint in CI** — `node --check` runs on all `src/js/*.js` files + before frontend tests, catching parse errors that tests alone can't detect. + ## [0.10.1] — 2026-02-24 ### Added diff --git a/EXTENSIONS.md b/EXTENSIONS.md index d440160..cd09cdc 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -620,11 +620,13 @@ resolve to a specific provider+model at runtime. │ │ classification, triage │ deepseek-chat │ │ embedding │ Vector generation for │ text-embedding-3 │ │ │ KB search, note search │ nomic-embed-text │ -│ generation │ Image/media generation │ dall-e-3 │ -│ │ │ stable-diffusion │ └──────────────────────────────────────────────────────────────┘ ``` +> **Note:** The `generation` role (image/media) was removed in v0.10.2. +> Image generation will be extension-managed with its own provider config +> and API surface, not a named role slot. + ### B.2 Configuration Admin configures roles in global settings, with optional fallback chain: @@ -668,9 +670,10 @@ against the role for cost tracking. |---|---|---| | Compaction service | `utility` | Summarize long conversations | | Smart routing | `utility` | Classify intent, pick best model | +| Summarize & Continue | `utility` | Conversation compaction (v0.10.2) | | Knowledge bases | `embedding` | Generate vectors for similarity search | | Note search (future) | `embedding` | Semantic search across notes | -| Image gen extension | `generation` | Create images from descriptions | +| Image gen extension | _(extension-managed)_ | Uses own provider config, not a role | ### B.5 Relationship to Smart Routing diff --git a/ROADMAP.md b/ROADMAP.md index 68d56ab..3d7d302 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ v0.9.4 API Key Encryption + Vault │ v0.10.0 Model Roles (utility + embedding) + Usage Tracking │ -v0.10.1 Summarize & Continue (first utility role consumer) +v0.10.2 Summarize & Continue (first utility role consumer) │ ┌───────┴──────────────┐ │ │ @@ -404,16 +404,56 @@ Three tracks of infrastructure that everything downstream depends on. --- -## v0.10.1 — Summarize & Continue +## ✅ v0.10.1 — Polish + UX + +Spit and polish release: admin system prompt injection, team management modal +extraction, persona tab, side panel enhancements, code download, and critical +OpenAI streaming usage race fix. + +- [x] Admin system prompt (global, injected before user/preset prompts) +- [x] Personas tab (moved from Models, policy-gated) +- [x] Team Management modal (extracted from Settings, tabbed, multi-team picker) +- [x] Preview pane: clear button, fullscreen toggle, resizable +- [x] Code block: download button (infers extension from language tag) +- [x] Personal Usage scoped to BYOK only +- [x] OpenAI streaming usage race fix (deferred Done event until usage chunk) +- [x] Usage logging zero-token guard removal + +--- + +## v0.10.2 — Summarize & Continue + Role Cleanup First consumer of the utility model role. User-triggered conversation compaction — not automatic (that's v0.15.0). -- [ ] "Summarize and continue" button (appears alongside context length warning) -- [ ] Calls utility role to summarize conversation history -- [ ] Summary inserted as system message, earlier messages hidden from context -- [ ] Original messages preserved in DB (viewable via "show full history" toggle) -- [ ] Respects team-level role overrides for utility model selection +Depends on: utility model role (v0.10.0). + +**Summarize & Continue** +- [x] "Summarize and continue" button in context warning bar (≥75% context) +- [x] `POST /channels/:id/summarize` — calls utility role with conversation history +- [x] Summary inserted as tree node with `metadata.type = "summary"` + boundary metadata +- [x] `loadConversation` recognizes summary boundaries: replaces pre-summary messages with summary system message +- [x] Multiple summaries stack (re-summarize after context fills again) +- [x] Fork-aware: summary is a tree node, branches have independent boundaries +- [x] Original messages preserved: "show full history" toggle reveals collapsed messages +- [x] Usage logged against utility role with cost tracking + +**BYOK Role Overrides** +- [x] Personal role resolution chain: personal → team → global +- [x] User Settings → Model Roles tab (BYOK providers only) +- [x] Stored in `user.settings` JSONB (`model_roles` key, same shape as team overrides) +- [x] Only utility and embedding roles (generation removed) + +**Generation Role Removal** +- [x] `RoleGeneration` removed from `ValidRoles` (was "generation" for image/media) +- [x] Removed from admin Roles tab UI +- [x] Image gen will be extension-managed (v0.11.0), not a role slot + +**Utility Rate Limiting** +- [x] `utility_rate_limit` global setting (default: 20 calls/hour/user, 0 = unlimited) +- [x] Check against `usage_log` before executing org-funded utility calls +- [x] BYOK calls exempt (user's own key, user's own cost) +- [x] 429 response with clear message when exceeded --- @@ -681,6 +721,15 @@ based on need. - GDPR-style "download my data" - Backup/restore CronJob manifests +**UX / Multi-Seat** +- Per-chat model/preset persistence (server-side). Currently a localStorage + bandaid (v0.10.2) that doesn't roam across devices. Proper fix: store + `last_selector_id` in `channels.settings` JSONB on each send. On chat + selection, resolve from `channel.settings.last_selector_id` → match against + available models/presets → fall back to `channel.model` → global default. + Eliminates device-local state entirely. Needs `updateChannel` PATCH on + completion success (single extra column merge, no migration). + **Platform** - Rate limiting per user/team/tier (token budgets) - Provider health monitoring + key rotation diff --git a/VERSION b/VERSION index 2774f85..5eef0f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.10.0 \ No newline at end of file +0.10.2 diff --git a/server/handlers/completion.go b/server/handlers/completion.go index a718e70..06d785f 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -480,6 +480,9 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c // loadConversation builds the message history for the LLM by walking the // active path through the message tree. Only the current branch is sent — // sibling branches are never included in context. +// +// Summary-aware: if the path contains a summary node (metadata.type = "summary"), +// messages before it are replaced by the summary content as a system message. func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) { messages := make([]providers.Message, 0) @@ -519,10 +522,32 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm return nil, err } - for _, m := range path { + // Find the most recent summary node in the path + summaryIdx := -1 + for i, m := range path { + if isSummaryMessage(&m) { + summaryIdx = i + } + } + + // If we found a summary, inject it as a system message and skip prior messages + startIdx := 0 + if summaryIdx >= 0 { + messages = append(messages, providers.Message{ + Role: "system", + Content: "Previous conversation summary:\n" + path[summaryIdx].Content, + }) + startIdx = summaryIdx + 1 + } + + for _, m := range path[startIdx:] { if m.Role == "system" { continue // system prompts handled above } + // Skip summary nodes that aren't the boundary (shouldn't happen, but defensive) + if isSummaryMessage(&m) { + continue + } messages = append(messages, providers.Message{ Role: m.Role, Content: m.Content, @@ -532,6 +557,18 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm return messages, nil } +// isSummaryMessage checks if a PathMessage has summary metadata. +func isSummaryMessage(m *PathMessage) bool { + if m.Metadata == nil { + return false + } + var meta map[string]interface{} + if err := json.Unmarshal(*m.Metadata, &meta); err != nil { + return false + } + return meta["type"] == "summary" +} + // ── Message Persistence ───────────────────── // persistMessage inserts a message into the tree, updates the cursor, and diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 60d7342..fd95061 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -1611,6 +1611,7 @@ func TestIntegration_Roles_ListReturnsSeeded(t *testing.T) { decode(w, &resp) // Migration 004 seeds utility, embedding, generation + // (generation removed from ValidRoles in v0.10.2 but still in JSONB seed data) for _, role := range []string{"utility", "embedding", "generation"} { if _, ok := resp[role]; !ok { t.Errorf("expected role %q in response, got: %v", role, resp) @@ -2174,3 +2175,48 @@ func TestIntegration_Pricing_RejectBYOKUpsert(t *testing.T) { t.Fatalf("pricing BYOK upsert: want 403, got %d: %s", w.Code, w.Body.String()) } } + +// ── GetRole Endpoint (exercises GetConfig signature) ── +// The existing Roles_UpdateAndGet test validates via ListRoles. +// This test hits GET /admin/roles/:role which routes through +// resolver.GetConfig — the exact call site that broke in v0.10.2 +// when the signature changed from 3-arg to 4-arg. + +func TestIntegration_Roles_GetSingleRole(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin_getrole", "admin_getrole@test.com") + + // Create provider + configure utility role + w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ + "name": "getrole-provider", "provider": "openai", + "endpoint": "http://localhost:1/v1", "api_key": "sk-getrole", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create config: %d: %s", w.Code, w.Body.String()) + } + var cfg map[string]interface{} + decode(w, &cfg) + cfgID := cfg["id"].(string) + + w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{ + "primary": map[string]string{ + "provider_config_id": cfgID, + "model_id": "gpt-4o-mini", + }, + }) + if w.Code != http.StatusOK { + t.Fatalf("update role: %d: %s", w.Code, w.Body.String()) + } + + // GET /admin/roles/utility — the endpoint that broke + w = h.request("GET", "/api/v1/admin/roles/utility", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get single role: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Seeded role with null config via GetRole → 200 (migration seeds all roles) + w = h.request("GET", "/api/v1/admin/roles/embedding", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get seeded-but-empty role: want 200, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/server/handlers/roles.go b/server/handlers/roles.go index 4bae563..1f53ee6 100644 --- a/server/handlers/roles.go +++ b/server/handlers/roles.go @@ -44,7 +44,7 @@ func (h *RolesHandler) GetRole(c *gin.Context) { return } - cfg, err := h.resolver.GetConfig(c.Request.Context(), role, nil) + cfg, err := h.resolver.GetConfig(c.Request.Context(), role, "", nil) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -104,7 +104,7 @@ func (h *RolesHandler) TestRole(c *gin.Context) { if role == roles.RoleEmbedding { // Test embedding - result, err := h.resolver.Embed(c.Request.Context(), role, nil, []string{"test embedding"}) + result, err := h.resolver.Embed(c.Request.Context(), role, "", nil, []string{"test embedding"}) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return @@ -120,7 +120,7 @@ func (h *RolesHandler) TestRole(c *gin.Context) { } // Test completion with a minimal prompt - result, err := h.resolver.Complete(c.Request.Context(), role, nil, []providers.Message{ + result, err := h.resolver.Complete(c.Request.Context(), role, "", nil, []providers.Message{ {Role: "user", Content: "Say 'ok' and nothing else."}, }) if err != nil { diff --git a/server/handlers/summarize.go b/server/handlers/summarize.go new file mode 100644 index 0000000..af15033 --- /dev/null +++ b/server/handlers/summarize.go @@ -0,0 +1,270 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/roles" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// SummarizeHandler handles conversation summarization using the utility model role. +type SummarizeHandler struct { + stores store.Stores + resolver *roles.Resolver +} + +// NewSummarizeHandler creates a new handler. +func NewSummarizeHandler(s store.Stores, resolver *roles.Resolver) *SummarizeHandler { + return &SummarizeHandler{stores: s, resolver: resolver} +} + +// ── Summarize & Continue ────────────────── +// POST /channels/:id/summarize +// +// Calls the utility role to summarize the conversation history, inserts +// the summary as a special message node in the tree, and returns it. +// Subsequent completions will use the summary as context boundary. + +func (h *SummarizeHandler) Summarize(c *gin.Context) { + channelID := c.Param("id") + userID := getUserID(c) + + // ── Verify channel ownership ── + var ownerID string + err := database.DB.QueryRow( + `SELECT user_id FROM channels WHERE id = $1 AND deleted_at IS NULL`, channelID, + ).Scan(&ownerID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) + return + } + if ownerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) + return + } + + // ── Check utility role is configured ── + if !h.resolver.IsConfigured(c.Request.Context(), roles.RoleUtility) { + // If user doesn't have a personal override either, it's not available + if !h.resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Utility model role is not configured. Ask your admin to set one, or configure your own in Settings → Model Roles.", + }) + return + } + } + + // ── Rate limiting (org-funded calls only) ── + isPersonal := h.resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) + if !isPersonal { + if err := h.checkRateLimit(c.Request.Context(), userID); err != nil { + c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()}) + return + } + } + + // ── Load active path ── + path, err := getActivePath(channelID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) + return + } + if len(path) < 4 { + c.JSON(http.StatusBadRequest, gin.H{"error": "conversation too short to summarize"}) + return + } + + // Find existing summary boundary (if any) and only summarize after it + startIdx := 0 + for i, m := range path { + if isSummaryMessage(&m) { + startIdx = i + 1 + } + } + + // Build messages to summarize (skip system messages, skip previous summaries) + var toSummarize []string + messagesInScope := 0 + var lastMessageID string + for _, m := range path[startIdx:] { + if m.Role == "system" || isSummaryMessage(&m) { + continue + } + toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content)) + messagesInScope++ + lastMessageID = m.ID + } + + if messagesInScope < 4 { + c.JSON(http.StatusBadRequest, gin.H{"error": "not enough new messages since last summary"}) + return + } + + // ── Build the summarization prompt ── + conversationText := strings.Join(toSummarize, "\n\n") + summaryPrompt := []providers.Message{ + { + Role: "system", + Content: `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves: +- Key decisions and conclusions reached +- Important facts, names, numbers, and technical details mentioned +- Action items or commitments made +- The overall context and topic flow + +Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.`, + }, + { + Role: "user", + Content: "Summarize this conversation:\n\n" + conversationText, + }, + } + + // ── Resolve team context for the user ── + teamID := h.getUserTeamID(c.Request.Context(), userID) + + // ── Call utility role ── + log.Printf("📝 Summarizing channel %s for user %s (%d messages)", channelID, userID, messagesInScope) + result, err := h.resolver.Complete(c.Request.Context(), roles.RoleUtility, userID, teamID, summaryPrompt) + if err != nil { + log.Printf("⚠ Summarize failed for channel %s: %v", channelID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "summarization failed: " + err.Error()}) + return + } + + // ── Log usage ── + h.logSummaryUsage(c.Request.Context(), channelID, userID, result) + + // ── Insert summary message as a tree node ── + // The summary message is inserted as an "assistant" message with special metadata. + // Its parent is the last message that was summarized, making it part of the tree. + metadata := models.JSONMap{ + "type": "summary", + "summarized_until_id": lastMessageID, + "summarized_count": messagesInScope, + "utility_model": result.Model, + "used_fallback": result.UsedFallback, + } + + metaJSON, _ := json.Marshal(metadata) + siblingIdx := nextSiblingIndex(channelID, &lastMessageID) + + var summaryMsgID string + err = database.DB.QueryRow(` + INSERT INTO messages (channel_id, parent_id, role, content, model, metadata, sibling_index, participant_type) + VALUES ($1, $2, 'assistant', $3, $4, $5, $6, 'system') + RETURNING id + `, channelID, lastMessageID, result.Content, result.Model, string(metaJSON), siblingIdx).Scan(&summaryMsgID) + if err != nil { + log.Printf("⚠ Failed to persist summary for channel %s: %v", channelID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save summary"}) + return + } + + // Update cursor to point to the summary node + if err := updateCursor(channelID, userID, summaryMsgID); err != nil { + log.Printf("⚠ Failed to update cursor after summarize: %v", err) + } + + log.Printf("✅ Summary created for channel %s: %s (%d messages → %d chars)", + channelID, summaryMsgID, messagesInScope, len(result.Content)) + + c.JSON(http.StatusOK, gin.H{ + "summary_id": summaryMsgID, + "summarized_count": messagesInScope, + "model": result.Model, + "used_fallback": result.UsedFallback, + "content": result.Content, + }) +} + +// ── Rate Limiting ───────────────────────── + +func (h *SummarizeHandler) checkRateLimit(ctx context.Context, userID string) error { + // Load rate limit from global settings (default: 20/hour, 0 = unlimited) + limit := 20 + settings, err := h.stores.GlobalConfig.Get(ctx, "utility_rate_limit") + if err == nil { + if v, ok := settings["value"]; ok { + switch n := v.(type) { + case float64: + limit = int(n) + case int: + limit = n + } + } + } + + if limit <= 0 { + return nil // unlimited + } + + since := time.Now().Add(-1 * time.Hour) + count, err := h.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since) + if err != nil { + log.Printf("⚠ Rate limit check failed: %v", err) + return nil // fail open — don't block on DB errors + } + + if count >= limit { + return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit) + } + return nil +} + +// ── Helpers ─────────────────────────────── + +func (h *SummarizeHandler) getUserTeamID(ctx context.Context, userID string) *string { + // Get the user's first team (for role override resolution) + var teamID string + err := database.DB.QueryRow(` + SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1 + `, userID).Scan(&teamID) + if err != nil || teamID == "" { + return nil + } + return &teamID +} + +func (h *SummarizeHandler) logSummaryUsage(ctx context.Context, channelID, userID string, result *roles.CompletionResult) { + role := roles.RoleUtility + entry := &models.UsageEntry{ + ChannelID: &channelID, + UserID: userID, + ProviderConfigID: &result.ConfigID, + ProviderScope: result.ProviderScope, + ModelID: result.Model, + Role: &role, + PromptTokens: result.InputTokens, + CompletionTokens: result.OutputTokens, + CacheCreationTokens: result.CacheCreationTokens, + CacheReadTokens: result.CacheReadTokens, + } + + // Calculate cost from pricing + pricing, err := h.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model) + if err == nil && pricing != nil { + if pricing.InputPerM != nil { + costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM + entry.CostInput = &costIn + } + if pricing.OutputPerM != nil { + costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM + entry.CostOutput = &costOut + } + } + + if err := h.stores.Usage.Log(ctx, entry); err != nil { + log.Printf("⚠ Failed to log summary usage: %v", err) + } +} diff --git a/server/handlers/tree.go b/server/handlers/tree.go index e7eff39..227fae9 100644 --- a/server/handlers/tree.go +++ b/server/handlers/tree.go @@ -20,6 +20,7 @@ type PathMessage struct { Model *string `json:"model"` TokensUsed *int `json:"tokens_used,omitempty"` ToolCalls *json.RawMessage `json:"tool_calls,omitempty"` + Metadata *json.RawMessage `json:"metadata,omitempty"` SiblingCount int `json:"sibling_count"` SiblingIndex int `json:"sibling_index"` ParticipantType string `json:"participant_type,omitempty"` @@ -98,7 +99,7 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) { rows, err := database.DB.Query(` WITH RECURSIVE path AS ( -- Anchor: start at the leaf - SELECT id, parent_id, role, content, model, tokens_used, tool_calls, + SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, participant_type, participant_id, sibling_index, created_at, 0 AS depth FROM messages @@ -107,14 +108,14 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) { UNION ALL -- Walk up via parent_id - SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, + SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, m.participant_type, m.participant_id, m.sibling_index, m.created_at, p.depth + 1 FROM messages m JOIN path p ON m.id = p.parent_id WHERE m.deleted_at IS NULL ) - SELECT id, parent_id, role, content, model, tokens_used, tool_calls, + SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, participant_type, participant_id, sibling_index, created_at FROM path ORDER BY depth DESC @@ -128,10 +129,10 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) { for rows.Next() { var m PathMessage var participantType, participantID sql.NullString - var toolCallsJSON []byte + var toolCallsJSON, metadataJSON []byte if err := rows.Scan( &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, - &toolCallsJSON, + &toolCallsJSON, &metadataJSON, &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, ); err != nil { return nil, fmt.Errorf("getPathToLeaf scan: %w", err) @@ -140,6 +141,10 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) { raw := json.RawMessage(toolCallsJSON) m.ToolCalls = &raw } + if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { + raw := json.RawMessage(metadataJSON) + m.Metadata = &raw + } if participantType.Valid { m.ParticipantType = participantType.String } diff --git a/server/main.go b/server/main.go index e276174..769da82 100644 --- a/server/main.go +++ b/server/main.go @@ -154,6 +154,10 @@ func main() { comp := handlers.NewCompletionHandler(keyResolver, stores) protected.POST("/chat/completions", comp.Complete) + // Summarize & Continue + summarize := handlers.NewSummarizeHandler(stores, roleResolver) + protected.POST("/channels/:id/summarize", summarize.Summarize) + // Provider Configs (user-facing — replaces /api-configs) provCfg := handlers.NewProviderConfigHandler(stores, keyResolver) protected.GET("/api-configs", provCfg.ListConfigs) // backward compat diff --git a/server/roles/roles.go b/server/roles/roles.go index 6efa829..24a4a1b 100644 --- a/server/roles/roles.go +++ b/server/roles/roles.go @@ -14,13 +14,14 @@ import ( // ── Known Roles ──────────────────────────── const ( - RoleUtility = "utility" // Internal tasks: summarization, title generation - RoleEmbedding = "embedding" // Vector embedding for knowledge bases - RoleGeneration = "generation" // Primary chat generation (future routing) + RoleUtility = "utility" // Internal tasks: summarization, title generation + RoleEmbedding = "embedding" // Vector embedding for knowledge bases ) // ValidRoles lists all recognized role names. -var ValidRoles = []string{RoleUtility, RoleEmbedding, RoleGeneration} +// Note: "generation" (image/media) was removed in v0.10.2 — image gen +// will be extension-managed with its own provider config, not a role slot. +var ValidRoles = []string{RoleUtility, RoleEmbedding} // IsValidRole returns true if the given role name is recognized. func IsValidRole(role string) bool { @@ -96,9 +97,9 @@ func NewResolver(s store.Stores, vault *crypto.KeyResolver) *Resolver { } // Complete sends a chat completion using the named role. -// Resolution: team override → global config → try primary → fallback on error. -func (r *Resolver) Complete(ctx context.Context, role string, teamID *string, messages []providers.Message) (*CompletionResult, error) { - cfg, err := r.GetConfig(ctx, role, teamID) +// Resolution: personal override → team override → global config → try primary → fallback on error. +func (r *Resolver) Complete(ctx context.Context, role string, userID string, teamID *string, messages []providers.Message) (*CompletionResult, error) { + cfg, err := r.GetConfig(ctx, role, userID, teamID) if err != nil { return nil, err } @@ -129,8 +130,8 @@ func (r *Resolver) Complete(ctx context.Context, role string, teamID *string, me } // Embed generates embeddings using the named role. -func (r *Resolver) Embed(ctx context.Context, role string, teamID *string, input []string) (*EmbeddingResult, error) { - cfg, err := r.GetConfig(ctx, role, teamID) +func (r *Resolver) Embed(ctx context.Context, role string, userID string, teamID *string, input []string) (*EmbeddingResult, error) { + cfg, err := r.GetConfig(ctx, role, userID, teamID) if err != nil { return nil, err } @@ -161,13 +162,21 @@ func (r *Resolver) Embed(ctx context.Context, role string, teamID *string, input } // GetConfig returns the resolved role config for the given role name. -// Team overrides take precedence over global config. -func (r *Resolver) GetConfig(ctx context.Context, role string, teamID *string) (*RoleConfig, error) { +// Resolution order: personal override → team override → global config. +func (r *Resolver) GetConfig(ctx context.Context, role string, userID string, teamID *string) (*RoleConfig, error) { if !IsValidRole(role) { return nil, fmt.Errorf("unknown role: %q", role) } - // Check team override first + // Check personal override first (BYOK users) + if userID != "" { + personalCfg, err := r.getPersonalRoleConfig(ctx, userID, role) + if err == nil && personalCfg != nil && (personalCfg.Primary != nil || personalCfg.Fallback != nil) { + return personalCfg, nil + } + } + + // Check team override if teamID != nil && *teamID != "" { teamCfg, err := r.getTeamRoleConfig(ctx, *teamID, role) if err == nil && teamCfg != nil && (teamCfg.Primary != nil || teamCfg.Fallback != nil) { @@ -181,7 +190,13 @@ func (r *Resolver) GetConfig(ctx context.Context, role string, teamID *string) ( // IsConfigured returns true if the named role has at least a primary binding. func (r *Resolver) IsConfigured(ctx context.Context, role string) bool { - cfg, err := r.GetConfig(ctx, role, nil) + cfg, err := r.GetConfig(ctx, role, "", nil) + return err == nil && cfg != nil && cfg.Primary != nil +} + +// IsPersonalOverride returns true if the user has a personal binding for the role. +func (r *Resolver) IsPersonalOverride(ctx context.Context, userID, role string) bool { + cfg, err := r.getPersonalRoleConfig(ctx, userID, role) return err == nil && cfg != nil && cfg.Primary != nil } @@ -285,6 +300,35 @@ func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (pr // ── Internal: Config Loading ─────────────── +func (r *Resolver) getPersonalRoleConfig(ctx context.Context, userID, role string) (*RoleConfig, error) { + user, err := r.stores.Users.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + settings := user.Settings + if settings == nil { + return nil, nil + } + + rolesRaw, ok := settings["model_roles"] + if !ok { + return nil, nil + } + + rolesMap, ok := rolesRaw.(map[string]interface{}) + if !ok { + return nil, nil + } + + roleData, ok := rolesMap[role] + if !ok { + return nil, nil + } + + return parseRoleConfig(roleData) +} + func (r *Resolver) getGlobalRoleConfig(ctx context.Context, role string) (*RoleConfig, error) { allRoles, err := r.stores.GlobalConfig.Get(ctx, "model_roles") if err != nil { diff --git a/server/store/interfaces.go b/server/store/interfaces.go index e7ef712..9dde26c 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -284,6 +284,7 @@ type GlobalConfigStore interface { type UsageStore interface { Log(ctx context.Context, entry *models.UsageEntry) error + CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) QueryByUserPersonal(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) diff --git a/server/store/postgres/usage.go b/server/store/postgres/usage.go index ccb1e32..c35a38e 100644 --- a/server/store/postgres/usage.go +++ b/server/store/postgres/usage.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -143,6 +144,17 @@ func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts return &t, err } +// CountRecentByRole counts how many usage entries a user has for a given +// role within the specified duration. Used for rate limiting utility calls. +func (s *UsageStore) CountRecentByRole(ctx context.Context, userID, role string, since time.Time) (int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM usage_log + WHERE user_id = $1 AND role = $2 AND created_at >= $3 + `, userID, role, since).Scan(&count) + return count, err +} + // ── Internal ─────────────────────────────── func (s *UsageStore) buildFilters(baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]string, []interface{}) { diff --git a/src/css/styles.css b/src/css/styles.css index ffba3be..3f90149 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -829,6 +829,40 @@ a:hover { text-decoration: underline; } .context-warning-text { flex: 1; } .context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; } .context-warning-dismiss:hover { color: var(--text-1); } +.context-warning-action { + flex-shrink: 0; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border); + background: var(--bg-surface); color: var(--text-1); cursor: pointer; + font-size: 0.75rem; white-space: nowrap; transition: all var(--transition); +} +.context-warning-action:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.context-warning-action:disabled { opacity: 0.5; cursor: not-allowed; } +.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text-1); border-color: var(--border); } +/* Summary message node */ +.message-summary { + border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent); + border-radius: 8px; padding: 10px 14px; margin: 4px 0; + background: color-mix(in srgb, var(--accent) 6%, transparent); + font-size: 0.85rem; position: relative; +} +.message-summary .summary-header { + display: flex; align-items: center; gap: 6px; margin-bottom: 6px; + font-size: 0.75rem; color: var(--text-3); font-weight: 500; +} +.message-summary .summary-toggle { + background: none; border: none; color: var(--accent); cursor: pointer; + font-size: 0.75rem; padding: 0; text-decoration: underline; +} +.message-summary .summary-toggle:hover { color: var(--text-1); } +.msg-dimmed { opacity: 0.5; } +.message-summary-divider { + text-align: center; padding: 8px 0; margin: 4px 0; +} +.message-summary-divider .summary-toggle { + background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 0.78rem; padding: 4px 12px; border-radius: 4px; + transition: all var(--transition); +} +.message-summary-divider .summary-toggle:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); } .input-wrap { max-width: 768px; margin: 0 auto; background: var(--bg-surface); border: 1px solid var(--border); diff --git a/src/index.html b/src/index.html index e548b47..c9bfa66 100644 --- a/src/index.html +++ b/src/index.html @@ -144,6 +144,7 @@