Changeset 0.22.7 (#149)

This commit is contained in:
2026-03-04 10:44:42 +00:00
parent d8e0664fa3
commit 389e47b0f9
62 changed files with 6820 additions and 1476 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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{

View File

@@ -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())

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)