Changeset 0.20.0 (#85)

This commit is contained in:
2026-03-01 12:40:15 +00:00
parent eb74180611
commit 817062e5fc
57 changed files with 5435 additions and 72 deletions

View File

@@ -0,0 +1,78 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// AdminEmailHandler handles admin SMTP configuration and test endpoints.
type AdminEmailHandler struct {
stores store.Stores
}
// NewAdminEmailHandler creates a new admin email handler.
func NewAdminEmailHandler(s store.Stores) *AdminEmailHandler {
return &AdminEmailHandler{stores: s}
}
// TestEmail sends a test email to the requesting admin's email address.
// POST /api/v1/admin/notifications/test-email
func (h *AdminEmailHandler) TestEmail(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if user.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no email address configured for your account"})
return
}
// Load current SMTP config
cfg, err := notifications.LoadSMTPConfig(h.stores.GlobalConfig, nil)
if err != nil || cfg == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP not configured: " + err.Error()})
return
}
transport := notifications.NewEmailTransport(*cfg)
if transport == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP configuration is incomplete"})
return
}
// Get instance name
instanceName := "Chat Switchboard"
if branding, err := h.stores.GlobalConfig.Get(context.Background(), "branding"); err == nil {
if name, ok := branding["instance_name"].(string); ok && name != "" {
instanceName = name
}
}
subject := "[" + instanceName + "] Test Email"
htmlBody := `<!DOCTYPE html>
<html><body style="font-family: sans-serif; padding: 20px;">
<h2 style="color: #4a8af4;">` + instanceName + `</h2>
<p>This is a test email from your Chat Switchboard instance.</p>
<p>If you received this, your SMTP configuration is working correctly.</p>
</body></html>`
textBody := instanceName + " — Test Email\n\nThis is a test email. Your SMTP configuration is working correctly."
if err := transport.Send(c.Request.Context(), user.Email, subject, htmlBody, textBody); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "send failed: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "sent_to": user.Email})
}

View File

@@ -0,0 +1,237 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Channel Models Handler ──────────────────
// Manages multi-model assignments per channel (v0.20.0 Phase 2).
// Routes:
// GET /channels/:id/models — list
// POST /channels/:id/models — add
// PATCH /channels/:id/models/:modelId — update (display_name, is_default, system_prompt)
// DELETE /channels/:id/models/:modelId — remove
const maxModelsPerChannel = 5
type ChannelModelHandler struct {
stores store.Stores
}
func NewChannelModelHandler(stores store.Stores) *ChannelModelHandler {
return &ChannelModelHandler{stores: stores}
}
// ── List ─────────────────────────────────────
func (h *ChannelModelHandler) List(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
roster, err := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
if roster == nil {
roster = []models.ChannelModel{}
}
c.JSON(http.StatusOK, roster)
}
// ── Add ──────────────────────────────────────
type addChannelModelRequest struct {
ModelID string `json:"model_id" binding:"required"`
ProviderConfigID string `json:"provider_config_id"`
DisplayName string `json:"display_name" binding:"required"`
SystemPrompt string `json:"system_prompt"`
IsDefault bool `json:"is_default"`
}
func (h *ChannelModelHandler) Add(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
var req addChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check max models limit
existing, err := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check existing models"})
return
}
if len(existing) >= maxModelsPerChannel {
c.JSON(http.StatusConflict, gin.H{"error": "maximum models per channel reached"})
return
}
// If this is the first model or marked as default, ensure only one default
isDefault := req.IsDefault || len(existing) == 0
cm := &models.ChannelModel{
ChannelID: channelID,
ModelID: req.ModelID,
ProviderConfigID: req.ProviderConfigID,
DisplayName: req.DisplayName,
SystemPrompt: req.SystemPrompt,
IsDefault: isDefault,
}
// SetModel handles INSERT ON CONFLICT (upsert by channel_id + model_id)
if err := h.stores.Channels.SetModel(c.Request.Context(), cm); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add model"})
return
}
// If this is the new default, clear default on others
if isDefault {
h.clearOtherDefaults(c, channelID, req.ModelID)
}
// Return the updated roster
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
c.JSON(http.StatusCreated, gin.H{"models": roster})
}
// ── Update ───────────────────────────────────
type updateChannelModelRequest struct {
DisplayName *string `json:"display_name"`
SystemPrompt *string `json:"system_prompt"`
IsDefault *bool `json:"is_default"`
}
func (h *ChannelModelHandler) Update(c *gin.Context) {
channelID := c.Param("id")
modelRecordID := c.Param("modelId")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
var req updateChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify model belongs to this channel
cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"})
return
}
if cm.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"})
return
}
fields := make(map[string]interface{})
if req.DisplayName != nil {
fields["display_name"] = *req.DisplayName
}
if req.SystemPrompt != nil {
fields["system_prompt"] = *req.SystemPrompt
}
if req.IsDefault != nil {
fields["is_default"] = *req.IsDefault
if *req.IsDefault {
h.clearOtherDefaults(c, channelID, cm.ModelID)
}
}
if len(fields) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Channels.UpdateModel(c.Request.Context(), modelRecordID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"})
return
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"models": roster})
}
// ── Delete ───────────────────────────────────
func (h *ChannelModelHandler) Delete(c *gin.Context) {
channelID := c.Param("id")
modelRecordID := c.Param("modelId")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
// Verify model belongs to this channel
cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"})
return
}
if cm.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"})
return
}
if err := h.stores.Channels.DeleteModel(c.Request.Context(), modelRecordID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
// If deleted model was default, promote the first remaining model
if cm.IsDefault {
remaining, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if len(remaining) > 0 {
h.stores.Channels.UpdateModel(c.Request.Context(), remaining[0].ID,
map[string]interface{}{"is_default": true})
}
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"models": roster})
}
// ── Helpers ──────────────────────────────────
// clearOtherDefaults sets is_default=false on all models in the channel
// except the one with the given modelID.
func (h *ChannelModelHandler) clearOtherDefaults(c *gin.Context, channelID, keepModelID string) {
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
for _, m := range roster {
if m.ModelID != keepModelID && m.IsDefault {
h.stores.Channels.UpdateModel(c.Request.Context(), m.ID,
map[string]interface{}{"is_default": false})
}
}
}

