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 @@
@@ -336,6 +337,7 @@ +
+ + diff --git a/src/js/__tests__/auth-resilience.test.js b/src/js/__tests__/auth-resilience.test.js new file mode 100644 index 0000000..0dc58df --- /dev/null +++ b/src/js/__tests__/auth-resilience.test.js @@ -0,0 +1,243 @@ +// ========================================== +// Auth Resilience Tests +// ========================================== +// Validates that auth failure during startup +// results in a redirect to login, NOT a broken +// main UI with 401 errors everywhere. +// +// Bug: Profile returned 200 (token on edge of +// expiry), startApp() proceeded, then every +// subsequent call 401'd. User saw broken UI +// instead of login screen. +// +// Run: node --test src/js/__tests__/auth-resilience.test.js +// ========================================== + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// ── Auth state model ──────────────────────── + +describe('Auth state transitions', () => { + + // Simulates the API client's auth lifecycle + function makeAuthState() { + return { + accessToken: null, + refreshToken: null, + get isAuthed() { return !!this.accessToken; }, + clearTokens() { this.accessToken = null; this.refreshToken = null; }, + setTokens(access, refresh) { this.accessToken = access; this.refreshToken = refresh; }, + }; + } + + it('isAuthed reflects token presence', () => { + const auth = makeAuthState(); + assert.equal(auth.isAuthed, false); + auth.setTokens('tok_access', 'tok_refresh'); + assert.equal(auth.isAuthed, true); + }); + + it('clearTokens makes isAuthed false', () => { + const auth = makeAuthState(); + auth.setTokens('tok_access', 'tok_refresh'); + assert.equal(auth.isAuthed, true); + auth.clearTokens(); + assert.equal(auth.isAuthed, false); + }); + + it('expired token string is still truthy (the edge case)', () => { + const auth = makeAuthState(); + // An expired JWT is still a non-empty string — isAuthed returns true + // This is BY DESIGN: the server validates, not the client + const expiredJWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.fake'; + auth.setTokens(expiredJWT, 'tok_refresh'); + assert.equal(auth.isAuthed, true, 'expired JWT is still truthy'); + }); +}); + +// ── Startup auth guard ────────────────────── + +describe('startApp auth guard', () => { + + // This models the critical startup sequence: + // init(): + // if (isAuthed) → getProfile() → if ok → startApp() + // startApp(): + // loadSettings() → loadChats() → fetchModels() + // *** AUTH GUARD CHECK HERE *** + // if (!isAuthed) → showSplash, return + // else → render UI + + it('proceeds when auth is valid throughout startup', () => { + let splashShown = false; + let uiRendered = false; + + const auth = { isAuthed: true }; + // Simulate: loadChats succeeds, auth stays valid + const loadChats = () => { /* no 401 */ }; + const fetchModels = () => { /* no 401 */ }; + + // startApp logic + loadChats(); + fetchModels(); + if (!auth.isAuthed) { + splashShown = true; + } else { + uiRendered = true; + } + + assert.equal(uiRendered, true, 'UI should render when auth is valid'); + assert.equal(splashShown, false); + }); + + it('returns to login when auth is lost during startup', () => { + let splashShown = false; + let uiRendered = false; + + const auth = { + accessToken: 'tok', + get isAuthed() { return !!this.accessToken; }, + clearTokens() { this.accessToken = null; }, + }; + + // Simulate: loadChats gets 401, refresh fails, clearTokens called + const loadChats = () => { + // _authed → 401 → refresh() → fails → clearTokens() + auth.clearTokens(); + }; + const fetchModels = () => { /* also 401 but tokens already cleared */ }; + + loadChats(); + fetchModels(); + + // THE FIX: guard check after critical loads + if (!auth.isAuthed) { + splashShown = true; + } else { + uiRendered = true; + } + + assert.equal(splashShown, true, 'must return to login when auth lost'); + assert.equal(uiRendered, false, 'must NOT render UI when auth lost'); + }); + + it('detects auth loss even when only one call triggers it', () => { + const auth = { + accessToken: 'tok', + get isAuthed() { return !!this.accessToken; }, + clearTokens() { this.accessToken = null; }, + }; + + let splashShown = false; + + // loadSettings succeeds (token barely valid) + // loadChats triggers 401 → clearTokens + const loadSettings = () => { /* ok */ }; + const loadChats = () => { auth.clearTokens(); }; + const fetchModels = () => { /* tokens already gone, silently fails */ }; + + loadSettings(); + loadChats(); + fetchModels(); + + if (!auth.isAuthed) splashShown = true; + + assert.equal(splashShown, true, 'single 401 during startup must trigger login redirect'); + }); +}); + +// ── 401 retry + refresh behavior ───────────── + +describe('401 retry with refresh', () => { + + it('successful refresh preserves auth', () => { + const auth = { + accessToken: 'expired', + refreshToken: 'valid_refresh', + get isAuthed() { return !!this.accessToken; }, + clearTokens() { this.accessToken = null; this.refreshToken = null; }, + refresh() { + // Simulate successful refresh + this.accessToken = 'new_valid_token'; + return true; + }, + }; + + // _authed: first call 401, refresh succeeds, retry succeeds + const result401 = { status: 401 }; + if (result401.status === 401 && auth.refreshToken) { + auth.refresh(); + } + + assert.equal(auth.isAuthed, true, 'auth preserved after successful refresh'); + assert.equal(auth.accessToken, 'new_valid_token'); + }); + + it('failed refresh clears auth', () => { + const auth = { + accessToken: 'expired', + refreshToken: 'also_expired', + get isAuthed() { return !!this.accessToken; }, + clearTokens() { this.accessToken = null; this.refreshToken = null; }, + refresh() { + // Simulate failed refresh (401 on refresh endpoint) + this.clearTokens(); + return false; + }, + }; + + const result401 = { status: 401 }; + if (result401.status === 401 && auth.refreshToken) { + auth.refresh(); + } + + assert.equal(auth.isAuthed, false, 'auth cleared after failed refresh'); + }); +}); + +// ── Init flow: profile check ───────────────── + +describe('init() profile gate', () => { + + it('clears tokens when profile returns 401', () => { + const auth = { + accessToken: 'expired', + refreshToken: 'expired_too', + get isAuthed() { return !!this.accessToken; }, + clearTokens() { this.accessToken = null; this.refreshToken = null; }, + }; + + // Simulate getProfile throwing (as _authed does after failed refresh) + const profileFailed = true; + if (profileFailed) auth.clearTokens(); + + assert.equal(auth.isAuthed, false, 'tokens cleared on profile failure'); + }); + + it('profile success + subsequent 401 = the edge case bug', () => { + // This is the exact scenario from the bug report: + // 1. Profile returns 200 (token barely valid, 5ms) + // 2. init() proceeds to startApp() + // 3. loadChats() returns 401 (token expired in the 200ms gap) + // 4. Without the guard, user sees broken UI + + const auth = { + accessToken: 'barely_valid', + get isAuthed() { return !!this.accessToken; }, + clearTokens() { this.accessToken = null; }, + }; + + // Profile succeeds (token still valid) + const profileOk = true; + assert.equal(profileOk, true); + assert.equal(auth.isAuthed, true, 'auth valid after profile'); + + // startApp: loadChats fails, clears tokens + auth.clearTokens(); + + // THE GUARD: must catch this + assert.equal(auth.isAuthed, false, 'auth gone after loadChats 401'); + // Without guard: UI renders. With guard: splash shown. + }); +}); diff --git a/src/js/api.js b/src/js/api.js index 6729085..a7bcb8f 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -27,6 +27,8 @@ const API = { this.accessToken = saved.accessToken || null; this.refreshToken = saved.refreshToken || null; this.user = saved.user || null; + // Unknown age — refresh soon to get a fresh token + if (this.refreshToken) this._scheduleRefresh(60); } } catch (e) { /* corrupt storage */ } }, @@ -44,6 +46,7 @@ const API = { this.refreshToken = null; this.user = null; localStorage.removeItem(_storageKey); + if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; } }, get isAuthed() { return !!this.accessToken; }, @@ -116,6 +119,20 @@ const API = { this.refreshToken = data.refresh_token; this.user = data.user; this.saveTokens(); + this._scheduleRefresh(data.expires_in || 900); + }, + + _refreshTimer: null, + + _scheduleRefresh(expiresIn) { + if (this._refreshTimer) clearTimeout(this._refreshTimer); + // Refresh at 80% of expiry (e.g., 12min for 15min token) + const ms = Math.max((expiresIn * 0.8) * 1000, 30000); + this._refreshTimer = setTimeout(async () => { + if (!this.refreshToken) return; + console.debug('🔄 Proactive token refresh'); + await this.refresh(); + }, ms); }, // ── Channels ────────────────────────────── @@ -202,6 +219,11 @@ const API = { return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`); }, + // ── Summarize & Continue ──────────────── + summarizeChannel(channelId) { + return this._post(`/api/v1/channels/${channelId}/summarize`, {}); + }, + // ── Completions ────────────────────────── async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) { diff --git a/src/js/app.js b/src/js/app.js index d662ccf..73acca0 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -108,6 +108,7 @@ function updateInputTokens() { function updateContextWarning() { const warning = document.getElementById('contextWarning'); const text = document.getElementById('contextWarningText'); + const summarizeBtn = document.getElementById('summarizeBtn'); if (!warning || !text) return; const budget = Tokens.getContextBudget(); @@ -125,10 +126,16 @@ function updateContextWarning() { const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt); const pct = convTokens / budget.maxContext; + // Show summarize button at ≥75% context usage (if enough messages) + const showSummarize = pct >= 0.75 && chat.messages.length >= 4; + if (summarizeBtn) { + summarizeBtn.style.display = showSummarize ? 'inline-block' : 'none'; + } + if (pct >= 0.9 && !Tokens._warningDismissed) { warning.style.display = 'flex'; warning.className = 'context-warning danger'; - text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context. Consider starting a new chat.`; + text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context.`; } else if (pct >= 0.75 && !Tokens._warningDismissed) { warning.style.display = 'flex'; warning.className = 'context-warning'; @@ -144,6 +151,54 @@ function dismissContextWarning() { if (el) el.style.display = 'none'; } +// ── Summarize & Continue ────────────────── + +async function summarizeAndContinue() { + const channelId = App.currentChatId; + if (!channelId) return; + + const btn = document.getElementById('summarizeBtn'); + if (btn) { + btn.disabled = true; + btn.textContent = '⏳ Summarizing…'; + } + + try { + const result = await API.summarizeChannel(channelId); + UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success'); + + // Reload the active path to get the updated message tree + const resp = await API.getActivePath(channelId); + const chat = App.chats.find(c => c.id === channelId); + if (chat && resp) { + chat.messages = (resp.path || []).map(m => ({ + id: m.id, + parent_id: m.parent_id || null, + role: m.role, + content: m.content, + model: m.model || '', + modelName: '', + timestamp: m.created_at, + tool_calls: m.tool_calls || null, + metadata: m.metadata || null, + siblingCount: m.sibling_count || 1, + siblingIndex: m.sibling_index || 0, + })); + UI.renderMessages(chat.messages); + updateContextWarning(); + updateInputTokens(); + } + } catch (err) { + console.error('Summarize failed:', err); + UI.toast(err.message || 'Summarization failed', 'error'); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = '📝 Summarize & Continue'; + } + } +} + async function init() { console.log('🔀 Chat Switchboard initializing...'); @@ -201,6 +256,15 @@ async function startApp() { await loadSettings(); await loadChats(); await fetchModels(); + + // Guard: if token was invalidated during startup (401 → refresh fail → clearTokens), + // kick back to login instead of rendering a broken UI. + if (!API.isAuthed) { + console.warn('⚠️ Auth lost during startup, returning to login'); + showSplash(null); + return; + } + await initBanners(); UI.renderChatList(); UI.updateModelSelector(); @@ -385,6 +449,7 @@ async function selectChat(chatId) { modelName: '', timestamp: m.created_at, tool_calls: m.tool_calls || null, + metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); @@ -394,6 +459,9 @@ async function selectChat(chatId) { } } + // Restore the last-used model/preset for this chat + _restoreChatModel(chatId, chat); + UI.renderMessages(chat.messages); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); Tokens._warningDismissed = false; @@ -401,6 +469,66 @@ async function selectChat(chatId) { updateInputTokens(); } +// ── Per-Chat Model/Preset Persistence ──────── +// BANDAID: localStorage — doesn't roam across devices. +// TODO: move to channel.settings.last_selector_id (server-side). See ROADMAP TBD. + +function _saveChatModel(chatId) { + if (!chatId) return; + const selectorId = UI.getModelValue(); + if (!selectorId) return; + try { + const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}'); + map[chatId] = selectorId; + localStorage.setItem('cs-chat-models', JSON.stringify(map)); + } catch (e) { /* quota exceeded, etc */ } +} + +function _restoreChatModel(chatId, chat) { + // Priority: stored selector ID → channel base model → keep current + let targetId = null; + + // 1. Check localStorage for exact selector ID (handles presets) + try { + const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}'); + if (map[chatId]) targetId = map[chatId]; + } catch (e) { /* */ } + + // 2. Verify the stored ID still exists in available models + if (targetId) { + const found = App.models.find(m => m.id === targetId); + if (found) { + UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : '')); + return; + } + // Stored model removed — clean up stale entry + try { + const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}'); + delete map[chatId]; + localStorage.setItem('cs-chat-models', JSON.stringify(map)); + } catch (e) { /* */ } + } + + // 3. Fall back to channel's base model ID (match by baseModelId) + if (chat?.model) { + const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden); + if (match) { + UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : '')); + return; + } + } + + // 4. Stored model gone — fall back to admin default → first visible + const visible = App.models.filter(m => !m.hidden); + const def = App.defaultModel + ? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel) + : null; + const fallback = def || visible[0]; + if (fallback) { + UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : '')); + } +} + async function newChat() { App.currentChatId = null; UI.renderChatList(); @@ -422,6 +550,12 @@ async function deleteChat(chatId) { try { await API.deleteChannel(chatId); App.chats = App.chats.filter(c => c.id !== chatId); + // Clean up stored model preference + try { + const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}'); + delete map[chatId]; + localStorage.setItem('cs-chat-models', JSON.stringify(map)); + } catch (e) { /* */ } if (App.currentChatId === chatId) { clearPreview(); newChat(); } UI.renderChatList(); } catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); } @@ -472,6 +606,9 @@ async function sendMessage() { // Reload active path from server to get proper IDs and sibling metadata await reloadActivePath(); UI.showRegenerate(true); + + // Persist the model/preset selection for this chat + _saveChatModel(App.currentChatId); } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); @@ -518,6 +655,7 @@ async function reloadActivePath() { modelName: '', timestamp: m.created_at, tool_calls: m.tool_calls || null, + metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); @@ -701,6 +839,8 @@ async function switchSibling(messageId, direction) { model: m.model || '', modelName: '', timestamp: m.created_at, + tool_calls: m.tool_calls || null, + metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, })); @@ -2277,6 +2417,65 @@ var _notesSelectMode = false; var _selectedNoteIds = new Set(); var _notesSort = 'updated_desc'; +// ── User Model Role Functions ───────────────── + +async function userRoleProviderChanged(role) { + const provSel = document.getElementById(`userRole-${role}-provider`); + const modelSel = document.getElementById(`userRole-${role}-model`); + if (!provSel || !modelSel) return; + + const providerId = provSel.value; + modelSel.innerHTML = ''; + if (!providerId) return; + + // Load models for the selected provider (App.models uses configId, not provider_config_id) + const allModels = App.models || []; + const provModels = allModels.filter(m => m.configId === providerId); + provModels.forEach(m => { + modelSel.innerHTML += ``; + }); +} + +async function saveUserRole(role) { + const provSel = document.getElementById(`userRole-${role}-provider`); + const modelSel = document.getElementById(`userRole-${role}-model`); + if (!provSel || !modelSel) return; + + const providerId = provSel.value; + const modelId = modelSel.value; + + if (!providerId || !modelId) { + UI.toast('Select both a provider and model', 'warning'); + return; + } + + try { + const settings = await API.getSettings(); + const roles = settings.model_roles || {}; + roles[role] = { primary: { provider_config_id: providerId, model_id: modelId } }; + await API.updateSettings({ model_roles: roles }); + UI.toast(`${role} role override saved`, 'success'); + UI.loadUserRoles(); + } catch (e) { + UI.toast(e.message || 'Failed to save role', 'error'); + } +} + +async function clearUserRole(role) { + try { + const settings = await API.getSettings(); + const roles = settings.model_roles || {}; + delete roles[role]; + await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null }); + UI.toast(`${role} role override removed — using org default`, 'success'); + UI.loadUserRoles(); + } catch (e) { + UI.toast(e.message || 'Failed to clear role', 'error'); + } +} + +// ── Notes ───────────────────────────────────── + async function openNotes() { openSidePanel('notes'); _exitSelectMode(); diff --git a/src/js/ui.js b/src/js/ui.js index b6dbf1a..0ef9d9e 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -164,6 +164,26 @@ function renderPresetForm(containerEl, options = {}) { return form; } +// ── Summary Message Helpers ───────────────── + +function _isSummaryMessage(msg) { + if (!msg.metadata) return false; + const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata; + return meta?.type === 'summary'; +} + +function toggleSummarizedHistory() { + const el = document.getElementById('summarizedHistory'); + const btn = document.querySelector('.message-summary-divider .summary-toggle'); + if (!el) return; + const show = el.style.display === 'none'; + el.style.display = show ? 'block' : 'none'; + if (btn) { + const count = el.querySelectorAll('.message').length; + btn.textContent = show ? `▾ Hide ${count} earlier messages` : `▸ ${count} earlier messages summarized`; + } +} + const UI = { // ── Sidebar ────────────────────────────── @@ -271,15 +291,64 @@ const UI = { return; } - el.innerHTML = messages - .filter(m => m.role !== 'system') - .map((m, i) => this._messageHTML(m, i)) - .join(''); + // Find the most recent summary boundary + let summaryIdx = -1; + for (let i = 0; i < messages.length; i++) { + if (_isSummaryMessage(messages[i])) summaryIdx = i; + } + + // Render: if summary exists, show collapsed-history toggle + summary + messages after + let html = ''; + if (summaryIdx >= 0) { + const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length; + if (hiddenCount > 0) { + html += `
+ +
+ `; + } + // Render summary node + html += this._summaryHTML(messages[summaryIdx]); + // Render messages after summary + html += messages.slice(summaryIdx + 1) + .filter(m => m.role !== 'system' && !_isSummaryMessage(m)) + .map((m, i) => this._messageHTML(m, summaryIdx + 1 + i)) + .join(''); + } else { + html = messages + .filter(m => m.role !== 'system') + .map((m, i) => this._messageHTML(m, i)) + .join(''); + } + + el.innerHTML = html; this._scrollToBottom(true); }, - _messageHTML(msg, index) { + _summaryHTML(msg) { + const meta = msg.metadata || {}; + const count = meta.summarized_count || '?'; + const model = meta.utility_model || 'utility model'; + return ` +
+
+ 📝 Conversation summary (${count} messages, by ${esc(model)}) +
+
${formatMessage(msg.content)}
+
`; + }, + + _messageHTML(msg, index, dimmed) { const isUser = msg.role === 'user'; + // Skip summary messages in normal rendering — they get _summaryHTML + if (_isSummaryMessage(msg)) return ''; const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; let assistantLabel = 'Assistant'; if (!isUser) { @@ -311,7 +380,7 @@ const UI = { const regenBtn = !isUser && msgId ? `` : ''; return ` -
+
${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}
@@ -846,6 +915,7 @@ const UI = { if (tab === 'models') UI.loadUserModels(); if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); } if (tab === 'usage') UI.loadMyUsage(); + if (tab === 'roles') UI.loadUserRoles(); if (tab === 'appearance') UI.loadAppearanceSettings(); }, @@ -901,12 +971,14 @@ const UI = { const addBtn = document.getElementById('providerShowAddBtn'); const tabBtn = document.getElementById('settingsProvidersTabBtn'); const usageTabBtn = document.getElementById('settingsUsageTabBtn'); + const rolesTabBtn = document.getElementById('settingsRolesTabBtn'); if (!notice) return; const allowed = App.policies?.allow_user_byok === 'true'; notice.style.display = allowed ? 'none' : ''; if (addBtn) addBtn.style.display = allowed ? '' : 'none'; if (tabBtn) tabBtn.style.display = allowed ? '' : 'none'; if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none'; + if (rolesTabBtn) rolesTabBtn.style.display = allowed ? '' : 'none'; }, checkUserPresetsAllowed() { @@ -1193,6 +1265,71 @@ const UI = { _teamAuditPage: 1, _teamAuditPerPage: 20, + // ── User Model Roles (BYOK overrides) ── + + async loadUserRoles() { + const el = document.getElementById('userRolesConfig'); + const notice = document.getElementById('userRolesDisabled'); + const tabBtn = document.getElementById('settingsRolesTabBtn'); + if (!el) return; + + // Only show if BYOK is enabled + const byokAllowed = App.policies?.allow_user_byok === 'true'; + if (tabBtn) tabBtn.style.display = byokAllowed ? '' : 'none'; + if (!byokAllowed) return; + + try { + // Get user's personal providers and their models + const configs = await API.listConfigs(); + const configList = configs.configs || configs.data || []; + const personalProviders = configList.filter(c => c.scope === 'personal'); + + if (personalProviders.length === 0) { + el.style.display = 'none'; + if (notice) notice.style.display = ''; + return; + } + el.style.display = ''; + if (notice) notice.style.display = 'none'; + + // Load all models for personal providers + const allModels = App.models || []; + const personalModels = allModels.filter(m => + personalProviders.some(p => p.id === m.configId) + ); + + // Load current user settings to get existing role overrides + const settings = await API.getSettings(); + const userRoles = settings.model_roles || {}; + + const roleNames = ['utility', 'embedding']; + el.innerHTML = roleNames.map(role => { + const cfg = userRoles[role] || { primary: null }; + return ` +
+

${role} + ${role === 'utility' ? '(summarization, title gen)' : '(future: KB search, note search)'} +

+
+ + + + + ${cfg.primary ? `` : ''} +
+
`; + }).join(''); + } catch (e) { + el.innerHTML = `
${esc(e.message)}
`; + } + }, + async loadTeamAuditLog(page) { const teamId = UI._managingTeamId; if (!teamId) return; @@ -1478,7 +1615,7 @@ const UI = { const models = await API.adminListModels(); const modelList = models.models || models.data || models || []; - const roleNames = ['utility', 'embedding', 'generation']; + const roleNames = ['utility', 'embedding']; el.innerHTML = roleNames.map(role => { const cfg = roles[role] || { primary: null, fallback: null }; return `