Changeset 0.10.2 (#58)

This commit is contained in:
2026-02-24 20:29:08 +00:00
parent 13772ebd6c
commit 4061e4a145
20 changed files with 1223 additions and 41 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1 +1 @@
0.10.0
0.10.2

View File

@@ -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

View File

@@ -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())
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -16,11 +16,12 @@ import (
const (
RoleUtility = "utility" // Internal tasks: summarization, title generation
RoleEmbedding = "embedding" // Vector embedding for knowledge bases
RoleGeneration = "generation" // Primary chat generation (future routing)
)
// 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 {

View File

@@ -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)

View File

@@ -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{}) {

View File

@@ -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);

View File

@@ -144,6 +144,7 @@
<div class="context-warning" id="contextWarning" style="display:none">
<span class="context-warning-icon">⚠️</span>
<span class="context-warning-text" id="contextWarningText"></span>
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss"></button>
</div>
<div class="input-wrap">
@@ -336,6 +337,7 @@
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
<button class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')" id="settingsPersonasTabBtn">Personas</button>
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')" id="settingsUsageTabBtn">Usage</button>
<button class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" id="settingsRolesTabBtn">Model Roles</button>
</div>
<div class="modal-body">
<!-- General Tab -->
@@ -458,6 +460,17 @@
<div id="myUsageResults"></div>
</section>
</div>
<!-- Model Roles Tab (BYOK users — personal role overrides) -->
<div class="settings-tab-content" id="settingsRolesTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:4px">Personal Model Roles</h3>
<p class="section-hint" style="margin-bottom:8px">Override the org's default utility and embedding models with your own providers. Uses your personal API keys — no org cost.</p>
<div id="userRolesConfig"></div>
</section>
<div id="userRolesDisabled" class="settings-notice" style="display:none">
<span></span> Add a personal provider first to configure role overrides.
</div>
</div>
</div>
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
</div>

View File

@@ -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.
});
});

View File

@@ -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) {

View File

@@ -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 = '<option value="">— select model —</option>';
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 += `<option value="${m.baseModelId}">${esc(m.name || m.baseModelId)}</option>`;
});
}
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();

View File

@@ -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
// 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 += `<div class="message-summary-divider" id="summaryDivider">
<button class="summary-toggle" onclick="toggleSummarizedHistory()">
${hiddenCount} earlier messages summarized
</button>
</div>
<div id="summarizedHistory" style="display:none">
${messages.slice(0, summaryIdx)
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i, true))
.join('')}
</div>`;
}
// 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 `
<div class="message-summary" data-msg-id="${esc(msg.id || '')}">
<div class="summary-header">
<span>📝 Conversation summary (${count} messages, by ${esc(model)})</span>
</div>
<div class="msg-text">${formatMessage(msg.content)}</div>
</div>`;
},
_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 ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
return `
<div class="message ${msg.role}" data-msg-id="${msgId}">
<div class="message ${msg.role}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
<div class="msg-inner">
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
<div class="msg-body">
@@ -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 `
<div class="settings-section" style="margin-bottom:12px">
<h4 style="font-size:13px;margin:0 0 6px;text-transform:capitalize">${role}
<span class="form-hint">${role === 'utility' ? '(summarization, title gen)' : '(future: KB search, note search)'}</span>
</h4>
<div class="form-row" style="gap:8px;align-items:center">
<label style="min-width:60px;font-size:12px">Provider</label>
<select id="userRole-${role}-provider" onchange="userRoleProviderChanged('${role}')" style="min-width:140px;font-size:12px">
<option value="">— use org default —</option>
${personalProviders.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="userRole-${role}-model" style="min-width:200px;font-size:12px">
<option value="">— select model —</option>
${cfg.primary ? personalModels.filter(m => m.configId === cfg.primary.provider_config_id).map(m => `<option value="${m.baseModelId}" ${m.baseModelId === cfg.primary.model_id ? 'selected' : ''}>${esc(m.name || m.baseModelId)}</option>`).join('') : ''}
</select>
<button class="btn-small" onclick="saveUserRole('${role}')" style="font-size:11px">Save</button>
${cfg.primary ? `<button class="btn-small btn-danger" onclick="clearUserRole('${role}')" style="font-size:11px" title="Remove override, use org default">Clear</button>` : ''}
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
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 `