View File

@@ -18,6 +18,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/mentions"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage"
@@ -218,6 +219,19 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── Multi-model @mention routing (v0.20.0) ──────────
// Check if the message @mentions specific channel models.
// If multiple models are targeted, fan out sequentially.
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if len(roster) > 1 {
parsed := mentions.Parse(req.Content, roster)
targets := mentions.ResolvedModels(parsed)
if len(targets) > 1 {
h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, req)
return
}
}
// Build provider request
provReq := providers.CompletionRequest{
Model: model,
@@ -257,6 +271,122 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── Multi-Model Sequential Stream (v0.20.0) ──
// multiModelStream handles the case where a message @mentions multiple
// channel models. It streams each model's response sequentially within
// a single SSE response, sending model_start/model_end delimiters.
func (h *CompletionHandler) multiModelStream(
c *gin.Context,
targets []models.ChannelModel,
messages []providers.Message,
channelID, userID, personaID, presetSystemPrompt string,
req completionRequest,
) {
// Set SSE headers once for the entire multi-model stream
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()
}
for _, target := range targets {
// Notify client which model is about to respond
startJSON, _ := json.Marshal(map[string]string{
"model_id": target.ModelID,
"display_name": target.DisplayName,
})
sendEvent("model_start", string(startJSON))
// Resolve config for this specific model
targetReq := req
targetReq.Model = target.ModelID
if target.ProviderConfigID != "" {
targetReq.APIConfigID = target.ProviderConfigID
}
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"failed to resolve config for %s: %s"}`, target.DisplayName, escapeJSON(err.Error())))
sendEvent("model_end", string(startJSON))
continue
}
provider, err := providers.Get(providerID)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"provider unavailable for %s"}`, target.DisplayName))
sendEvent("model_end", string(startJSON))
continue
}
// Build per-model messages (inject model-specific system prompt if set)
modelMessages := make([]providers.Message, len(messages))
copy(modelMessages, messages)
if target.SystemPrompt != "" {
// Prepend model-specific system prompt
modelMessages = append([]providers.Message{{
Role: "system",
Content: target.SystemPrompt,
}}, modelMessages...)
}
caps := h.getModelCapabilities(c, model, configID)
provReq := providers.CompletionRequest{
Model: model,
Messages: modelMessages,
}
if targetReq.MaxTokens > 0 {
provReq.MaxTokens = targetReq.MaxTokens
} else {
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if targetReq.Temperature != nil {
provReq.Temperature = targetReq.Temperature
}
if targetReq.TopP != nil {
provReq.TopP = targetReq.TopP
}
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools)
}
// Stream this model's response using the shared streaming core
result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, h.hub)
// Persist assistant message with model attribution
if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
log.Printf("Failed to persist multi-model assistant message: %v", err)
}
}
// Log usage
h.logUsage(c, channelID, userID, configID, providerScope, model,
result.InputTokens, result.OutputTokens,
result.CacheCreationTokens, result.CacheReadTokens)
sendEvent("model_end", string(startJSON))
}
sendSSE("[DONE]")
}
// buildToolDefs converts registered tools to provider-format tool definitions.
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
// Tools whose names appear in disabledTools are excluded.

