diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bcbb17..b9f9d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ All notable changes to Chat Switchboard. +## [0.10.1] — 2026-02-24 + +### Added +- **Admin System Prompt** — Global system prompt configured in Admin › Settings. + Injected as the first system message in every conversation, before user/preset + system prompts. Users cannot override or disable it. Stored in + `global_settings['system_prompt']`. +- **Personas tab** — User presets moved from the Models tab to their own + "Personas" tab in Settings. Tab is hidden when admin disables user presets + (`allow_user_personas` policy). +- **Team Management modal** — Team admin functionality extracted from Settings + into its own tabbed modal (Members, Providers, Presets, Usage, Activity). + Accessed via "Team Management" flyout menu item, or "Manage →" on team cards + in Settings. Multi-team admins see a picker first; single-team admins go + straight to the tabbed view. Back arrow in header returns to picker. +- **Preview pane: clear button** — Trash icon in the preview header resets the + iframe and shows the empty hint. Also auto-clears when deleting a chat that + had active preview content. +- **Preview/Notes pane: fullscreen** — Expand button in the header toggles + fullscreen mode (panel fills entire viewport width). +- **Preview/Notes pane: resizable** — Drag the left edge of the side panel + to resize (280px–70vw). Width resets on close. +- **Code block: download** — "Download" button on every code block. Infers + file extension from language tag (e.g. `python` → `.py`, `go` → `.go`). + +### Changed +- **Personal Usage scoped to BYOK** — `GET /usage` and the user Usage tab now + only show consumption against personal (BYOK) providers. Global provider + usage is the org's cost, not the user's. New `QueryByUserPersonal` store + method filters on `scope = 'personal' AND owner_id = user_id`. Admin usage + views remain unaffected. Usage tab hidden when admin disables user providers + (`allow_user_byok` policy). +- **OpenAI streaming usage race** — Deferred the Done event until the usage + chunk arrives. Previously, finish_reason fired Done before the usage chunk + (which has `choices: []`), so streaming token counts were always 0 for all + OpenAI-compatible providers. + +### Fixed +- **Usage logging zero-token guard** — Removed early exit in `logUsage` that + suppressed rows when tokens were 0. Combined with the streaming race above, + this meant no streaming usage was ever recorded. +- **Live chat completion test** — `TestLive_VeniceChatCompletion` sent wrong + field names (`messages`/`config_id` instead of `content`/`provider_config_id`). + ## [0.10.0] — 2026-02-24 ### Added diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 3a832d7..60b7a42 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -267,11 +267,19 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) { // Banner config, branding, etc. — safe subset for non-admin users banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner") branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding") + systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt") policies, _ := h.stores.Policies.GetAll(c.Request.Context()) + // Only tell the user whether admin prompt exists — don't expose content + hasAdminPrompt := false + if content, ok := systemPrompt["content"].(string); ok && content != "" { + hasAdminPrompt = true + } + c.JSON(http.StatusOK, gin.H{ - "banner": banner, - "branding": branding, + "banner": banner, + "branding": branding, + "has_admin_prompt": hasAdminPrompt, "policies": gin.H{ "allow_registration": policies["allow_registration"], "allow_user_byok": policies["allow_user_byok"], diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 1f4cfc0..a718e70 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "database/sql" "encoding/json" "fmt" @@ -480,14 +481,25 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c // active path through the message tree. Only the current branch is sent — // sibling branches are never included in context. func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) { - // Load system prompt from channel + messages := make([]providers.Message, 0) + + // ── Admin system prompt (always injected first, no opt out) ── + adminPrompt, err := h.stores.GlobalConfig.Get(context.Background(), "system_prompt") + if err == nil { + if content, ok := adminPrompt["content"].(string); ok && content != "" { + messages = append(messages, providers.Message{ + Role: "system", + Content: content, + }) + } + } + + // ── User/preset system prompt (appended after admin prompt) ── var systemPrompt *string _ = database.DB.QueryRow( `SELECT system_prompt FROM channels WHERE id = $1`, channelID, ).Scan(&systemPrompt) - messages := make([]providers.Message, 0) - // Preset system prompt takes priority; channel system prompt is fallback if presetSystemPrompt != "" { messages = append(messages, providers.Message{ diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 94b92fd..60d7342 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -1861,21 +1861,35 @@ func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) { _, adminToken := h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") + database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") userID, userToken := h.registerUser("carol", "carol@test.com", "password123") - // Create global provider + // Create global provider (admin-managed) w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "Global2", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-g2", }) - var cfg map[string]interface{} - decode(w, &cfg) - cfgID := cfg["id"].(string) + var globalCfg map[string]interface{} + decode(w, &globalCfg) + globalCfgID := globalCfg["id"].(string) - seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01) - seedUsage(t, userID, cfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005) + // Create personal BYOK provider (user-managed) + w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ + "name": "MyKey", "provider": "openai", + "endpoint": "http://localhost:1/v1", "api_key": "sk-user-carol", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create personal config: want 201, got %d: %s", w.Code, w.Body.String()) + } + var personalCfg map[string]interface{} + decode(w, &personalCfg) + personalCfgID := personalCfg["id"].(string) - // Personal view should include all scopes + // Seed usage against both providers + seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01) + seedUsage(t, userID, personalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005) + + // Personal view should only show BYOK usage (not global) w = h.request("GET", "/api/v1/usage", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String()) @@ -1884,8 +1898,26 @@ func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) { decode(w, &resp) totals := resp["totals"].(map[string]interface{}) - if int(totals["requests"].(float64)) != 2 { - t.Errorf("personal should see 2 requests (includes BYOK), got %v", totals["requests"]) + if int(totals["requests"].(float64)) != 1 { + t.Errorf("personal should see 1 request (BYOK only, not global), got %v", totals["requests"]) + } + if int(totals["input_tokens"].(float64)) != 500 { + t.Errorf("personal should see 500 input tokens (BYOK row), got %v", totals["input_tokens"]) + } + + // Admin view should show global usage but exclude BYOK + w = h.request("GET", "/api/v1/admin/usage", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String()) + } + decode(w, &resp) + totals = resp["totals"].(map[string]interface{}) + + if int(totals["requests"].(float64)) != 1 { + t.Errorf("admin should see 1 request (global only, not BYOK), got %v", totals["requests"]) + } + if int(totals["input_tokens"].(float64)) != 1000 { + t.Errorf("admin should see 1000 input tokens (global row), got %v", totals["input_tokens"]) } } diff --git a/server/handlers/usage.go b/server/handlers/usage.go index 7863c80..a1b93ba 100644 --- a/server/handlers/usage.go +++ b/server/handlers/usage.go @@ -92,7 +92,7 @@ func (h *UsageHandler) PersonalUsage(c *gin.Context) { return } - results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts) + results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"}) return diff --git a/server/store/interfaces.go b/server/store/interfaces.go index ebaeccf..e7ef712 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -285,6 +285,7 @@ type GlobalConfigStore interface { type UsageStore interface { Log(ctx context.Context, entry *models.UsageEntry) 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) QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error) QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error) diff --git a/server/store/postgres/usage.go b/server/store/postgres/usage.go index 5974614..ccb1e32 100644 --- a/server/store/postgres/usage.go +++ b/server/store/postgres/usage.go @@ -40,6 +40,16 @@ func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store. return s.query(ctx, where, args, opts) } +// QueryByUserPersonal returns aggregated usage for a user's own (BYOK) providers only. +func (s *UsageStore) QueryByUserPersonal(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) { + where := []string{ + "user_id = $1", + "provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)", + } + args := []interface{}{userID} + return s.query(ctx, where, args, opts) +} + // QueryByTeam returns aggregated usage for all members of a team (admin view). func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) { where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"} @@ -104,11 +114,15 @@ func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, o return &t, err } -// GetPersonalTotals returns aggregate totals for a specific user (all scopes). +// GetPersonalTotals returns aggregate totals for a user's own provider usage. +// Only includes usage against personal (BYOK) providers — global provider +// costs are the org's responsibility, not the user's. func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) { - baseWhere := []string{"user_id = $1"} + baseWhere := []string{ + "user_id = $1", + "provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)", + } baseArgs := []interface{}{userID} - // Don't exclude BYOK for personal view opts.ExcludeBYOK = false where, args := s.buildFilters(baseWhere, baseArgs, opts) diff --git a/src/css/styles.css b/src/css/styles.css index c17f420..ffba3be 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -579,17 +579,38 @@ a:hover { text-decoration: underline; } .side-panel { width: 0; min-width: 0; overflow: hidden; background: var(--bg); border-left: 1px solid var(--border); - display: flex; flex-direction: column; + display: flex; flex-direction: column; position: relative; transition: width 0.25s ease, min-width 0.25s ease; } .side-panel.open { width: 480px; min-width: 480px; } +.side-panel.fullscreen { + position: fixed; top: 0; right: 0; bottom: 0; + width: 100% !important; min-width: 100% !important; + z-index: 100; + transition: none; +} +.side-panel-resize { + position: absolute; left: -3px; top: 0; bottom: 0; width: 6px; + cursor: col-resize; z-index: 10; +} +.side-panel-resize:hover { background: var(--accent); opacity: 0.3; } +.side-panel.fullscreen .side-panel-resize { display: none; } .side-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg-raised); flex-shrink: 0; } +.side-panel-actions { + display: flex; align-items: center; gap: 2px; +} +.side-panel-action-btn { + background: none; border: none; color: var(--text-3); + cursor: pointer; padding: 4px 6px; border-radius: var(--radius); + transition: all var(--transition); display: flex; align-items: center; +} +.side-panel-action-btn:hover { background: var(--bg-hover); color: var(--text); } .side-panel-tabs { display: flex; gap: 2px; background: var(--bg-surface); border-radius: var(--radius); padding: 2px; @@ -1279,6 +1300,12 @@ button { font-family: var(--font); cursor: pointer; } .btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; } .team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; } .team-card-info { display: flex; align-items: center; gap: 8px; } +.team-admin-back { + cursor: pointer; margin-right: 4px; opacity: 0.5; + transition: opacity var(--transition); +} +.team-admin-back:hover { opacity: 1; } +.team-tab-content { padding: 0; } .badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } .admin-user-row.user-inactive { opacity: 0.7; } .loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; } diff --git a/src/index.html b/src/index.html index a44a6cb..e548b47 100644 --- a/src/index.html +++ b/src/index.html @@ -95,6 +95,10 @@ Admin Panel + - +
+ + + +
@@ -321,8 +334,8 @@ - - + +
+ + @@ -442,102 +458,114 @@
- - + - -
-

Team Presets

-
- - -
- -
-

Usage

-
- - + +
- -
-

Activity Log

-
- +
+
+ + + +
+ + + + + + + + + + - @@ -647,6 +675,13 @@