diff --git a/CHANGELOG.md b/CHANGELOG.md index c76f313..00171a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,85 @@ All notable changes to Chat Switchboard. +## [0.9.3] — 2026-02-23 + +### Changed +- **Code blocks: button-driven collapse** — Replaced `
` wrapper with + inline collapse/expand toggle button in the code toolbar. Auto-collapses at + >15 lines with a fade mask; toggle available on all blocks >5 lines. User + can always expand/collapse regardless of threshold. Smooth CSS transition + instead of native `
` jump. +- **Thinking blocks: always visible** — Thinking blocks are always rendered in + the DOM regardless of the `showThinking` setting. The setting now controls + whether blocks start expanded (`
`) or collapsed. User can + always click to toggle. Setting label updated to "Auto-expand thinking blocks". + +### Added +- **Admin default model** — New `default_model` policy in Admin → Settings. + Dropdown populated from enabled models. When a user has no saved selection + (fresh login, cleared browser) or their saved model is no longer available, + the admin default is used before falling back to first visible. Resolution + chain: `localStorage → admin default → first visible`. +- **Reasoning/thinking support for OpenAI-compatible providers** — Models that + send `reasoning_content` in stream deltas (Grok, DeepSeek, etc.) now have + thinking blocks streamed live and persisted as `` tags. Renders as + collapsible thinking blocks in both streaming and history views. +- **Favicon animation during generation** — Browser tab favicon pulses with + cascading dot opacity animation while a completion is in progress, restoring + to the static favicon when done. +- **Tool calls in message history** — Tool call metadata (name, arguments, + result, error status) is now persisted alongside assistant messages in the + database. History view renders tool calls as collapsed `
` blocks + showing "🔧 tool_name → done" with expandable input/output JSON. +- **Notes tool: "View note" link** — When a notes tool (`note_create`, + `note_update`, etc.) completes, a "📝 View note" button appears in both + the live streaming tool indicator and the history tool call block. Clicking + opens the Notes panel and navigates directly to the note. +- **Notes panel: Copy button** — "Copy" button added to note read mode, + copies title + content as markdown to clipboard. +- **Brand hover: jitter-free crossfade** — Sidebar brand logo→collapse icon + swap uses opacity crossfade instead of `display: none` toggle, eliminating + layout reflow jitter on hover. + +### Fixed +- **Model selector shows unavailable model** — Selector restoration searched + all models including user-hidden ones. Saved selection (localStorage) for a + hidden model like Claude Opus would re-select it on every page load even when + only Grok was visible. Resolution now filters to visible models only. +- **Refresh toast shows wrong count** — "Loaded 33 models" counted all models + including hidden; now shows only visible count ("Loaded 1 model"). +- **Tool calls vanish after completion** — Live streaming tool indicators + disappeared when `reloadActivePath()` rebuilt messages from DB. Fixed: the + `getActivePath` query now includes `tool_calls` column, and `PathMessage` + carries the data through to the frontend renderer. +- **Tool result "undefined results" text** — Operator precedence bug in tool + result summary parser caused `undefined results` to display for note_create. + Fixed summary logic to properly branch on title vs count. +- **Regenerate streams at wrong position** — Regen'd response appeared below + the full conversation (appended at bottom) then snapped to correct position + after completion. Fixed: display is now truncated to the parent message + before streaming starts, so the new response streams in-place as a clean + branch. Backend context was already correct (excludes old response). +- **Regenerate loses tools, reasoning, and tool execution** — Regen handler + was a stripped-down copy of the completion handler missing tool definitions, + tool execution loop, reasoning_content forwarding, and tool_calls persistence. + Model lost access to note tools on regen and fell back to generic capabilities. + Refactored: extracted `streamWithToolLoop()` into `stream_loop.go` as the + single canonical streaming implementation. Both `streamCompletion` (normal + chat) and `Regenerate` now call the shared function — only persistence + differs. Future streaming features (new event types, tool capabilities) + automatically apply to all code paths. +- **OpenRouter free models crash** — Free models with nil pricing caused + `pq: invalid input syntax for type json` on catalog sync. Nil pricing now + routes through `ToJSON()` producing `"{}"` instead of nil `[]byte`. +- **API key appears unsaved** — `ListGlobalConfigs` returned raw + `ProviderConfig` structs where `APIKeyEnc` is `json:"-"` (never serialized), + so `has_key` was always `undefined`. Now returns computed `has_key` field. +- **Case-insensitive usernames** — Login, registration, and all user lookups + use `LOWER()` in SQL. All user creation paths normalize to lowercase. + New migration `002_ci_username.sql` adds `LOWER()` unique indexes and + normalizes existing rows. + ## [0.9.2] — 2026-02-23 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 8bb7d83..f23d08a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,7 +17,9 @@ Features have real dependencies. This ordering respects them. ``` v0.9.x Stability + Quick UX Wins │ -v0.9.3 API Key Encryption + Vault +v0.9.3 Content Visibility & Block Controls + │ +v0.9.4 API Key Encryption + Vault │ v0.10.0 Model Roles (utility + embedding) + Usage Tracking │ @@ -125,7 +127,34 @@ Polish that doesn't need new infrastructure. Ship fast, stabilize. --- -## v0.9.3 — API Key Encryption + Vault +## v0.9.3 — Content Visibility & Block Controls + +User control over what's expanded/collapsed in the message flow. Thinking +blocks, tool calls, and code blocks all get consistent collapse controls +instead of being hidden or auto-formatted without user agency. + +**Code Blocks** +- [ ] Collapse/expand toggle button on code block header (alongside Copy/Preview) +- [ ] Auto-collapse at threshold (>15 lines), user can always toggle +- [ ] Smooth max-height transition instead of `
` wrapping + +**Thinking Blocks** +- [ ] Always render thinking blocks (never hide from DOM) +- [ ] `showThinking` setting controls default open/closed, not visibility +- [ ] User can always click to expand regardless of setting + +**Tool Calls** +- [ ] Persist tool call metadata in stored messages +- [ ] Render tool calls on history load (not just during streaming) +- [ ] Collapsed by default: "🔧 tool_name → done" with toggle for full I/O +- [ ] Notes tool: "📝 View note" link that navigates to Notes panel + +**Notes Panel** +- [ ] Copy button on note cards (same pattern as code block copy) + +--- + +## v0.9.4 — API Key Encryption + Vault Two-tier encryption with a trust boundary between organizational keys (admin- accessible) and personal BYOK keys (user-only). Personal keys are protected diff --git a/VERSION b/VERSION index f76f913..965065d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.2 \ No newline at end of file +0.9.3 diff --git a/server/capabilities/resolver.go b/server/capabilities/resolver.go index 1ebdbfb..dd0fd84 100644 --- a/server/capabilities/resolver.go +++ b/server/capabilities/resolver.go @@ -61,6 +61,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m providerMap[p.ID] = p } + countGlobal := len(globalModels) for _, entry := range globalModels { caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities) prov := providerMap[entry.ProviderConfigID] @@ -82,6 +83,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m } // ── 2. Team enabled catalog models → team members ──── + countTeam := 0 for _, teamID := range teamIDs { teamProviders, err := stores.Providers.ListForTeam(ctx, teamID) if err != nil { @@ -113,11 +115,13 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m OwnerID: prov.OwnerID, Hidden: hiddenMap[entry.ModelID], }) + countTeam++ } } } // ── 3. Personal BYOK enabled models → owner ──── + countPersonal := 0 if allowBYOK { personalProviders, err := stores.Providers.ListForUser(ctx, userID) if err != nil { @@ -148,6 +152,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m OwnerID: &userID, Hidden: hiddenMap[entry.ModelID], }) + countPersonal++ } } } @@ -221,6 +226,10 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m } } + countPersonas := len(result) - countGlobal - countTeam - countPersonal + log.Printf("📋 ModelsForUser(%s): %d global, %d team, %d personal, %d personas → %d total", + userID, countGlobal, countTeam, countPersonal, countPersonas, len(result)) + return result, nil } diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 88f6703..9964722 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -368,7 +368,7 @@ func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { // ── Model Catalog ─────────────────────────── func (h *AdminHandler) ListModelConfigs(c *gin.Context) { - entries, err := h.stores.Catalog.ListAll(c.Request.Context()) + entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) return diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go index 861697d..5e23b6b 100644 --- a/server/handlers/capabilities.go +++ b/server/handlers/capabilities.go @@ -35,7 +35,10 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"models": userModels}) + // Include admin default model so frontend can use it in resolution chain + defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model") + + c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel}) } // ResolveModelCaps is the canonical capability resolver for any model. diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 8be659b..f1006bd 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -137,7 +137,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { }) // Persist user message - if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil); err != nil { + if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil); err != nil { log.Printf("Failed to persist user message: %v", err) } @@ -210,159 +210,11 @@ func (h *CompletionHandler) streamCompletion( req providers.CompletionRequest, channelID, userID, model string, ) { - // Set SSE headers - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") // Disable nginx buffering + result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID) - c.Status(http.StatusOK) - flusher, _ := c.Writer.(http.Flusher) - - flush := func() { - if flusher != nil { - flusher.Flush() - } - } - - // sendSSE sends a standard OpenAI-compatible SSE data line. - sendSSE := func(data string) { - fmt.Fprintf(c.Writer, "data: %s\n\n", data) - flush() - } - - // sendEvent sends a named SSE event (for tool status — non-standard). - sendEvent := func(event, data string) { - fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data) - flush() - } - - var fullContent string - - for iteration := 0; iteration < maxToolIterations; iteration++ { - ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req) - if err != nil { - sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error()))) - return - } - - var iterContent string - var toolCalls []providers.ToolCall - - for event := range ch { - if event.Error != nil { - sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error()))) - return - } - - // Stream text deltas to client - if event.Delta != "" { - iterContent += event.Delta - sendSSE(fmt.Sprintf( - `{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`, - event.Delta, model)) - } - - if event.Done { - if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 { - toolCalls = event.ToolCalls - } else { - // Normal completion — send finish event - finishReason := event.FinishReason - if finishReason == "" { - finishReason = "stop" - } - fullContent += iterContent - sendSSE(fmt.Sprintf( - `{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`, - finishReason, model)) - sendSSE("[DONE]") - - // Persist assistant response - if fullContent != "" { - if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil { - log.Printf("Failed to persist assistant message: %v", err) - } - } - return - } - break - } - } - - // ── Tool execution round ──────────────── - if len(toolCalls) == 0 { - // Stream ended without tool calls or finish — shouldn't happen - sendSSE("[DONE]") - if iterContent != "" { - fullContent += iterContent - if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil { - log.Printf("Failed to persist assistant message: %v", err) - } - } - return - } - - // Notify client about tool calls - toolCallsJSON, _ := json.Marshal(toolCalls) - sendEvent("tool_use", string(toolCallsJSON)) - - // Append assistant message (with tool_calls, possibly with text) to conversation - assistantMsg := providers.Message{ - Role: "assistant", - Content: iterContent, - } - for _, tc := range toolCalls { - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc) - } - req.Messages = append(req.Messages, assistantMsg) - - // Execute all tool calls - execCtx := tools.ExecutionContext{ - UserID: userID, - ChannelID: channelID, - } - for _, tc := range toolCalls { - call := tools.ToolCall{ - ID: tc.ID, - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - } - - log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID) - result := tools.ExecuteCall(c.Request.Context(), execCtx, call) - - // Notify client about tool result - resultJSON, _ := json.Marshal(map[string]interface{}{ - "tool_call_id": result.ToolCallID, - "name": result.Name, - "content": result.Content, - "is_error": result.IsError, - }) - sendEvent("tool_result", string(resultJSON)) - - // Append tool result to conversation for next iteration - req.Messages = append(req.Messages, providers.Message{ - Role: "tool", - Content: result.Content, - ToolCallID: result.ToolCallID, - Name: result.Name, - }) - } - - fullContent += iterContent - // Loop: send updated messages back to provider - } - - // If we hit max iterations, send what we have - log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID) - sendSSE(fmt.Sprintf( - `{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`, - escapeJSON("[Tool execution limit reached]"), model)) - sendSSE("[DONE]") - - if fullContent != "" { - if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil { + // Persist assistant response + if result.Content != "" { + if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, 0, 0, nil, toolActivityJSON(result.ToolActivity)); err != nil { log.Printf("Failed to persist assistant message: %v", err) } } @@ -379,6 +231,7 @@ func (h *CompletionHandler) syncCompletion( ) { var totalInput, totalOutput int var finalContent string + var allToolActivity []map[string]interface{} for iteration := 0; iteration < maxToolIterations; iteration++ { resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req) @@ -395,7 +248,7 @@ func (h *CompletionHandler) syncCompletion( finalContent = resp.Content // Persist assistant response - if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil); err != nil { + if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity)); err != nil { log.Printf("Failed to persist assistant message: %v", err) } @@ -443,6 +296,15 @@ func (h *CompletionHandler) syncCompletion( log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID) result := tools.ExecuteCall(c.Request.Context(), execCtx, call) + // Collect for persistence + allToolActivity = append(allToolActivity, map[string]interface{}{ + "id": tc.ID, + "name": tc.Function.Name, + "arguments": tc.Function.Arguments, + "result": result.Content, + "is_error": result.IsError, + }) + req.Messages = append(req.Messages, providers.Message{ Role: "tool", Content: result.Content, @@ -632,7 +494,16 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm // - parentOverride (explicit parent for edit/regen operations) // - cursor's active_leaf_id (normal conversation flow) // - latest message by created_at (fallback for legacy/missing cursor) -func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string) (string, error) { +// toolActivityJSON marshals tool activity for persistence. Returns nil if empty. +func toolActivityJSON(activity []map[string]interface{}) json.RawMessage { + if len(activity) == 0 { + return nil + } + b, _ := json.Marshal(activity) + return b +} + +func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage) (string, error) { var tokensUsed *int if inputTokens > 0 || outputTokens > 0 { total := inputTokens + outputTokens @@ -658,15 +529,21 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod participantID = model } + // Prepare tool_calls JSONB (nil → NULL) + var tcVal interface{} + if len(toolCallsJSON) > 0 { + tcVal = string(toolCallsJSON) + } + // Insert with RETURNING id var newID string err := database.DB.QueryRow(` INSERT INTO messages (channel_id, role, content, model, tokens_used, - parent_id, participant_type, participant_id, sibling_index) - VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9) + tool_calls, parent_id, participant_type, participant_id, sibling_index) + VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10) RETURNING id `, channelID, role, content, model, tokensUsed, - parentID, participantType, participantID, siblingIdx, + tcVal, parentID, participantType, participantID, siblingIdx, ).Scan(&newID) if err != nil { return "", err diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 90323e9..e6f9171 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -1547,7 +1547,7 @@ func TestUserJourney_FullMatrix(t *testing.T) { } }) - t.Run("admin_models_shows_all_including_disabled", func(t *testing.T) { + t.Run("admin_models_shows_global_only", func(t *testing.T) { w := h.request("GET", "/api/v1/admin/models", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin/models: %d", w.Code) @@ -1555,9 +1555,9 @@ func TestUserJourney_FullMatrix(t *testing.T) { var resp map[string]interface{} decode(w, &resp) allModels := resp["models"].([]interface{}) - // global(3) + team(2) + member-byok(1) + outsider-byok(1) = 7 - if len(allModels) < 7 { - t.Fatalf("admin/models should show all 7 catalog entries, got %d", len(allModels)) + // Admin should see only global-scope models (3), not team(2) or BYOK(2) + if len(allModels) != 3 { + t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels)) } }) } diff --git a/server/handlers/messages.go b/server/handlers/messages.go index f7974d0..497f36b 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -4,7 +4,6 @@ import ( "database/sql" "encoding/json" "fmt" - "io" "log" "math" "net/http" @@ -14,6 +13,7 @@ import ( capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/tools" ) // ── Request / Response types ──────────────── @@ -443,74 +443,43 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { provReq.Temperature = temperature } - // ── Stream the response ── - - ch, err := provider.StreamCompletion(c.Request.Context(), providerCfg, provReq) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()}) - return + // Attach tool definitions (same as normal completion) + if caps.ToolCalling && tools.HasTools() { + provReq.Tools = comp.buildToolDefs() } - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - c.Status(http.StatusOK) + // ── Stream the response (shared loop handles tools, reasoning, SSE) ── - var fullContent string - flusher, _ := c.Writer.(http.Flusher) + result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID) - for event := range ch { - if event.Error != nil { - fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error()) - if flusher != nil { - flusher.Flush() - } - break - } - - if event.Delta != "" { - fullContent += event.Delta - fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n", - event.Delta, model) - if flusher != nil { - flusher.Flush() - } - } - - if event.Done { - finishReason := event.FinishReason - if finishReason == "" { - finishReason = "stop" - } - fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n", - finishReason, model) - io.WriteString(c.Writer, "data: [DONE]\n\n") - if flusher != nil { - flusher.Flush() - } - break - } - } - - // Persist as child or sibling depending on target role - if fullContent != "" { + // Persist as sibling (regen) with tool activity + if result.Content != "" { siblingIdx := nextSiblingIndex(channelID, newParentID) + // Match persistMessage pattern: nil interface{} → SQL NULL, + // non-nil string → valid JSONB. A nil json.RawMessage ([]byte) + // is sent by pq as empty bytes, not NULL, causing "invalid input + // syntax for type json". + var tcVal interface{} + if len(result.ToolActivity) > 0 { + b, _ := json.Marshal(result.ToolActivity) + tcVal = string(b) + } + var newID string err := database.DB.QueryRow(` - INSERT INTO messages (channel_id, role, content, model, + INSERT INTO messages (channel_id, role, content, model, tool_calls, parent_id, participant_type, participant_id, sibling_index) - VALUES ($1, 'assistant', $2, $3, $4, 'model', $5, $6) + VALUES ($1, 'assistant', $2, $3, $4, $5, 'model', $6, $7) RETURNING id - `, channelID, fullContent, model, newParentID, model, siblingIdx).Scan(&newID) + `, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx).Scan(&newID) if err != nil { log.Printf("Failed to persist regenerated message: %v", err) } else { _ = updateCursor(channelID, userID, newID) - // Send message metadata in a final SSE event so frontend can update + flusher, _ := c.Writer.(http.Flusher) msgJSON, _ := json.Marshal(gin.H{ "id": newID, "parent_id": newParentID, diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go new file mode 100644 index 0000000..214b79f --- /dev/null +++ b/server/handlers/stream_loop.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/tools" +) + +// streamResult holds the accumulated output from a streaming completion +// with tool execution. Callers are responsible for persistence. +type streamResult struct { + Content string + ToolActivity []map[string]interface{} +} + +// streamWithToolLoop is the single, canonical streaming implementation. +// It handles SSE setup, multi-round tool execution, reasoning_content +// forwarding, and client notifications. Returns the accumulated content +// and tool activity for the caller to persist however it needs to. +// +// Used by: +// - streamCompletion (normal chat — persists via persistMessage) +// - Regenerate (regen/edit — persists as sibling with tree metadata) +// +// If you add features here (new event types, new tool capabilities, new +// persistence fields), both callers get them automatically. +func streamWithToolLoop( + c *gin.Context, + provider providers.Provider, + cfg providers.ProviderConfig, + req *providers.CompletionRequest, + model, userID, channelID string, +) streamResult { + // Set SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + c.Status(http.StatusOK) + flusher, _ := c.Writer.(http.Flusher) + + flush := func() { + if flusher != nil { + flusher.Flush() + } + } + + sendSSE := func(data string) { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + flush() + } + + sendEvent := func(event, data string) { + fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data) + flush() + } + + var result streamResult + + for iteration := 0; iteration < maxToolIterations; iteration++ { + ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req) + if err != nil { + sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error()))) + return result + } + + var iterContent string + var iterReasoning string + var toolCalls []providers.ToolCall + + for event := range ch { + if event.Error != nil { + sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error()))) + return result + } + + // Stream reasoning deltas to client + if event.Reasoning != "" { + iterReasoning += event.Reasoning + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q}`, + event.Reasoning, model)) + } + + // Stream text deltas to client + if event.Delta != "" { + iterContent += event.Delta + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`, + event.Delta, model)) + } + + if event.Done { + if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 { + toolCalls = event.ToolCalls + } else { + // Normal completion — send finish event + finishReason := event.FinishReason + if finishReason == "" { + finishReason = "stop" + } + if iterReasoning != "" { + result.Content += "" + iterReasoning + "" + } + result.Content += iterContent + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`, + finishReason, model)) + sendSSE("[DONE]") + return result + } + break + } + } + + // ── Tool execution round ──────────────── + if len(toolCalls) == 0 { + // Stream ended without tool calls or finish — shouldn't happen + sendSSE("[DONE]") + if iterReasoning != "" { + result.Content += "" + iterReasoning + "" + } + result.Content += iterContent + return result + } + + // Notify client about tool calls + toolCallsJSON, _ := json.Marshal(toolCalls) + sendEvent("tool_use", string(toolCallsJSON)) + + // Append assistant message (with tool_calls, possibly with text) to conversation + assistantMsg := providers.Message{ + Role: "assistant", + Content: iterContent, + } + for _, tc := range toolCalls { + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc) + } + req.Messages = append(req.Messages, assistantMsg) + + // Execute all tool calls + execCtx := tools.ExecutionContext{ + UserID: userID, + ChannelID: channelID, + } + for _, tc := range toolCalls { + call := tools.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + + log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID) + toolResult := tools.ExecuteCall(c.Request.Context(), execCtx, call) + + // Notify client about tool result + resultJSON, _ := json.Marshal(map[string]interface{}{ + "tool_call_id": toolResult.ToolCallID, + "name": toolResult.Name, + "content": toolResult.Content, + "is_error": toolResult.IsError, + }) + sendEvent("tool_result", string(resultJSON)) + + // Collect for persistence + result.ToolActivity = append(result.ToolActivity, map[string]interface{}{ + "id": tc.ID, + "name": tc.Function.Name, + "arguments": tc.Function.Arguments, + "result": toolResult.Content, + "is_error": toolResult.IsError, + }) + + // Append tool result to conversation for next iteration + req.Messages = append(req.Messages, providers.Message{ + Role: "tool", + Content: toolResult.Content, + ToolCallID: toolResult.ToolCallID, + Name: toolResult.Name, + }) + } + + result.Content += iterContent + // Loop: send updated messages back to provider + } + + // Hit max iterations + log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID) + sendSSE(fmt.Sprintf( + `{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`, + escapeJSON("[Tool execution limit reached]"), model)) + sendSSE("[DONE]") + + return result +} diff --git a/server/handlers/tree.go b/server/handlers/tree.go index f13d322..e7eff39 100644 --- a/server/handlers/tree.go +++ b/server/handlers/tree.go @@ -2,6 +2,7 @@ package handlers import ( "database/sql" + "encoding/json" "fmt" "git.gobha.me/xcaliber/chat-switchboard/database" @@ -12,17 +13,18 @@ import ( // PathMessage represents a single message in an active path, enriched // with sibling metadata for the frontend branch indicator. type PathMessage struct { - ID string `json:"id"` - ParentID *string `json:"parent_id"` - Role string `json:"role"` - Content string `json:"content"` - Model *string `json:"model"` - TokensUsed *int `json:"tokens_used,omitempty"` - SiblingCount int `json:"sibling_count"` - SiblingIndex int `json:"sibling_index"` - ParticipantType string `json:"participant_type,omitempty"` - ParticipantID string `json:"participant_id,omitempty"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ParentID *string `json:"parent_id"` + Role string `json:"role"` + Content string `json:"content"` + Model *string `json:"model"` + TokensUsed *int `json:"tokens_used,omitempty"` + ToolCalls *json.RawMessage `json:"tool_calls,omitempty"` + SiblingCount int `json:"sibling_count"` + SiblingIndex int `json:"sibling_index"` + ParticipantType string `json:"participant_type,omitempty"` + ParticipantID string `json:"participant_id,omitempty"` + CreatedAt string `json:"created_at"` } // SiblingInfo is a lightweight entry for the siblings listing endpoint. @@ -96,7 +98,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, + SELECT id, parent_id, role, content, model, tokens_used, tool_calls, participant_type, participant_id, sibling_index, created_at, 0 AS depth FROM messages @@ -105,14 +107,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, + SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, 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, + SELECT id, parent_id, role, content, model, tokens_used, tool_calls, participant_type, participant_id, sibling_index, created_at FROM path ORDER BY depth DESC @@ -126,12 +128,18 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) { for rows.Next() { var m PathMessage var participantType, participantID sql.NullString + var toolCallsJSON []byte if err := rows.Scan( &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, + &toolCallsJSON, &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, ); err != nil { return nil, fmt.Errorf("getPathToLeaf scan: %w", err) } + if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { + raw := json.RawMessage(toolCallsJSON) + m.ToolCalls = &raw + } if participantType.Valid { m.ParticipantType = participantType.String } diff --git a/server/models/models.go b/server/models/models.go index 0f5ab51..c9369b9 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -249,6 +249,7 @@ var PolicyDefaults = map[string]string{ "allow_registration": "true", "default_user_active": "false", "allow_team_providers": "true", + "default_model": "", } // ========================================= diff --git a/server/providers/openai.go b/server/providers/openai.go index 858299c..69a004b 100644 --- a/server/providers/openai.go +++ b/server/providers/openai.go @@ -134,6 +134,7 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi ev := StreamEvent{ Delta: choice.Delta.Content, + Reasoning: choice.Delta.ReasoningContent, Model: chunk.Model, FinishReason: choice.FinishReason, } @@ -363,8 +364,9 @@ type openaiStreamChunk struct { Model string `json:"model"` Choices []struct { Delta struct { - Content string `json:"content"` - ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` } `json:"delta"` FinishReason string `json:"finish_reason"` } `json:"choices"` diff --git a/server/providers/provider.go b/server/providers/provider.go index c8dc458..f01bf82 100644 --- a/server/providers/provider.go +++ b/server/providers/provider.go @@ -99,6 +99,7 @@ type CompletionResponse struct { // StreamEvent is a single chunk from a streaming response. type StreamEvent struct { Delta string `json:"delta,omitempty"` + Reasoning string `json:"reasoning,omitempty"` Done bool `json:"done,omitempty"` FinishReason string `json:"finish_reason,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 648fa10..a26ee86 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -67,7 +67,8 @@ type CatalogStore interface { ListVisible(ctx context.Context) ([]models.CatalogEntry, error) ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) - ListAll(ctx context.Context) ([]models.CatalogEntry, error) // admin view + ListAll(ctx context.Context) ([]models.CatalogEntry, error) // everything (internal use) + ListAllGlobal(ctx context.Context) ([]models.CatalogEntry, error) // admin view — global-scope providers only // Visibility management SetVisibility(ctx context.Context, id string, visibility string) error diff --git a/server/store/postgres/catalog.go b/server/store/postgres/catalog.go index 5f3c62e..a8e6d9a 100644 --- a/server/store/postgres/catalog.go +++ b/server/store/postgres/catalog.go @@ -134,6 +134,21 @@ func (s *CatalogStore) ListAll(ctx context.Context) ([]models.CatalogEntry, erro return scanCatalogEntries(rows) } +// ListAllGlobal returns all catalog entries whose provider_config has scope='global'. +// Used by admin panel — admin should not see user BYOK or team-level models. +func (s *CatalogStore) ListAllGlobal(ctx context.Context) ([]models.CatalogEntry, error) { + rows, err := DB.QueryContext(ctx, + fmt.Sprintf(`SELECT %s FROM model_catalog mc + JOIN provider_configs pc ON pc.id = mc.provider_config_id + WHERE pc.scope = 'global' + ORDER BY mc.model_id`, catalogColsMC)) + if err != nil { + return nil, err + } + defer rows.Close() + return scanCatalogEntries(rows) +} + func (s *CatalogStore) SetVisibility(ctx context.Context, id string, visibility string) error { _, err := DB.ExecContext(ctx, "UPDATE model_catalog SET visibility = $1 WHERE id = $2", visibility, id) @@ -149,7 +164,10 @@ func (s *CatalogStore) BulkSetVisibility(ctx context.Context, providerConfigID s func (s *CatalogStore) BulkSetVisibilityAll(ctx context.Context, visibility string) error { _, err := DB.ExecContext(ctx, - "UPDATE model_catalog SET visibility = $1", visibility) + `UPDATE model_catalog SET visibility = $1 + WHERE provider_config_id IN ( + SELECT id FROM provider_configs WHERE scope = 'global' + )`, visibility) return err } diff --git a/src/css/styles.css b/src/css/styles.css index 5d952f8..c17f420 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -536,15 +536,35 @@ a:hover { text-decoration: underline; } .msg-text pre code { background: none; padding: 0; font-size: inherit; } /* Code block toolbar */ -.copy-code-btn { - position: absolute; top: 6px; right: 6px; background: var(--bg-raised); - border: 1px solid var(--border); color: var(--text-3); border-radius: 4px; - padding: 2px 10px; font-size: 11px; cursor: pointer; +.code-toolbar { + position: absolute; top: 6px; right: 6px; + display: flex; gap: 4px; align-items: center; opacity: 0; transition: opacity var(--transition); } -.preview-code-btn { right: 56px; } -.msg-text pre:hover .copy-code-btn { opacity: 1; } +.msg-text pre:hover .code-toolbar { opacity: 1; } +.code-block.code-collapsed .code-toolbar { opacity: 1; } +.copy-code-btn { + background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text-3); border-radius: 4px; + padding: 2px 10px; font-size: 11px; cursor: pointer; + transition: color var(--transition), border-color var(--transition); +} .copy-code-btn:hover { color: var(--text); border-color: var(--border-light); } +.code-collapse-btn { + background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text-3); border-radius: 4px; + padding: 2px 10px; font-size: 11px; cursor: pointer; + font-family: var(--mono); white-space: nowrap; + transition: color var(--transition), border-color var(--transition); +} +.code-collapse-btn:hover { color: var(--text); border-color: var(--border-light); } + +/* Collapsed code block */ +.code-block.code-collapsed code { + max-height: 3.6em; overflow: hidden; display: block; + mask-image: linear-gradient(to bottom, #000 40%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 100%); +} /* Language label */ .code-lang { @@ -553,44 +573,83 @@ a:hover { text-decoration: underline; } text-transform: uppercase; letter-spacing: 0.5px; user-select: none; } -/* Collapsible code blocks */ -.code-collapsible { - border: 1px solid var(--border); border-radius: var(--radius); - margin: 0.75rem 0; background: var(--bg-surface); +/* HTML preview */ +/* ── Side Panel (Preview + Notes) ──────── */ + +.side-panel { + width: 0; min-width: 0; overflow: hidden; + background: var(--bg); border-left: 1px solid var(--border); + display: flex; flex-direction: column; + transition: width 0.25s ease, min-width 0.25s ease; } -.code-collapsible pre { - margin: 0; border: none; border-radius: 0 0 var(--radius) var(--radius); +.side-panel.open { + width: 480px; min-width: 480px; } -.code-collapse-summary { - padding: 0.5rem 0.75rem; cursor: pointer; font-size: 12px; - color: var(--text-3); user-select: none; - transition: color var(--transition); - font-family: var(--mono); +.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; } -.code-collapse-summary:hover { color: var(--text-2); } -.code-collapsible[open] .code-collapse-summary { - border-bottom: 1px solid var(--border); +.side-panel-tabs { + display: flex; gap: 2px; background: var(--bg-surface); + border-radius: var(--radius); padding: 2px; +} +.side-panel-tab { + padding: 4px 14px; border-radius: calc(var(--radius) - 2px); + font-size: 12px; font-family: var(--font); font-weight: 500; + background: none; border: none; color: var(--text-3); + cursor: pointer; transition: all var(--transition); +} +.side-panel-tab:hover { color: var(--text); } +.side-panel-tab.active { + background: var(--bg-raised); color: var(--text); + box-shadow: 0 1px 2px rgba(0,0,0,0.2); +} +.side-panel-close { + background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 16px; padding: 2px 6px; + border-radius: var(--radius); transition: all var(--transition); +} +.side-panel-close:hover { background: var(--bg-hover); color: var(--text); } +.side-panel-body { + flex: 1; overflow-y: auto; min-height: 0; +} +.side-panel-page { height: 100%; display: flex; flex-direction: column; } +.side-panel-empty { + display: flex; align-items: center; justify-content: center; + height: 100%; color: var(--text-3); font-size: 13px; + padding: 2rem; text-align: center; +} +.preview-frame { + flex: 1; width: 100%; border: none; background: #fff; } -/* HTML preview */ -.html-preview-wrap { - border: 1px solid var(--accent); border-radius: var(--radius); - margin: 0.5rem 0; overflow: hidden; - animation: fadeIn 0.15s ease; +/* Notes inside side panel */ +.notes-actions-bar { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border-bottom: 1px solid var(--border); } -.html-preview-header { - display: flex; justify-content: space-between; align-items: center; - padding: 4px 10px; background: var(--accent-dim); - font-size: 11px; color: var(--accent); +.side-panel .notes-list { padding: 4px 8px; } +.side-panel .notes-editor { padding: 8px 12px; } +.side-panel .note-read-content { + max-height: none; flex: 1; overflow-y: auto; } -.html-preview-close { - background: none; border: none; color: var(--text-3); - cursor: pointer; padding: 0 4px; font-size: 14px; +.side-panel .notes-content-input { + min-height: 150px; } -.html-preview-close:hover { color: var(--text); } -.html-preview-frame { - width: 100%; height: 300px; border: none; - background: #fff; display: block; +.side-panel #notesListView { + flex: 1; overflow-y: auto; min-height: 0; +} +.side-panel #notesEditorView { + flex: 1; overflow-y: auto; min-height: 0; +} + +/* Mobile: full-width overlay */ +@media (max-width: 768px) { + .side-panel.open { + position: fixed; top: 0; right: 0; bottom: 0; + width: 100vw; min-width: 100vw; z-index: 200; + } } .msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; } @@ -662,6 +721,46 @@ a:hover { text-decoration: underline; } font-size: 11px; color: var(--text-3); margin-left: 6px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tool-note-link { + background: none; border: none; color: var(--accent); cursor: pointer; + font-size: 11px; margin-left: 6px; padding: 0; + text-decoration: none; + transition: color var(--transition); +} +.tool-note-link:hover { color: var(--text); text-decoration: underline; } + +/* Tool calls in message history (collapsed) */ +.tool-call-block { + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg-raised); font-size: 12px; +} +.tool-call-summary { + display: flex; align-items: center; gap: 6px; + padding: 4px 10px; cursor: pointer; user-select: none; + color: var(--text-3); list-style: none; + transition: color var(--transition); +} +.tool-call-summary::-webkit-details-marker { display: none; } +.tool-call-summary::before { + content: '▸'; font-size: 10px; color: var(--text-3); + transition: transform 0.15s ease; +} +.tool-call-block[open] .tool-call-summary::before { transform: rotate(90deg); } +.tool-call-summary:hover { color: var(--text-2); } +.tool-detail { + border-top: 1px solid var(--border); padding: 6px 10px; + display: flex; flex-direction: column; gap: 6px; +} +.tool-detail-label { + font-size: 10px; color: var(--text-3); text-transform: uppercase; + letter-spacing: 0.5px; font-weight: 600; +} +.tool-detail-pre { + margin: 2px 0 0; padding: 6px 8px; + background: var(--bg-surface); border-radius: 4px; + font-size: 11px; color: var(--text-2); white-space: pre-wrap; + word-break: break-all; max-height: 200px; overflow-y: auto; +} /* Empty state */ .empty-state { @@ -1242,12 +1341,12 @@ button { font-family: var(--font); cursor: pointer; } .sidebar.collapsed .sidebar-notes-btn .sb-label { display: none; } .sidebar.collapsed .sb-notes { justify-content: center; padding: 8px; } -/* ── Notes Modal ────────────────────────── */ +/* ── Notes (inside side panel) ──────────── */ -.notes-header-actions { display: flex; align-items: center; gap: 8px; } +/* Notes toolbar and selection bar now live inside the side panel */ .notes-toolbar { - display: flex; gap: 8px; padding: 10px 20px; + display: flex; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg-raised); } @@ -1269,12 +1368,12 @@ button { font-family: var(--font); cursor: pointer; } max-width: 160px; } -.notes-body { padding: 0 !important; } +/* Notes toolbar and selection bar now live inside the side panel */ /* Selection bar */ .notes-selection-bar { display: flex; align-items: center; gap: 10px; - padding: 6px 20px; + padding: 6px 12px; background: var(--accent-dim); border-bottom: 1px solid var(--border); font-size: 12px; } diff --git a/src/index.html b/src/index.html index 47fb1f7..90bf6bb 100644 --- a/src/index.html +++ b/src/index.html @@ -158,6 +158,99 @@ + + + + @@ -270,7 +363,7 @@
- + @@ -534,6 +627,15 @@

