package handlers import ( "net/http" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // PersonaHandler handles persona (formerly preset) endpoints. type PersonaHandler struct { stores store.Stores } func NewPersonaHandler(s store.Stores) *PersonaHandler { return &PersonaHandler{stores: s} } // ── User Personas (personal scope) ────────── func (h *PersonaHandler) ListUserPersonas(c *gin.Context) { userID := getUserID(c) personas, err := h.stores.Personas.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"}) return } c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas}) } func (h *PersonaHandler) CreateUserPersona(c *gin.Context) { userID := getUserID(c) // Check policy allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_personas") if !allowed { c.JSON(http.StatusForbidden, gin.H{"error": "custom personas not allowed"}) return } var req personaRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } persona := req.toPersona() persona.Scope = models.ScopePersonal persona.OwnerID = &userID persona.CreatedBy = userID if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"}) return } c.JSON(http.StatusCreated, persona) } func (h *PersonaHandler) UpdateUserPersona(c *gin.Context) { userID := getUserID(c) id := c.Param("id") existing, err := h.stores.Personas.GetByID(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) return } // Users can only edit their own personal personas if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "cannot edit this persona"}) return } var patch models.PersonaPatch if err := c.ShouldBindJSON(&patch); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"}) return } c.JSON(http.StatusOK, gin.H{"message": "persona updated"}) } func (h *PersonaHandler) DeleteUserPersona(c *gin.Context) { userID := getUserID(c) id := c.Param("id") existing, err := h.stores.Personas.GetByID(c.Request.Context(), id) if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete this persona"}) return } if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"}) return } c.JSON(http.StatusOK, gin.H{"message": "persona deleted"}) } // ── Team Personas ─────────────────────────── func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) { teamID := c.Param("teamId") personas, err := h.stores.Personas.ListForTeam(c.Request.Context(), teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team personas"}) return } c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas}) } func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) { userID := getUserID(c) teamID := c.Param("teamId") var req personaRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } persona := req.toPersona() persona.Scope = models.ScopeTeam persona.OwnerID = &teamID persona.CreatedBy = userID if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team persona"}) return } c.JSON(http.StatusCreated, persona) } func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) { id := c.Param("id") if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"}) return } c.JSON(http.StatusOK, gin.H{"message": "persona deleted"}) } // ── Admin Personas (global scope) ─────────── func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) { personas, err := h.stores.Personas.ListGlobal(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"}) return } c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas}) } func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) { userID := getUserID(c) var req personaRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } persona := req.toPersona() persona.Scope = models.ScopeGlobal persona.CreatedBy = userID if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"}) return } c.JSON(http.StatusCreated, persona) } func (h *PersonaHandler) UpdateAdminPersona(c *gin.Context) { id := c.Param("id") var patch models.PersonaPatch if err := c.ShouldBindJSON(&patch); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"}) return } c.JSON(http.StatusOK, gin.H{"message": "persona updated"}) } func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) { id := c.Param("id") if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"}) return } c.JSON(http.StatusOK, gin.H{"message": "persona deleted"}) } // ── Request Types ─────────────────────────── type personaRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description,omitempty"` Icon string `json:"icon,omitempty"` BaseModelID string `json:"base_model_id,omitempty"` ProviderConfigID *string `json:"provider_config_id,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` ThinkingBudget *int `json:"thinking_budget,omitempty"` TopP *float64 `json:"top_p,omitempty"` IsShared bool `json:"is_shared,omitempty"` } func (r *personaRequest) toPersona() *models.Persona { p := &models.Persona{ Name: r.Name, Description: r.Description, Icon: r.Icon, BaseModelID: r.BaseModelID, ProviderConfigID: r.ProviderConfigID, SystemPrompt: r.SystemPrompt, Temperature: r.Temperature, MaxTokens: r.MaxTokens, ThinkingBudget: r.ThinkingBudget, TopP: r.TopP, IsActive: true, IsShared: r.IsShared, } return p } // ResolvePreset loads a persona by ID and returns it if the user has access. // Returns nil if not found, inactive, or not accessible. // Used by completion.go and messages.go for preset unwrapping. func ResolvePreset(presetID, userID string) *models.Persona { var p models.Persona var providerConfigID *string err := database.DB.QueryRow(` SELECT id, name, base_model_id, provider_config_id, system_prompt, temperature, max_tokens, thinking_budget, top_p, scope, owner_id, created_by, is_active FROM personas WHERE id = $1 AND is_active = true AND ( scope = 'global' OR (scope = 'personal' AND created_by = $2) OR (scope = 'personal' AND is_shared = true) OR (scope = 'team' AND owner_id IN ( SELECT team_id FROM team_members WHERE user_id = $2 )) ) `, presetID, userID).Scan( &p.ID, &p.Name, &p.BaseModelID, &providerConfigID, &p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ThinkingBudget, &p.TopP, &p.Scope, &p.OwnerID, &p.CreatedBy, &p.IsActive, ) if err != nil { return nil } p.ProviderConfigID = providerConfigID return &p }