Changeset 0.22.7 (#149)
This commit is contained in:
@@ -164,10 +164,10 @@ func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preset Avatar Upload ────────────────────
|
||||
// POST /api/v1/presets/:id/avatar (user) or /api/v1/admin/presets/:id/avatar (admin)
|
||||
func UploadPresetAvatar(c *gin.Context) {
|
||||
presetID := c.Param("id")
|
||||
// ── Persona Avatar Upload ────────────────────
|
||||
// POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin)
|
||||
func UploadPersonaAvatar(c *gin.Context) {
|
||||
personaID := c.Param("id")
|
||||
|
||||
var req uploadAvatarRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -208,7 +208,7 @@ func UploadPresetAvatar(c *gin.Context) {
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
database.Q(`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`),
|
||||
dataURI, presetID,
|
||||
dataURI, personaID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
|
||||
@@ -216,20 +216,20 @@ func UploadPresetAvatar(c *gin.Context) {
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
|
||||
}
|
||||
|
||||
// DeletePresetAvatar clears a preset's avatar.
|
||||
func DeletePresetAvatar(c *gin.Context) {
|
||||
presetID := c.Param("id")
|
||||
// DeletePersonaAvatar clears a persona's avatar.
|
||||
func DeletePersonaAvatar(c *gin.Context) {
|
||||
personaID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
database.Q(`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`),
|
||||
presetID,
|
||||
personaID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
|
||||
@@ -237,7 +237,7 @@ func DeletePresetAvatar(c *gin.Context) {
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ type createChannelRequest struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
@@ -33,7 +33,7 @@ type updateChannelRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
IsArchived *bool `json:"is_archived,omitempty"`
|
||||
IsPinned *bool `json:"is_pinned,omitempty"`
|
||||
Folder *string `json:"folder,omitempty"`
|
||||
@@ -49,7 +49,7 @@ type channelResponse struct {
|
||||
Type string `json:"type"`
|
||||
Description *string `json:"description"`
|
||||
Model *string `json:"model"`
|
||||
APIConfigID *string `json:"provider_config_id"`
|
||||
ProviderConfigID *string `json:"provider_config_id"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
@@ -231,7 +231,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := rows.Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
@@ -288,7 +288,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
system_prompt, provider_config_id, folder, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, userID, req.Title, channelType, req.Description, req.Model,
|
||||
req.SystemPrompt, req.APIConfigID, req.Folder, writeTagsArg(req.Tags),
|
||||
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||
@@ -301,7 +301,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
tags, settings,
|
||||
created_at, updated_at
|
||||
FROM channels WHERE id = ?`, id).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
@@ -315,10 +315,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
pq.Array(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
@@ -356,13 +356,13 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, store.NewID(), ch.ID, req.Model, req.APIConfigID)
|
||||
`, store.NewID(), ch.ID, req.Model, req.ProviderConfigID)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, req.Model, req.APIConfigID)
|
||||
`, ch.ID, req.Model, req.ProviderConfigID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
) mc ON mc.channel_id = c.id
|
||||
WHERE c.id = $1 AND c.user_id = $2
|
||||
`), channelID, userID).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
@@ -462,8 +462,8 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
if req.SystemPrompt != nil {
|
||||
addClause("system_prompt", *req.SystemPrompt)
|
||||
}
|
||||
if req.APIConfigID != nil {
|
||||
addClause("provider_config_id", *req.APIConfigID)
|
||||
if req.ProviderConfigID != nil {
|
||||
addClause("provider_config_id", *req.ProviderConfigID)
|
||||
}
|
||||
if req.IsArchived != nil {
|
||||
addClause("is_archived", *req.IsArchived)
|
||||
|
||||
@@ -32,12 +32,11 @@ import (
|
||||
// ── Request Types ───────────────────────────
|
||||
|
||||
type completionRequest struct {
|
||||
ChannelID string `json:"channel_id"` // preferred; validated manually below
|
||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
||||
Content string `json:"content" binding:"required"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
@@ -194,11 +193,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Support chat_id as alias during frontend transition
|
||||
channelID := req.ChannelID
|
||||
if channelID == "" {
|
||||
channelID = req.ChatID
|
||||
}
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
|
||||
return
|
||||
@@ -211,60 +206,60 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── Preset unwrap: preset overrides defaults, explicit request fields win ──
|
||||
var presetSystemPrompt string
|
||||
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
|
||||
var personaSystemPrompt string
|
||||
var personaID string // tracks active persona for KB scoping
|
||||
var presetThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
||||
if req.PresetID != "" {
|
||||
preset := ResolvePreset(h.stores, req.PresetID, userID)
|
||||
if preset == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found or not accessible"})
|
||||
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
||||
if req.PersonaID != "" {
|
||||
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
||||
if persona == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found or not accessible"})
|
||||
return
|
||||
}
|
||||
personaID = preset.ID
|
||||
// Preset provides defaults; explicit request fields take priority
|
||||
personaID = persona.ID
|
||||
// Persona provides defaults; explicit request fields take priority
|
||||
if req.Model == "" {
|
||||
req.Model = preset.BaseModelID
|
||||
req.Model = persona.BaseModelID
|
||||
}
|
||||
if req.APIConfigID == "" && preset.ProviderConfigID != nil {
|
||||
req.APIConfigID = *preset.ProviderConfigID
|
||||
if req.ProviderConfigID == "" && persona.ProviderConfigID != nil {
|
||||
req.ProviderConfigID = *persona.ProviderConfigID
|
||||
}
|
||||
if req.Temperature == nil && preset.Temperature != nil {
|
||||
req.Temperature = preset.Temperature
|
||||
if req.Temperature == nil && persona.Temperature != nil {
|
||||
req.Temperature = persona.Temperature
|
||||
}
|
||||
if req.MaxTokens == 0 && preset.MaxTokens != nil {
|
||||
req.MaxTokens = *preset.MaxTokens
|
||||
if req.MaxTokens == 0 && persona.MaxTokens != nil {
|
||||
req.MaxTokens = *persona.MaxTokens
|
||||
}
|
||||
if preset.SystemPrompt != "" {
|
||||
presetSystemPrompt = preset.SystemPrompt
|
||||
if persona.SystemPrompt != "" {
|
||||
personaSystemPrompt = persona.SystemPrompt
|
||||
}
|
||||
presetThinkingBudget = preset.ThinkingBudget
|
||||
personaThinkingBudget = persona.ThinkingBudget
|
||||
}
|
||||
|
||||
// ── Project persona fallback (v0.19.2): if no explicit preset, check project ──
|
||||
if req.PresetID == "" && h.stores.Projects != nil {
|
||||
// ── Project persona fallback (v0.19.2): if no explicit persona, check project ──
|
||||
if req.PersonaID == "" && h.stores.Projects != nil {
|
||||
projID, _ := h.stores.Projects.GetProjectIDForChannel(context.Background(), channelID)
|
||||
if projID != "" {
|
||||
if proj, err := h.stores.Projects.GetByID(context.Background(), projID); err == nil {
|
||||
if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" {
|
||||
if preset := ResolvePreset(h.stores, pid, userID); preset != nil {
|
||||
personaID = preset.ID
|
||||
if persona := ResolvePersona(h.stores, pid, userID); persona != nil {
|
||||
personaID = persona.ID
|
||||
if req.Model == "" {
|
||||
req.Model = preset.BaseModelID
|
||||
req.Model = persona.BaseModelID
|
||||
}
|
||||
if req.APIConfigID == "" && preset.ProviderConfigID != nil {
|
||||
req.APIConfigID = *preset.ProviderConfigID
|
||||
if req.ProviderConfigID == "" && persona.ProviderConfigID != nil {
|
||||
req.ProviderConfigID = *persona.ProviderConfigID
|
||||
}
|
||||
if req.Temperature == nil && preset.Temperature != nil {
|
||||
req.Temperature = preset.Temperature
|
||||
if req.Temperature == nil && persona.Temperature != nil {
|
||||
req.Temperature = persona.Temperature
|
||||
}
|
||||
if req.MaxTokens == 0 && preset.MaxTokens != nil {
|
||||
req.MaxTokens = *preset.MaxTokens
|
||||
if req.MaxTokens == 0 && persona.MaxTokens != nil {
|
||||
req.MaxTokens = *persona.MaxTokens
|
||||
}
|
||||
if preset.SystemPrompt != "" {
|
||||
presetSystemPrompt = preset.SystemPrompt
|
||||
if persona.SystemPrompt != "" {
|
||||
personaSystemPrompt = persona.SystemPrompt
|
||||
}
|
||||
presetThinkingBudget = preset.ThinkingBudget
|
||||
personaThinkingBudget = persona.ThinkingBudget
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +282,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
routingDecision = dec
|
||||
if winConfigID != configID {
|
||||
// Routing selected a different provider — reload its credentials
|
||||
req.APIConfigID = winConfigID
|
||||
req.ProviderConfigID = winConfigID
|
||||
providerCfg2, providerID2, model2, configID2, providerScope2, err := h.resolveConfig(userID, channelID, req)
|
||||
if err == nil {
|
||||
providerCfg = providerCfg2
|
||||
@@ -307,12 +302,12 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// ── Inject persona-level thinking budget into provider settings (v0.22.1) ──
|
||||
// When a persona specifies a thinking budget, promote it into provider
|
||||
// settings so hooks can activate extended thinking automatically.
|
||||
if presetThinkingBudget != nil && *presetThinkingBudget > 0 {
|
||||
if personaThinkingBudget != nil && *personaThinkingBudget > 0 {
|
||||
if providerCfg.Settings == nil {
|
||||
providerCfg.Settings = make(map[string]interface{})
|
||||
}
|
||||
providerCfg.Settings["extended_thinking"] = true
|
||||
providerCfg.Settings["thinking_budget"] = *presetThinkingBudget
|
||||
providerCfg.Settings["thinking_budget"] = *personaThinkingBudget
|
||||
}
|
||||
|
||||
// ── Team policy: require_private_providers ──
|
||||
@@ -337,7 +332,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Load conversation history
|
||||
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt, personaID)
|
||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
@@ -396,7 +391,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
parsed := mentions.Parse(req.Content, roster)
|
||||
targets := mentions.ResolvedModels(parsed)
|
||||
if len(targets) > 1 {
|
||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, workspaceID, req)
|
||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -449,7 +444,7 @@ func (h *CompletionHandler) multiModelStream(
|
||||
c *gin.Context,
|
||||
targets []models.ChannelModel,
|
||||
messages []providers.Message,
|
||||
channelID, userID, personaID, presetSystemPrompt, workspaceID string,
|
||||
channelID, userID, personaID, personaSystemPrompt, workspaceID string,
|
||||
req completionRequest,
|
||||
) {
|
||||
// Set SSE headers once for the entire multi-model stream
|
||||
@@ -485,7 +480,7 @@ func (h *CompletionHandler) multiModelStream(
|
||||
targetReq := req
|
||||
targetReq.Model = target.ModelID
|
||||
if target.ProviderConfigID != "" {
|
||||
targetReq.APIConfigID = target.ProviderConfigID
|
||||
targetReq.ProviderConfigID = target.ProviderConfigID
|
||||
}
|
||||
|
||||
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq)
|
||||
@@ -998,8 +993,8 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
var configID string
|
||||
|
||||
// 1. Explicit config from request
|
||||
if req.APIConfigID != "" {
|
||||
configID = req.APIConfigID
|
||||
if req.ProviderConfigID != "" {
|
||||
configID = req.ProviderConfigID
|
||||
}
|
||||
|
||||
// 2. Config from channel
|
||||
@@ -1115,7 +1110,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
//
|
||||
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
||||
// messages before it are replaced by the summary content as a system message.
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt, personaID string) ([]providers.Message, error) {
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID string) ([]providers.Message, error) {
|
||||
messages := make([]providers.Message, 0)
|
||||
|
||||
// ── Admin system prompt (always injected first, no opt out) ──
|
||||
@@ -1129,17 +1124,17 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
||||
}
|
||||
}
|
||||
|
||||
// ── User/preset system prompt (appended after admin prompt) ──
|
||||
// ── User/persona system prompt (appended after admin prompt) ──
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(
|
||||
database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&systemPrompt)
|
||||
|
||||
// Preset system prompt takes priority; channel system prompt is fallback
|
||||
if presetSystemPrompt != "" {
|
||||
// Persona system prompt takes priority; channel system prompt is fallback
|
||||
if personaSystemPrompt != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: presetSystemPrompt,
|
||||
Content: personaSystemPrompt,
|
||||
})
|
||||
} else if systemPrompt != nil && *systemPrompt != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
|
||||
@@ -200,12 +200,12 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
settings := NewSettingsHandler(nil)
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
|
||||
// Presets
|
||||
presets := NewPersonaHandler(stores)
|
||||
protected.GET("/presets", presets.ListUserPersonas)
|
||||
protected.POST("/presets", presets.CreateUserPersona)
|
||||
protected.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||
// Personas
|
||||
personas := NewPersonaHandler(stores)
|
||||
protected.GET("/personas", personas.ListUserPersonas)
|
||||
protected.POST("/personas", personas.CreateUserPersona)
|
||||
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Notes
|
||||
notes := NewNoteHandler(stores)
|
||||
@@ -283,10 +283,10 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.GET("/teams/:id", teams.GetTeam)
|
||||
admin.GET("/teams/:id/members", teams.ListMembers)
|
||||
admin.POST("/teams/:id/members", teams.AddMember)
|
||||
admin.GET("/presets", presets.ListAdminPersonas)
|
||||
admin.POST("/presets", presets.CreateAdminPersona)
|
||||
admin.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||
admin.GET("/personas", personas.ListAdminPersonas)
|
||||
admin.POST("/personas", personas.CreateAdminPersona)
|
||||
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Admin groups (v0.16.0)
|
||||
groupAdm := NewGroupHandler(stores)
|
||||
@@ -802,14 +802,14 @@ func TestIntegration_TeamMemberManagement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Presets (Policy Gated) ────────────────
|
||||
// ── 7. Personas (Policy Gated) ───────────────
|
||||
|
||||
func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
|
||||
func TestIntegration_PersonaCreation_PolicyGated(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
_ = adminID
|
||||
|
||||
// Create a provider config (presets need a valid model reference)
|
||||
// Create a provider config (personas need a valid model reference)
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "TestProvider", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
|
||||
@@ -821,13 +821,13 @@ func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
|
||||
// Ensure allow_user_personas = false
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'")
|
||||
|
||||
// Try to create a preset as admin (role=admin but policy says no)
|
||||
w = h.request("POST", "/api/v1/presets", adminToken, map[string]interface{}{
|
||||
"name": "My Preset", "base_model_id": "gpt-4o",
|
||||
// Try to create a persona as admin (role=admin but policy says no)
|
||||
w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
|
||||
"name": "My Persona", "base_model_id": "gpt-4o",
|
||||
"provider_config_id": configID, "system_prompt": "You are helpful",
|
||||
})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("preset create with policy=false: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("persona create with policy=false: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Enable the policy
|
||||
@@ -835,18 +835,18 @@ func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
|
||||
map[string]interface{}{"value": "true"})
|
||||
|
||||
// Now creation should succeed
|
||||
w = h.request("POST", "/api/v1/presets", adminToken, map[string]interface{}{
|
||||
"name": "My Preset", "base_model_id": "gpt-4o",
|
||||
w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
|
||||
"name": "My Persona", "base_model_id": "gpt-4o",
|
||||
"provider_config_id": configID, "system_prompt": "You are helpful",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("preset create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("persona create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List presets
|
||||
w = h.request("GET", "/api/v1/presets", adminToken, nil)
|
||||
// List personas
|
||||
w = h.request("GET", "/api/v1/personas", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,7 +1105,7 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
|
||||
t.Error("MISSING: model_id — required for composite ID")
|
||||
}
|
||||
|
||||
// source required for preset detection
|
||||
// source required for persona detection
|
||||
if m["source"] == nil || m["source"] == "" {
|
||||
t.Error("MISSING: source — frontend uses this to distinguish catalog vs persona")
|
||||
}
|
||||
@@ -2748,7 +2748,7 @@ func TestResourceGrants(t *testing.T) {
|
||||
decode(w, &group)
|
||||
|
||||
// Create a persona (admin)
|
||||
w = h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
|
||||
w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
|
||||
"name": "Test Bot",
|
||||
"base_model_id": "test-model",
|
||||
"scope": "global",
|
||||
@@ -2840,11 +2840,11 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── User should NOT see team persona ──
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
var presetsResp map[string]interface{}
|
||||
decode(w, &presetsResp)
|
||||
presets, _ := presetsResp["presets"].([]interface{})
|
||||
for _, p := range presets {
|
||||
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
||||
var personasResp map[string]interface{}
|
||||
decode(w, &personasResp)
|
||||
personas, _ := personasResp["personas"].([]interface{})
|
||||
for _, p := range personas {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
t.Fatalf("user should NOT see team persona without group access")
|
||||
@@ -2872,14 +2872,14 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── User should NOW see team persona via group grant ──
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
decode(w, &presetsResp)
|
||||
presets, _ = presetsResp["presets"].([]interface{})
|
||||
decode(w, &personasResp)
|
||||
personas, _ = personasResp["personas"].([]interface{})
|
||||
found := false
|
||||
for _, p := range presets {
|
||||
for _, p := range personas {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
found = true
|
||||
@@ -2887,16 +2887,16 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("user should see team persona after group grant, but didn't find it in %d presets (response: %s)", len(presets), w.Body.String())
|
||||
t.Fatalf("user should see team persona after group grant, but didn't find it in %d personas (response: %s)", len(personas), w.Body.String())
|
||||
}
|
||||
|
||||
// ── Remove user from group → should lose access ──
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
|
||||
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
decode(w, &presetsResp)
|
||||
presets, _ = presetsResp["presets"].([]interface{})
|
||||
for _, p := range presets {
|
||||
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
||||
decode(w, &personasResp)
|
||||
personas, _ = personasResp["personas"].([]interface{})
|
||||
for _, p := range personas {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
t.Fatalf("user should NOT see persona after group removal")
|
||||
@@ -2915,7 +2915,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// 1. Create a global persona via admin API
|
||||
w := h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
|
||||
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
|
||||
"name": "KB Persona",
|
||||
"base_model_id": "test-model",
|
||||
"system_prompt": "You are a test persona.",
|
||||
@@ -2941,7 +2941,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
|
||||
// 3. Bind KB to persona
|
||||
w = h.request("PUT",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
|
||||
adminToken, map[string]interface{}{
|
||||
"kb_ids": []string{kbID},
|
||||
"auto_search": map[string]bool{kbID: true},
|
||||
@@ -2952,7 +2952,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
|
||||
// 4. Read back bindings
|
||||
w = h.request("GET",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
|
||||
adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -2973,7 +2973,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
|
||||
// 5. Unbind — send empty list
|
||||
w = h.request("PUT",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
|
||||
adminToken, map[string]interface{}{
|
||||
"kb_ids": []string{},
|
||||
})
|
||||
@@ -2983,7 +2983,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
|
||||
// 6. Verify empty
|
||||
w = h.request("GET",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
|
||||
adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
|
||||
@@ -47,8 +47,8 @@ type editRequest struct {
|
||||
|
||||
type regenerateRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
DisabledTools []string `json:"disabled_tools,omitempty"`
|
||||
@@ -417,34 +417,34 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil)
|
||||
|
||||
var presetSystemPrompt string
|
||||
var personaSystemPrompt string
|
||||
var personaID string
|
||||
model := req.Model
|
||||
apiConfigID := req.APIConfigID
|
||||
providerConfigID := req.ProviderConfigID
|
||||
temperature := req.Temperature
|
||||
maxTokens := req.MaxTokens
|
||||
|
||||
if req.PresetID != "" {
|
||||
preset := ResolvePreset(h.stores, req.PresetID, userID)
|
||||
if preset == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
|
||||
if req.PersonaID != "" {
|
||||
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
||||
if persona == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found"})
|
||||
return
|
||||
}
|
||||
personaID = preset.ID
|
||||
personaID = persona.ID
|
||||
if model == "" {
|
||||
model = preset.BaseModelID
|
||||
model = persona.BaseModelID
|
||||
}
|
||||
if apiConfigID == "" && preset.ProviderConfigID != nil {
|
||||
apiConfigID = *preset.ProviderConfigID
|
||||
if providerConfigID == "" && persona.ProviderConfigID != nil {
|
||||
providerConfigID = *persona.ProviderConfigID
|
||||
}
|
||||
if temperature == nil && preset.Temperature != nil {
|
||||
temperature = preset.Temperature
|
||||
if temperature == nil && persona.Temperature != nil {
|
||||
temperature = persona.Temperature
|
||||
}
|
||||
if maxTokens == 0 && preset.MaxTokens != nil {
|
||||
maxTokens = *preset.MaxTokens
|
||||
if maxTokens == 0 && persona.MaxTokens != nil {
|
||||
maxTokens = *persona.MaxTokens
|
||||
}
|
||||
if preset.SystemPrompt != "" {
|
||||
presetSystemPrompt = preset.SystemPrompt
|
||||
if persona.SystemPrompt != "" {
|
||||
personaSystemPrompt = persona.SystemPrompt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
|
||||
Model: model,
|
||||
APIConfigID: apiConfigID,
|
||||
ProviderConfigID: providerConfigID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -475,8 +475,8 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
// Build LLM message array
|
||||
llmMessages := make([]providers.Message, 0, len(contextPath)+1)
|
||||
|
||||
if presetSystemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: presetSystemPrompt})
|
||||
if personaSystemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
|
||||
} else {
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID).Scan(&systemPrompt)
|
||||
|
||||
@@ -30,7 +30,7 @@ func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
|
||||
@@ -121,7 +121,7 @@ func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
|
||||
@@ -167,7 +167,7 @@ func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
|
||||
@@ -253,18 +253,17 @@ func (r *personaRequest) toPersona() *models.Persona {
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolvePreset loads a persona by ID and returns it if the user has access.
|
||||
// ResolvePersona 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(stores store.Stores, presetID, userID string) *models.Persona {
|
||||
func ResolvePersona(stores store.Stores, personaID, userID string) *models.Persona {
|
||||
ctx := context.Background()
|
||||
|
||||
p, err := stores.Personas.GetByID(ctx, presetID)
|
||||
p, err := stores.Personas.GetByID(ctx, personaID)
|
||||
if err != nil || !p.IsActive {
|
||||
return nil
|
||||
}
|
||||
|
||||
ok, err := stores.Personas.UserCanAccess(ctx, userID, presetID)
|
||||
ok, err := stores.Personas.UserCanAccess(ctx, userID, personaID)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -470,10 +470,10 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": teams})
|
||||
}
|
||||
|
||||
// ── Team Models: Available for Presets ──────
|
||||
// ── Team Models: Available for Personas ──────
|
||||
|
||||
// ListAvailableModels returns models with visibility 'enabled' or 'team'
|
||||
// for team admins building presets. Requires RequireTeamAdmin middleware.
|
||||
// for team admins building personas. Requires RequireTeamAdmin middleware.
|
||||
// GET /api/v1/teams/:teamId/models
|
||||
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
Reference in New Issue
Block a user