package handlers import ( "context" "net/http" "github.com/gin-gonic/gin" "chat-switchboard/models" "chat-switchboard/store" ) // PersonaHandler handles persona 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 } if personas == nil { personas = []models.Persona{} } c.JSON(http.StatusOK, gin.H{"data": 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 } if personas == nil { personas = []models.Persona{} } c.JSON(http.StatusOK, gin.H{"data": 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) } // UpdateTeamPersona updates a team-scoped persona. Verifies the persona // belongs to the team specified in the URL path. func (h *PersonaHandler) UpdateTeamPersona(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } 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"}) } // DeleteTeamPersona deletes a team-scoped persona. Verifies the persona // belongs to the team specified in the URL path before deleting. func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } 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 } if personas == nil { personas = []models.Persona{} } c.JSON(http.StatusOK, gin.H{"data": 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"` Handle string `json:"handle,omitempty"` 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, Handle: r.Handle, 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 } // ResolvePersona loads a persona by ID and returns it if the user has access. // Returns nil if not found, inactive, or not accessible. func ResolvePersona(stores store.Stores, personaID, userID string) *models.Persona { ctx := context.Background() p, err := stores.Personas.GetByID(ctx, personaID) if err != nil || !p.IsActive { return nil } ok, err := stores.Personas.UserCanAccess(ctx, userID, personaID) if err != nil || !ok { return nil } return p } // ── Persona-KB Binding Endpoints (v0.17.0) ────── // GetPersonaKBs returns the knowledge bases bound to a persona. func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) { personaID := c.Param("id") kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"}) return } c.JSON(http.StatusOK, gin.H{"data": kbs}) } // GetTeamPersonaKBs returns KBs bound to a persona, verifying team ownership. func (h *PersonaHandler) GetTeamPersonaKBs(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } personaID := c.Param("id") kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"}) return } c.JSON(http.StatusOK, gin.H{"data": kbs}) } // SetPersonaKBs replaces the knowledge bases bound to a persona. func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) { personaID := c.Param("id") var req struct { KBIDs []string `json:"kb_ids"` AutoSearch map[string]bool `json:"auto_search"` // kb_id → true/false } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // SetTeamPersonaKBs replaces KBs bound to a persona, verifying team ownership. func (h *PersonaHandler) SetTeamPersonaKBs(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } personaID := c.Param("id") var req struct { KBIDs []string `json:"kb_ids"` AutoSearch map[string]bool `json:"auto_search"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // ── Persona Tool Grants (v0.25.0) ─────────── // GetPersonaToolGrants returns the tool names granted to a persona. // Empty list = persona inherits all context-available tools. func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) { personaID := c.Param("id") grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"}) return } if grants == nil { grants = []string{} } c.JSON(http.StatusOK, gin.H{"data": grants}) } // SetPersonaToolGrants replaces the tool grants for a persona. // Sending an empty tool_names array clears all grants (persona inherits all tools). func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) { personaID := c.Param("id") var req struct { ToolNames []string `json:"tool_names"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Convert tool names to Grant objects grants := make([]models.Grant, 0, len(req.ToolNames)) for _, name := range req.ToolNames { grants = append(grants, models.Grant{ PersonaID: personaID, GrantType: models.GrantTypeTool, GrantRef: name, }) } if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // ── User-Scoped Persona Avatar (with ownership check) ───── // UploadUserPersonaAvatar uploads an avatar for a personal persona, // verifying the caller owns it. func (h *PersonaHandler) UploadUserPersonaAvatar(c *gin.Context) { userID := getUserID(c) personaID := c.Param("id") // Verify ownership existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) return } if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"}) return } // Delegate to the shared avatar handler UploadPersonaAvatar(h.stores.Personas, c) } // DeleteUserPersonaAvatar deletes an avatar for a personal persona, // verifying the caller owns it. func (h *PersonaHandler) DeleteUserPersonaAvatar(c *gin.Context) { userID := getUserID(c) personaID := c.Param("id") // Verify ownership existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) return } if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"}) return } // Delegate to the shared avatar handler DeletePersonaAvatar(h.stores.Personas, c) } // ── Team-Scoped Helpers ───────────────────── // requireTeamPersona loads a persona by ID and verifies it belongs to // the team in the URL path. Returns the persona on success or writes // an error response and returns nil. func (h *PersonaHandler) requireTeamPersona(c *gin.Context) *models.Persona { teamID := c.Param("teamId") personaID := c.Param("id") existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) return nil } if existing.Scope != models.ScopeTeam || existing.OwnerID == nil || *existing.OwnerID != teamID { c.JSON(http.StatusForbidden, gin.H{"error": "persona does not belong to this team"}) return nil } return existing } // ── Team-Scoped Persona Tool Grants ───────── // GetTeamPersonaToolGrants returns tool grants for a team persona, // verifying team ownership. func (h *PersonaHandler) GetTeamPersonaToolGrants(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } personaID := c.Param("id") grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"}) return } if grants == nil { grants = []string{} } c.JSON(http.StatusOK, gin.H{"data": grants}) } // SetTeamPersonaToolGrants replaces tool grants for a team persona, // verifying team ownership. func (h *PersonaHandler) SetTeamPersonaToolGrants(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } personaID := c.Param("id") var req struct { ToolNames []string `json:"tool_names"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } grants := make([]models.Grant, 0, len(req.ToolNames)) for _, name := range req.ToolNames { grants = append(grants, models.Grant{ PersonaID: personaID, GrantType: models.GrantTypeTool, GrantRef: name, }) } if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // ── Team-Scoped Persona Avatar ────────────── // UploadTeamPersonaAvatar uploads an avatar for a team persona, // verifying team ownership. func (h *PersonaHandler) UploadTeamPersonaAvatar(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } UploadPersonaAvatar(h.stores.Personas, c) } // DeleteTeamPersonaAvatar deletes an avatar for a team persona, // verifying team ownership. func (h *PersonaHandler) DeleteTeamPersonaAvatar(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } DeletePersonaAvatar(h.stores.Personas, c) }