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

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