241 lines
7.3 KiB
Go
241 lines
7.3 KiB
Go
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, gin.H{"models": 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)
|
|
if roster == nil { roster = []models.ChannelModel{} }
|
|
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)
|
|
if roster == nil { roster = []models.ChannelModel{} }
|
|
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)
|
|
if roster == nil { roster = []models.ChannelModel{} }
|
|
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})
|
|
}
|
|
}
|
|
}
|