View File

@@ -8,6 +8,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -202,16 +203,26 @@ func (h *GroupHandler) AddMember(c *gin.Context) {
}
// Verify group exists
if _, err := h.stores.Groups.GetByID(c.Request.Context(), groupID); err == sql.ErrNoRows {
group, err := h.stores.Groups.GetByID(c.Request.Context(), groupID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up group"})
return
}
if err := h.stores.Groups.AddMember(c.Request.Context(), groupID, req.UserID, actorID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
// Notify the added user (v0.20.0)
if svc := notifications.Default(); svc != nil {
notifications.NotifyGroupMemberAdded(svc, req.UserID, groupID, group.Name)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "group.member.add", "group", groupID, map[string]interface{}{
"user_id": req.UserID,
@@ -224,6 +235,9 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) {
groupID := c.Param("id")
userID := c.Param("userId")
// Fetch group name for notification before removal
group, _ := h.stores.Groups.GetByID(c.Request.Context(), groupID)
err := h.stores.Groups.RemoveMember(c.Request.Context(), groupID, userID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
@@ -234,6 +248,11 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) {
return
}
// Notify the removed user (v0.20.0)
if svc := notifications.Default(); svc != nil && group != nil {
notifications.NotifyGroupMemberRemoved(svc, userID, groupID, group.Name)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "group.member.remove", "group", groupID, map[string]interface{}{
"user_id": userID,

View File

@@ -0,0 +1,281 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Handler ─────────────────────────────────
// NotificationHandler handles notification CRUD endpoints.
type NotificationHandler struct {
stores store.Stores
hub *events.Hub
}
// NewNotificationHandler creates a new notification handler.
func NewNotificationHandler(s store.Stores, hub *events.Hub) *NotificationHandler {
return &NotificationHandler{stores: s, hub: hub}
}
// ── GET /api/v1/notifications ───────────────
// Returns paginated notifications for the authenticated user.
// Query params: ?limit=20&offset=0&unread_only=false
func (h *NotificationHandler) List(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
unreadOnly := c.Query("unread_only") == "true"
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
items, total, err := h.stores.Notifications.ListByUser(
c.Request.Context(), userID, limit, offset, unreadOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// ── GET /api/v1/notifications/unread-count ──
func (h *NotificationHandler) UnreadCount(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
count, err := h.stores.Notifications.UnreadCount(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, gin.H{"count": count})
}
// ── PATCH /api/v1/notifications/:id/read ────
func (h *NotificationHandler) MarkRead(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "notification id required"})
return
}
if err := h.stores.Notifications.MarkRead(c.Request.Context(), id, userID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
// Sync badge across tabs
if h.hub != nil {
payload, _ := json.Marshal(map[string]string{"id": id})
h.hub.SendToUser(userID, events.Event{
Label: "notification.read",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── POST /api/v1/notifications/mark-all-read ─
func (h *NotificationHandler) MarkAllRead(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
if err := h.stores.Notifications.MarkAllRead(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
// Sync badge across tabs
if h.hub != nil {
payload, _ := json.Marshal(map[string]string{"action": "mark_all_read"})
h.hub.SendToUser(userID, events.Event{
Label: "notification.read",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── DELETE /api/v1/notifications/:id ────────
func (h *NotificationHandler) Delete(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "notification id required"})
return
}
if err := h.stores.Notifications.Delete(c.Request.Context(), id, userID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Notification Preferences (v0.20.0 Phase 3) ──
// GET /api/v1/notifications/preferences
func (h *NotificationHandler) ListPreferences(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
if h.stores.NotifPrefs == nil {
c.JSON(http.StatusOK, []models.NotificationPreference{})
return
}
prefs, err := h.stores.NotifPrefs.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if prefs == nil {
prefs = []models.NotificationPreference{}
}
c.JSON(http.StatusOK, prefs)
}
// PUT /api/v1/notifications/preferences/:type
func (h *NotificationHandler) SetPreference(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
notifType := c.Param("type")
if notifType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"})
return
}
var req struct {
InApp *bool `json:"in_app"`
Email *bool `json:"email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.stores.NotifPrefs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "preferences not available"})
return
}
// Load existing or create new
existing, _ := h.stores.NotifPrefs.Get(c.Request.Context(), userID, notifType)
pref := &models.NotificationPreference{
UserID: userID,
Type: notifType,
InApp: true, // default
Email: false, // default
}
if existing != nil {
pref = existing
}
if req.InApp != nil {
pref.InApp = *req.InApp
}
if req.Email != nil {
pref.Email = *req.Email
}
if err := h.stores.NotifPrefs.Upsert(c.Request.Context(), pref); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
c.JSON(http.StatusOK, pref)
}
// DELETE /api/v1/notifications/preferences/:type
func (h *NotificationHandler) DeletePreference(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
notifType := c.Param("type")
if notifType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"})
return
}
if h.stores.NotifPrefs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "preferences not available"})
return
}
if err := h.stores.NotifPrefs.Delete(c.Request.Context(), userID, notifType); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -240,6 +240,181 @@ func streamWithToolLoop(
const browserToolTimeout = 30 * time.Second
// streamModelResponse is a variant of streamWithToolLoop used by multi-model
// fan-out (v0.20.0). It streams a single model's response within an already-
// established SSE connection — it does NOT set SSE headers or send [DONE].
//
// SSE deltas include a "model_display" field so the frontend can attribute
// each response to the correct model.
func streamModelResponse(
c *gin.Context,
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, displayName, userID, channelID, personaID string,
hub *events.Hub,
) streamResult {
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
}
if event.Reasoning != "" {
iterReasoning += event.Reasoning
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
event.Reasoning, model, displayName))
}
if event.Delta != "" {
iterContent += event.Delta
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
event.Delta, model, displayName))
}
if event.Done {
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
} else {
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
if iterReasoning != "" {
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q,"model_display":%q}`,
finishReason, model, displayName))
// Do NOT send [DONE] — caller manages that for multi-model
return result
}
break
}
}
// Tool execution (same logic as streamWithToolLoop)
if len(toolCalls) == 0 {
if iterReasoning != "" {
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
return result
}
toolCallsJSON, _ := json.Marshal(toolCalls)
sendEvent("tool_use", string(toolCallsJSON))
assistantMsg := providers.Message{
Role: "assistant",
Content: iterContent,
}
for _, tc := range toolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
}
req.Messages = append(req.Messages, assistantMsg)
execCtx := tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
PersonaID: personaID,
}
for _, tc := range toolCalls {
call := tools.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
}
var toolResult tools.ToolResult
if tools.Get(call.Name) != nil {
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
} else if hub != nil && hub.IsConnected(userID) {
toolResult = executeBrowserTool(hub, userID, call)
} else {
toolResult = tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"unknown tool or browser not connected"}`,
IsError: true,
}
}
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))
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,
})
req.Messages = append(req.Messages, providers.Message{
Role: "tool",
Content: toolResult.Content,
ToolCallID: toolResult.ToolCallID,
Name: toolResult.Name,
})
}
result.Content += iterContent
}
// Hit max iterations
log.Printf("⚠️ Multi-model tool loop hit max iterations (%d) for model %s", maxToolIterations, displayName)
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q,"model_display":%q}`,
escapeJSON("[Tool execution limit reached]"), model, displayName))
return result
}
// executeBrowserTool sends a tool call to the user's browser via WebSocket
// and blocks until a result is returned or the timeout elapses.
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {