Changeset 0.10.2 (#58)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
55
CHANGELOG.md
55
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
63
ROADMAP.md
63
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
270
server/handlers/summarize.go
Normal file
270
server/handlers/summarize.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
243
src/js/__tests__/auth-resilience.test.js
Normal file
243
src/js/__tests__/auth-resilience.test.js
Normal 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.
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
201
src/js/app.js
201
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 = '<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();
|
||||
|
||||
145
src/js/ui.js
145
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
|
||||
// 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 `
|
||||
|
||||
Reference in New Issue
Block a user