Changeset 0.20.0 (#85)
This commit is contained in:
78
server/handlers/admin_email.go
Normal file
78
server/handlers/admin_email.go
Normal 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})
|
||||
}
|
||||
237
server/handlers/channel_models.go
Normal file
237
server/handlers/channel_models.go
Normal 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})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
281
server/handlers/notifications.go
Normal file
281
server/handlers/notifications.go
Normal 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})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user