Changeset 0.9.3 (#53)
This commit is contained in:
79
CHANGELOG.md
79
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 `<details>` 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 `<details>` 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 (`<details open>`) 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 `<think>` 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 `<details>` 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
|
||||
|
||||
33
ROADMAP.md
33
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 `<details>` 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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]")
|
||||
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID)
|
||||
|
||||
// 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 {
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
202
server/handlers/stream_loop.go
Normal file
202
server/handlers/stream_loop.go
Normal file
@@ -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 += "<think>" + iterReasoning + "</think>"
|
||||
}
|
||||
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 += "<think>" + iterReasoning + "</think>"
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
@@ -18,6 +19,7 @@ type PathMessage struct {
|
||||
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"`
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ var PolicyDefaults = map[string]string{
|
||||
"allow_registration": "true",
|
||||
"default_user_active": "false",
|
||||
"allow_team_providers": "true",
|
||||
"default_model": "",
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -364,6 +365,7 @@ type openaiStreamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.html-preview-close:hover { color: var(--text); }
|
||||
.html-preview-frame {
|
||||
width: 100%; height: 300px; border: none;
|
||||
background: #fff; display: block;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
183
src/index.html
183
src/index.html
@@ -158,6 +158,99 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Side Panel (preview + notes) -->
|
||||
<aside class="side-panel" id="sidePanel">
|
||||
<div class="side-panel-header">
|
||||
<div class="side-panel-tabs" id="sidePanelTabs">
|
||||
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
|
||||
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
|
||||
</div>
|
||||
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel">✕</button>
|
||||
</div>
|
||||
<div class="side-panel-body">
|
||||
<!-- Preview tab -->
|
||||
<div class="side-panel-page" id="sidePanelPreview">
|
||||
<div class="side-panel-empty" id="previewEmpty">
|
||||
<p>Click <strong>Preview</strong> on any HTML code block to render it here.</p>
|
||||
</div>
|
||||
<iframe id="previewFrame" class="preview-frame" sandbox="allow-scripts" style="display:none"></iframe>
|
||||
</div>
|
||||
<!-- Notes tab -->
|
||||
<div class="side-panel-page" id="sidePanelNotes" style="display:none">
|
||||
<div class="notes-toolbar">
|
||||
<div class="notes-search-wrap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
|
||||
</div>
|
||||
<select id="notesFolderFilter" class="notes-filter-select">
|
||||
<option value="">All folders</option>
|
||||
</select>
|
||||
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
|
||||
<option value="updated_desc">Updated ↓</option>
|
||||
<option value="updated_asc">Updated ↑</option>
|
||||
<option value="created_desc">Created ↓</option>
|
||||
<option value="created_asc">Created ↑</option>
|
||||
<option value="title_asc">Title A–Z</option>
|
||||
<option value="title_desc">Title Z–A</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="notes-actions-bar">
|
||||
<button class="btn-small" id="notesSelectModeBtn">Select</button>
|
||||
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
|
||||
</div>
|
||||
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
|
||||
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
|
||||
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
|
||||
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
|
||||
</div>
|
||||
<!-- List view (default) -->
|
||||
<div id="notesListView">
|
||||
<div id="notesList" class="notes-list">
|
||||
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Editor view (hidden by default) -->
|
||||
<div id="notesEditorView" style="display:none">
|
||||
<div class="notes-editor">
|
||||
<div class="notes-editor-toolbar">
|
||||
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
|
||||
<div class="notes-mode-toggle">
|
||||
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
|
||||
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Read mode: rendered markdown -->
|
||||
<div id="noteReadMode" style="display:none">
|
||||
<h3 class="note-read-title" id="noteReadTitle"></h3>
|
||||
<div class="note-read-meta" id="noteReadMeta"></div>
|
||||
<div class="note-read-content msg-text" id="noteReadContent"></div>
|
||||
<div class="notes-editor-actions">
|
||||
<button class="btn-small" id="noteCopyBtn" onclick="copyNoteContent()">Copy</button>
|
||||
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
|
||||
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit mode: form fields -->
|
||||
<div id="noteEditMode">
|
||||
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
|
||||
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
||||
</div>
|
||||
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
|
||||
<div class="notes-editor-actions">
|
||||
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
|
||||
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
|
||||
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div><!-- .app-body -->
|
||||
|
||||
<!-- Environment Banner (bottom) -->
|
||||
@@ -270,7 +363,7 @@
|
||||
</div>
|
||||
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
|
||||
</div>
|
||||
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Auto-expand thinking blocks</label>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Appearance Tab -->
|
||||
@@ -534,6 +627,15 @@
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal presets</label>
|
||||
<p class="section-hint">When disabled, users can only use admin-created global presets.</p>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>Default Model</h3>
|
||||
<div class="form-group">
|
||||
<select id="adminDefaultModel">
|
||||
<option value="">— None (first visible) —</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="section-hint">Model selected by default for new users or when a saved model is no longer available.</p>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>Environment Banner</h3>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>
|
||||
@@ -583,85 +685,6 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Notes Modal ────────────────────────── -->
|
||||
<div class="modal-overlay" id="notesModal">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h2>Notes</h2>
|
||||
<div class="notes-header-actions">
|
||||
<button class="btn-small" id="notesSelectModeBtn">Select</button>
|
||||
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
|
||||
<button class="modal-close" onclick="closeModal('notesModal')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-toolbar">
|
||||
<div class="notes-search-wrap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
|
||||
</div>
|
||||
<select id="notesFolderFilter" class="notes-filter-select">
|
||||
<option value="">All folders</option>
|
||||
</select>
|
||||
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
|
||||
<option value="updated_desc">Updated ↓</option>
|
||||
<option value="updated_asc">Updated ↑</option>
|
||||
<option value="created_desc">Created ↓</option>
|
||||
<option value="created_asc">Created ↑</option>
|
||||
<option value="title_asc">Title A–Z</option>
|
||||
<option value="title_desc">Title Z–A</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
|
||||
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
|
||||
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
|
||||
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
|
||||
</div>
|
||||
<div class="modal-body notes-body">
|
||||
<!-- List view (default) -->
|
||||
<div id="notesListView">
|
||||
<div id="notesList" class="notes-list">
|
||||
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Editor view (hidden by default) -->
|
||||
<div id="notesEditorView" style="display:none">
|
||||
<div class="notes-editor">
|
||||
<div class="notes-editor-toolbar">
|
||||
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
|
||||
<div class="notes-mode-toggle">
|
||||
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
|
||||
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Read mode: rendered markdown -->
|
||||
<div id="noteReadMode" style="display:none">
|
||||
<h3 class="note-read-title" id="noteReadTitle"></h3>
|
||||
<div class="note-read-meta" id="noteReadMeta"></div>
|
||||
<div class="note-read-content msg-text" id="noteReadContent"></div>
|
||||
<div class="notes-editor-actions">
|
||||
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
|
||||
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit mode: form fields -->
|
||||
<div id="noteEditMode">
|
||||
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
|
||||
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
||||
</div>
|
||||
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
|
||||
<div class="notes-editor-actions">
|
||||
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
|
||||
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
|
||||
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Modal (Ctrl+Shift+L) -->
|
||||
<div class="modal-overlay" id="debugModal">
|
||||
<div class="modal debug-modal">
|
||||
|
||||
@@ -25,7 +25,7 @@ const App = {
|
||||
settings: {
|
||||
model: '',
|
||||
stream: true,
|
||||
showThinking: true,
|
||||
showThinking: false,
|
||||
systemPrompt: '',
|
||||
maxTokens: 0, // 0 = auto from model capabilities
|
||||
temperature: 0.7,
|
||||
@@ -287,6 +287,7 @@ function resolveCapabilities(backendCaps, modelId) {
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const data = await API.listEnabledModels();
|
||||
App.defaultModel = data.default_model || '';
|
||||
// Load user model preferences
|
||||
try {
|
||||
const prefData = await API.getModelPreferences();
|
||||
@@ -335,6 +336,7 @@ async function fetchModels() {
|
||||
});
|
||||
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets, ${App.models.filter(m => m.hidden).length} hidden)`);
|
||||
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
||||
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
}
|
||||
@@ -382,6 +384,7 @@ async function selectChat(chatId) {
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
tool_calls: m.tool_calls || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
@@ -514,6 +517,7 @@ async function reloadActivePath() {
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
tool_calls: m.tool_calls || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
@@ -535,12 +539,23 @@ async function regenerateMessage(messageId) {
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
|
||||
// Truncate display to the parent of the message being regenerated.
|
||||
// This makes the stream appear in the correct position — as a fresh
|
||||
// response to the parent user message, not appended at the bottom.
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (chat) {
|
||||
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
|
||||
if (msgIdx > 0) {
|
||||
const truncated = chat.messages.slice(0, msgIdx);
|
||||
UI.renderMessages(truncated);
|
||||
}
|
||||
}
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, messageId, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
@@ -969,7 +984,8 @@ function initListeners() {
|
||||
});
|
||||
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
UI.toast(`Loaded ${App.models.length} models`, 'success');
|
||||
const visible = App.models.filter(m => !m.hidden).length;
|
||||
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
|
||||
});
|
||||
|
||||
// Settings modal
|
||||
@@ -1385,12 +1401,15 @@ function initListeners() {
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Escape: stop generation → close command palette → close topmost modal
|
||||
// Escape: stop generation → close command palette → close side panel → close topmost modal
|
||||
if (e.key === 'Escape') {
|
||||
if (App.isGenerating) { stopGeneration(); return; }
|
||||
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
|
||||
closeCmdPalette(); return;
|
||||
}
|
||||
if (document.getElementById('sidePanel')?.classList.contains('open')) {
|
||||
closeSidePanel(); return;
|
||||
}
|
||||
const open = [...document.querySelectorAll('.modal-overlay.active')];
|
||||
if (open.length) closeModal(open[open.length - 1].id);
|
||||
return;
|
||||
@@ -1690,6 +1709,10 @@ async function handleSaveAdminSettings() {
|
||||
const userPresets = document.getElementById('adminUserPresetsToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' });
|
||||
|
||||
// Default model → default_model policy
|
||||
const defaultModel = document.getElementById('adminDefaultModel').value;
|
||||
await API.adminUpdateSetting('default_model', { value: defaultModel });
|
||||
|
||||
// Banner → global_settings (JSON value)
|
||||
const banner = {
|
||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||
@@ -2177,7 +2200,7 @@ var _selectedNoteIds = new Set();
|
||||
var _notesSort = 'updated_desc';
|
||||
|
||||
async function openNotes() {
|
||||
openModal('notesModal');
|
||||
openSidePanel('notes');
|
||||
_exitSelectMode();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
@@ -2336,6 +2359,12 @@ function showNotesList() {
|
||||
|
||||
var _currentNote = null; // cached note for read mode
|
||||
|
||||
function copyNoteContent() {
|
||||
if (!_currentNote) return;
|
||||
const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`;
|
||||
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
|
||||
}
|
||||
|
||||
async function openNoteEditor(noteId) {
|
||||
document.getElementById('notesListView').style.display = 'none';
|
||||
document.getElementById('notesEditorView').style.display = '';
|
||||
|
||||
233
src/js/ui.js
233
src/js/ui.js
@@ -325,6 +325,7 @@ const UI = {
|
||||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
||||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 ? '<think>' + reasoning + '</think>' : '') + 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 ? '<think>' + reasoning + '</think>' : '') + 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,14 +588,26 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
// Sync settings modal selector (flat select is fine there)
|
||||
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect x="4" y="4" width="56" height="56" rx="10" fill="%23252220" stroke="%234a4540" stroke-width="1.5"/>
|
||||
<circle cx="22" cy="23" r="8" fill="%23111"/><circle cx="42" cy="23" r="8" fill="%23111"/>
|
||||
<circle cx="42" cy="43" r="8" fill="%23111"/><circle cx="22" cy="43" r="8" fill="%23111"/>
|
||||
<circle cx="22" cy="23" r="5.5" fill="%232D7DD2"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0s"/></circle>
|
||||
<circle cx="42" cy="23" r="5.5" fill="%23E8852E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.3s"/></circle>
|
||||
<circle cx="42" cy="43" r="5.5" fill="%239B59B6"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.6s"/></circle>
|
||||
<circle cx="22" cy="43" r="5.5" fill="%232EAA4E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.9s"/></circle>
|
||||
</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 = '<option value="">— None (first visible) —</option>';
|
||||
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, '<br>');
|
||||
const thinkHTML = App.settings.showThinking
|
||||
? `<details class="thinking-block"><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`
|
||||
: '';
|
||||
const openAttr = App.settings.showThinking ? ' open' : '';
|
||||
const thinkHTML = `<details class="thinking-block"${openAttr}><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`;
|
||||
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, '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(/<pre><code([^>]*)>([\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 = `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
const collapseBtn = lineCount > 5
|
||||
? `<button class="code-collapse-btn" onclick="toggleCodeCollapse('${codeId}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||||
: '';
|
||||
|
||||
let toolbar = collapseBtn;
|
||||
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
|
||||
// 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 ? `<span class="code-lang">${lang}</span>` : '';
|
||||
const block = `<pre class="code-block">${langLabel}<code id="${codeId}"${attrs}>${code}</code>${toolbar}</pre>`;
|
||||
|
||||
// Wrap in collapsible details if long
|
||||
if (lineCount > COLLAPSE_THRESHOLD) {
|
||||
return `<details class="code-collapsible"><summary class="code-collapse-summary">Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines</summary>${block}</details>`;
|
||||
}
|
||||
return block;
|
||||
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
|
||||
return `<pre class="code-block${collapsedClass}" id="block-${codeId}">${langLabel}<code id="${codeId}"${attrs}>${code}</code><div class="code-toolbar">${toolbar}</div></pre>`;
|
||||
});
|
||||
|
||||
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 = `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
const collapseBtn = lineCount > 5
|
||||
? `<button class="code-collapse-btn" onclick="toggleCodeCollapse('${id}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||||
: '';
|
||||
|
||||
let toolbar = collapseBtn;
|
||||
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
|
||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${id}')">Preview</button>`;
|
||||
}
|
||||
|
||||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||||
const block = `<pre class="code-block">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code>${toolbar}</pre>`;
|
||||
|
||||
if (lineCount > COLLAPSE_THRESHOLD) {
|
||||
return `<details class="code-collapsible"><summary class="code-collapse-summary">Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines</summary>${block}</details>`;
|
||||
}
|
||||
return block;
|
||||
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
|
||||
return `<pre class="code-block${collapsedClass}" id="block-${id}">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code><div class="code-toolbar">${toolbar}</div></pre>`;
|
||||
});
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
@@ -1854,46 +1926,107 @@ function _looksLikeHTML(code) {
|
||||
return /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
|
||||
}
|
||||
|
||||
// Toggle sandboxed HTML preview below a code block
|
||||
function toggleHTMLPreview(codeId) {
|
||||
const existing = document.getElementById('preview-' + codeId);
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
return;
|
||||
// Toggle code block collapse/expand
|
||||
function toggleCodeCollapse(codeId) {
|
||||
const pre = document.getElementById('block-' + codeId);
|
||||
if (!pre) return;
|
||||
const isCollapsed = pre.classList.toggle('code-collapsed');
|
||||
const btn = pre.querySelector('.code-collapse-btn');
|
||||
if (btn) {
|
||||
const lines = btn.textContent.replace(/[▸▾]\s*/, '');
|
||||
btn.textContent = (isCollapsed ? '▸ ' : '▾ ') + lines;
|
||||
btn.title = isCollapsed ? 'Expand' : 'Collapse';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle sandboxed HTML preview below a code block
|
||||
function toggleHTMLPreview(codeId) {
|
||||
const codeEl = document.getElementById(codeId);
|
||||
if (!codeEl) return;
|
||||
|
||||
const pre = codeEl.closest('pre');
|
||||
if (!pre) return;
|
||||
|
||||
// Decode HTML entities back to real HTML for the preview
|
||||
const rawHTML = codeEl.textContent;
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'preview-' + codeId;
|
||||
wrapper.className = 'html-preview-wrap';
|
||||
frame.srcdoc = rawHTML;
|
||||
frame.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'html-preview-header';
|
||||
header.innerHTML = `<span>HTML Preview</span><button class="html-preview-close" onclick="document.getElementById('preview-${codeId}').remove()">✕</button>`;
|
||||
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 <pre> (or after <details> 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 = `<button class="tool-note-link" onclick="openNotes(); setTimeout(() => openNoteEditor('${esc(parsed.id)}'), 300)">📝 View</button>`;
|
||||
}
|
||||
} 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 = `<div class="tool-detail">`;
|
||||
if (argsStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Input</span><pre class="tool-detail-pre">${esc(argsStr)}</pre></div>`;
|
||||
if (resultStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Output</span><pre class="tool-detail-pre">${esc(resultStr)}</pre></div>`;
|
||||
detailHTML += `</div>`;
|
||||
}
|
||||
|
||||
return `<details class="tool-call-block">
|
||||
<summary class="tool-call-summary">
|
||||
<span class="tool-icon">🔧</span>
|
||||
<span class="tool-name">${esc(name)}</span>
|
||||
<span class="tool-status ${statusCls}">${statusText}</span>
|
||||
${noteLink}
|
||||
</summary>
|
||||
${detailHTML}
|
||||
</details>`;
|
||||
});
|
||||
|
||||
return `<div class="msg-tools">${items.join('')}</div>`;
|
||||
}
|
||||
|
||||
function _relativeTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
|
||||
Reference in New Issue
Block a user