When disabled, users can only use admin-created global presets.

+
+

Default Model

+
+ +
+

Model selected by default for new users or when a saved model is no longer available.

+

Environment Banner

@@ -583,85 +685,6 @@ - - + ${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
${formatMessage(msg.content)}
@@ -408,6 +409,7 @@ const UI = { const decoder = new TextDecoder(); let buffer = ''; let content = ''; + let reasoning = ''; let currentEvent = ''; // SSE event type while (true) { @@ -446,11 +448,22 @@ const UI = { continue; } + // Reasoning content delta (thinking blocks) + const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || ''; + if (reasoningDelta) { + reasoning += reasoningDelta; + // Render accumulated reasoning + content together + const full = (reasoning ? '' + reasoning + '' : '') + content; + document.getElementById('streamContent').innerHTML = formatMessage(full); + this._scrollToBottom(); + } + // Normal content delta const delta = parsed.choices?.[0]?.delta?.content || ''; if (delta) { content += delta; - document.getElementById('streamContent').innerHTML = formatMessage(content); + const full = (reasoning ? '' + reasoning + '' : '') + content; + document.getElementById('streamContent').innerHTML = formatMessage(full); this._scrollToBottom(); } currentEvent = ''; @@ -490,13 +503,23 @@ const UI = { // Parse tool result content for a brief summary try { const parsed = JSON.parse(result.content); - const summary = parsed.title || parsed.id || parsed.count != null ? `${parsed.count} results` : ''; + let summary = ''; + if (parsed.title) summary = parsed.title; + else if (parsed.count != null) summary = `${parsed.count} results`; if (summary) { const hint = document.createElement('span'); hint.className = 'tool-hint'; hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary); el.appendChild(hint); } + // Add "View note" link for note tools + if (result.name && result.name.startsWith('note_') && parsed.id) { + const link = document.createElement('button'); + link.className = 'tool-note-link'; + link.textContent = '📝 View note'; + link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); }; + el.appendChild(link); + } } catch (e) { /* not JSON or no useful summary */ } } this._scrollToBottom(); @@ -565,13 +588,25 @@ const UI = { addGroup('Models', models.filter(m => m.source !== 'personal')); addGroup('🔑 My Providers', models.filter(m => m.source === 'personal')); - // Restore selection (supports both composite and bare model IDs) - const match = App.findModel(current); + // Restore selection — resolution chain: localStorage → admin default → first visible + const visibleModels = App.models.filter(m => !m.hidden); + const match = visibleModels.find(m => m.id === current || m.baseModelId === current); if (match) { UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : '')); - } else if (App.models.length > 0) { - const first = App.models[0]; + } else if (App.defaultModel && visibleModels.length > 0) { + // Admin-configured default + const def = visibleModels.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel); + if (def) { + UI.setModelValue(def.id, def.name + (def.provider ? ` (${def.provider})` : '')); + } else { + const first = visibleModels[0]; + UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : '')); + } + } else if (visibleModels.length > 0) { + const first = visibleModels[0]; UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : '')); + } else { + UI.setModelValue('', 'No visible models'); } } @@ -710,6 +745,28 @@ const UI = { setGenerating(on) { document.getElementById('stopBtn').classList.toggle('visible', on); document.getElementById('sendBtn').disabled = on; + + // Swap favicon to animated version during generation + const faviconLink = document.querySelector('link[rel="icon"][type="image/svg+xml"]'); + if (faviconLink) { + if (on) { + // Pulsing favicon: dots fade in/out rapidly + faviconLink._origHref = faviconLink._origHref || faviconLink.href; + const svg = ` + + + + + + + + `; + faviconLink.href = 'data:image/svg+xml,' + svg; + } else if (faviconLink._origHref) { + faviconLink.href = faviconLink._origHref; + } + } + if (on) { const container = document.getElementById('chatMessages'); const currentModel = App.findModel(App.settings.model); @@ -1583,6 +1640,19 @@ const UI = { // User presets / personas (policy: allow_user_personas) document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true'; + // Default model (policy: default_model) — populate from current model list + const defModelSel = document.getElementById('adminDefaultModel'); + if (defModelSel) { + defModelSel.innerHTML = ''; + App.models.filter(m => !m.hidden).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.baseModelId || m.id; + opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''); + defModelSel.appendChild(opt); + }); + defModelSel.value = policies.default_model || ''; + } + // Banner (global_settings) const banner = getSetting('banner', {}) || {}; document.getElementById('adminBannerEnabled').checked = !!banner.enabled; @@ -1765,9 +1835,8 @@ function formatMessage(content) { for (const b of thinkingBlocks) { const inner = esc(b.content).replace(/\n/g, '
'); - const thinkHTML = App.settings.showThinking - ? `
💭 Thinking
${inner}
` - : ''; + const openAttr = App.settings.showThinking ? ' open' : ''; + const thinkHTML = `
💭 Thinking
${inner}
`; html = html.replace(new RegExp(`

\\s*THINK_PLACEHOLDER_${b.id}\\s*

`, 'g'), thinkHTML); html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML); } @@ -1782,7 +1851,7 @@ function _formatMarked(text) { ADD_ATTR: ['id', 'class', 'onclick', 'sandbox', 'srcdoc'] }); - // Process code blocks: add copy button, collapsible wrapper, HTML preview + // Process code blocks: add copy button, collapse toggle, HTML preview html = html.replace(/
]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
         const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
         const langMatch = attrs.match(/class="language-(\w+)"/);
@@ -1791,9 +1860,15 @@ function _formatMarked(text) {
         // Count lines for collapse decision
         const lineCount = (code.match(/\n/g) || []).length + 1;
         const COLLAPSE_THRESHOLD = 15;
+        const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
 
         // Build toolbar buttons
-        let toolbar = ``;
+        const collapseBtn = lineCount > 5
+            ? ``
+            : '';
+
+        let toolbar = collapseBtn;
+        toolbar += ``;
 
         // HTML preview button (only for HTML-like content)
         if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
@@ -1801,13 +1876,8 @@ function _formatMarked(text) {
         }
 
         const langLabel = lang ? `${lang}` : '';
-        const block = `
${langLabel}${code}${toolbar}
`; - - // Wrap in collapsible details if long - if (lineCount > COLLAPSE_THRESHOLD) { - return `
Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines${block}
`; - } - return block; + const collapsedClass = shouldCollapse ? ' code-collapsed' : ''; + return `
${langLabel}${code}
${toolbar}
`; }); return html; @@ -1819,20 +1889,22 @@ function _formatBasic(text) { const id = 'code-' + Math.random().toString(36).slice(2, 9); const lineCount = (code.match(/\n/g) || []).length + 1; const COLLAPSE_THRESHOLD = 15; + const shouldCollapse = lineCount > COLLAPSE_THRESHOLD; - let toolbar = ``; + const collapseBtn = lineCount > 5 + ? `` + : ''; + + let toolbar = collapseBtn; + toolbar += ``; if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) { toolbar += ``; } const langLabel = lang ? `${lang}` : ''; - const block = `
${langLabel}${code.trim()}${toolbar}
`; - - if (lineCount > COLLAPSE_THRESHOLD) { - return `
Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines${block}
`; - } - return block; + const collapsedClass = shouldCollapse ? ' code-collapsed' : ''; + return `
${langLabel}${code.trim()}
${toolbar}
`; }); html = html.replace(/`([^`]+)`/g, '$1'); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); @@ -1854,46 +1926,107 @@ function _looksLikeHTML(code) { return /HTML Preview`; + openSidePanel('preview'); +} - const iframe = document.createElement('iframe'); - iframe.className = 'html-preview-frame'; - iframe.sandbox = 'allow-scripts'; // no allow-same-origin: fully isolated - iframe.srcdoc = rawHTML; +function openSidePanel(tab) { + const panel = document.getElementById('sidePanel'); + panel.classList.add('open'); + if (tab) switchSidePanelTab(tab); +} - wrapper.appendChild(header); - wrapper.appendChild(iframe); +function closeSidePanel() { + document.getElementById('sidePanel').classList.remove('open'); +} - // Insert after the
 (or after 
if collapsible) - const parent = pre.closest('.code-collapsible') || pre; - parent.after(wrapper); +function switchSidePanelTab(tab) { + // Update tab buttons + document.querySelectorAll('.side-panel-tab').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tab); + }); + // Show/hide pages + document.getElementById('sidePanelPreview').style.display = tab === 'preview' ? '' : 'none'; + document.getElementById('sidePanelNotes').style.display = tab === 'notes' ? '' : 'none'; } // ── Helpers ────────────────────────────────── +// Render tool calls from stored message data (history view) +function _renderToolCallsHTML(toolCalls) { + if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) return ''; + + const items = toolCalls.map(tc => { + const name = tc.function?.name || tc.name || 'unknown'; + const args = tc.function?.arguments || tc.arguments || tc.input || ''; + const result = tc.result || ''; + const isError = tc.is_error || false; + const tcId = tc.id || ''; + + const statusCls = isError ? 'tool-error' : 'tool-done'; + const statusText = isError ? 'error' : 'done'; + + // Check for note tool — add view link + let noteLink = ''; + if (name.startsWith('note_') && result) { + try { + const parsed = typeof result === 'string' ? JSON.parse(result) : result; + if (parsed.id) { + noteLink = ``; + } + } catch (e) { /* not JSON */ } + } + + // Build collapsible detail + let detailHTML = ''; + if (args || result) { + const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2); + const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2); + detailHTML = `
`; + if (argsStr) detailHTML += `
Input
${esc(argsStr)}
`; + if (resultStr) detailHTML += `
Output
${esc(resultStr)}
`; + detailHTML += `
`; + } + + return `
+ + 🔧 + ${esc(name)} + ${statusText} + ${noteLink} + + ${detailHTML} +
`; + }); + + return `
${items.join('')}
`; +} + function _relativeTime(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr);