Changeset 0.23.0 (#153)
This commit is contained in:
@@ -20,7 +20,6 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/health"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/mentions"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/routing"
|
||||
@@ -201,9 +200,13 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
// Verify channel ownership
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
// Verify participation: check channel_participants first, fall back to
|
||||
// legacy channels.user_id ownership for direct channels.
|
||||
isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
|
||||
if !isParticipant {
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
|
||||
@@ -332,7 +335,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Load conversation history
|
||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID)
|
||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID, model)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
@@ -365,7 +368,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
// Persist user message (text-only for storage — multimodal parts are ephemeral)
|
||||
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil)
|
||||
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist user message: %v", err)
|
||||
}
|
||||
@@ -383,17 +386,65 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// Resolve effective workspace: channel.workspace_id > project.workspace_id
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||
|
||||
// ── Multi-model @mention routing (v0.20.0) ──────────
|
||||
// Check if the message @mentions specific channel models.
|
||||
// If multiple models are targeted, fan out sequentially.
|
||||
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
||||
if len(roster) > 1 {
|
||||
parsed := mentions.Parse(req.Content, roster)
|
||||
targets := mentions.ResolvedModels(parsed)
|
||||
if len(targets) > 1 {
|
||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req)
|
||||
// ── @mention routing (v0.23.0) ──────────────
|
||||
// Resolve @model-id or @persona-handle directly against the enabled
|
||||
// model catalog and personas table. Works in ANY chat — no roster needed.
|
||||
if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" {
|
||||
req.Model = mentionModel
|
||||
if mentionConfig != "" {
|
||||
req.ProviderConfigID = mentionConfig
|
||||
}
|
||||
if mentionPersona != nil {
|
||||
personaID = mentionPersona.ID
|
||||
personaThinkingBudget = mentionPersona.ThinkingBudget
|
||||
if mentionPersona.SystemPrompt != "" {
|
||||
if len(messages) > 0 && messages[0].Role == "system" {
|
||||
messages[0].Content = mentionPersona.SystemPrompt
|
||||
} else {
|
||||
messages = append([]providers.Message{{Role: "system", Content: mentionPersona.SystemPrompt}}, messages...)
|
||||
}
|
||||
}
|
||||
// Context boundary: tell this persona to ignore other personas' styles
|
||||
boundary := fmt.Sprintf(
|
||||
"[You are now %s. Previous assistant messages may be from different AI personas with different styles — ignore their tone, character, and mannerisms. Respond only as yourself per your system prompt.]",
|
||||
mentionPersona.Name,
|
||||
)
|
||||
if len(messages) >= 2 {
|
||||
last := messages[len(messages)-1]
|
||||
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
|
||||
messages = append(messages, last)
|
||||
}
|
||||
}
|
||||
// Re-resolve provider config for the @mentioned target
|
||||
providerCfg, providerID, model, configID, providerScope, err = h.resolveConfig(userID, channelID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
caps = h.getModelCapabilities(c, model, configID)
|
||||
if personaThinkingBudget != nil && *personaThinkingBudget > 0 {
|
||||
if providerCfg.Settings == nil {
|
||||
providerCfg.Settings = make(map[string]interface{})
|
||||
}
|
||||
providerCfg.Settings["extended_thinking"] = true
|
||||
providerCfg.Settings["thinking_budget"] = *personaThinkingBudget
|
||||
}
|
||||
provider, err = providers.Get(providerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// No @mention: inject a boundary if conversation has persona responses
|
||||
// to prevent the default model from mimicking persona styles.
|
||||
if h.conversationHasPersonaMessages(c.Request.Context(), channelID) {
|
||||
boundary := "[Previous assistant messages in this conversation may include responses from AI personas with specific characters and styles. You are the default assistant — respond in your own natural style, not theirs.]"
|
||||
if len(messages) >= 2 {
|
||||
last := messages[len(messages)-1]
|
||||
messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary}
|
||||
messages = append(messages, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build provider request
|
||||
@@ -540,7 +591,11 @@ func (h *CompletionHandler) multiModelStream(
|
||||
|
||||
// Persist assistant message with model attribution
|
||||
if result.Content != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
|
||||
pID := ""
|
||||
if target.PersonaID != nil {
|
||||
pID = *target.PersonaID
|
||||
}
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, pID); err != nil {
|
||||
log.Printf("Failed to persist multi-model assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -716,9 +771,20 @@ func (h *CompletionHandler) streamCompletion(
|
||||
|
||||
// Persist assistant response
|
||||
if result.Content != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// AI-to-AI chaining: if the response @mentions another persona participant,
|
||||
// trigger a follow-up completion asynchronously via WebSocket delivery.
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
h.chainIfMentioned(channelID, userID, personaID, result.Content, 0)
|
||||
}()
|
||||
}
|
||||
|
||||
// Log usage
|
||||
@@ -765,10 +831,20 @@ func (h *CompletionHandler) syncCompletion(
|
||||
finalContent = resp.Content
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity)); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity), configID, personaID); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// AI-to-AI chaining
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
|
||||
}()
|
||||
|
||||
// Log usage
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
|
||||
@@ -992,6 +1068,199 @@ func isImageContentType(ct string) bool {
|
||||
return strings.HasPrefix(ct, "image/")
|
||||
}
|
||||
|
||||
// ── @mention Resolution (v0.23.0) ───────────
|
||||
|
||||
// conversationHasPersonaMessages checks if any assistant message in the
|
||||
// channel was generated by a persona (participant_type = 'persona').
|
||||
// Used to decide whether to inject a context boundary for the default model.
|
||||
func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context, channelID string) bool {
|
||||
var id string
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM messages
|
||||
WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona'
|
||||
LIMIT 1
|
||||
`), channelID).Scan(&id)
|
||||
return err == nil && id != ""
|
||||
}
|
||||
|
||||
// buildParticipantHint builds a system message listing available @mentionable
|
||||
// personas so the LLM knows who it can direct responses to.
|
||||
// Only includes personas accessible to this user. Excludes the current persona.
|
||||
func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPersonaID string) string {
|
||||
ctx := context.Background()
|
||||
|
||||
personas, err := h.stores.Personas.ListForUser(ctx, userID)
|
||||
if err != nil || len(personas) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, p := range personas {
|
||||
if !p.IsActive || p.Handle == "" || p.ID == currentPersonaID {
|
||||
continue
|
||||
}
|
||||
desc := p.Description
|
||||
if len(desc) > 80 {
|
||||
desc = desc[:80] + "..."
|
||||
}
|
||||
if desc != "" {
|
||||
lines = append(lines, fmt.Sprintf("- @%s (%s) — %s", p.Handle, p.Name, desc))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- @%s (%s)", p.Handle, p.Name))
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
hint := "Available conversation participants you can @mention to direct a response:\n"
|
||||
for _, l := range lines {
|
||||
hint += l + "\n"
|
||||
}
|
||||
hint += "To ask another participant to respond, include their @handle in your message."
|
||||
return hint
|
||||
}
|
||||
|
||||
// Resolves @tokens in message content directly against the model catalog
|
||||
// and personas table. Works in any chat — no channel roster needed.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Persona handle (exact match, then prefix)
|
||||
// 2. Model ID in enabled catalog (exact match, then prefix)
|
||||
//
|
||||
// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found.
|
||||
|
||||
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) {
|
||||
// Extract first @token
|
||||
token := extractFirstMention(content)
|
||||
if token == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// Normalize: lowercase, hyphens
|
||||
normalized := strings.ToLower(strings.TrimSpace(token))
|
||||
|
||||
// 1. Try persona handle (exact)
|
||||
var personaID, personaHandle string
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id, handle FROM personas
|
||||
WHERE LOWER(handle) = $1 AND is_active = true
|
||||
`), normalized).Scan(&personaID, &personaHandle)
|
||||
if err == nil && personaID != "" {
|
||||
p := ResolvePersona(h.stores, personaID, userID)
|
||||
if p != nil {
|
||||
cfgID := ""
|
||||
if p.ProviderConfigID != nil {
|
||||
cfgID = *p.ProviderConfigID
|
||||
}
|
||||
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID)
|
||||
return p.BaseModelID, cfgID, p
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try persona handle (prefix — unambiguous only)
|
||||
if personaID == "" {
|
||||
var count int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
|
||||
`), normalized+"%").Scan(&count)
|
||||
if count == 1 {
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true
|
||||
`), normalized+"%").Scan(&personaID)
|
||||
if personaID != "" {
|
||||
p := ResolvePersona(h.stores, personaID, userID)
|
||||
if p != nil {
|
||||
cfgID := ""
|
||||
if p.ProviderConfigID != nil {
|
||||
cfgID = *p.ProviderConfigID
|
||||
}
|
||||
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID)
|
||||
return p.BaseModelID, cfgID, p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try model_id in enabled catalog (exact)
|
||||
var modelID, provConfigID string
|
||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT mc.model_id, mc.provider_config_id
|
||||
FROM model_catalog mc
|
||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||
WHERE LOWER(mc.model_id) = $1
|
||||
AND mc.visibility = 'enabled'
|
||||
AND pc.is_active = true
|
||||
ORDER BY
|
||||
CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
||||
LIMIT 1
|
||||
`), normalized).Scan(&modelID, &provConfigID)
|
||||
if err == nil && modelID != "" {
|
||||
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID)
|
||||
return modelID, provConfigID, nil
|
||||
}
|
||||
|
||||
// 4. Try model_id prefix (unambiguous)
|
||||
var prefixCount int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(DISTINCT mc.model_id)
|
||||
FROM model_catalog mc
|
||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||
WHERE LOWER(mc.model_id) LIKE $1
|
||||
AND mc.visibility = 'enabled'
|
||||
AND pc.is_active = true
|
||||
`), normalized+"%").Scan(&prefixCount)
|
||||
if prefixCount == 1 {
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT mc.model_id, mc.provider_config_id
|
||||
FROM model_catalog mc
|
||||
JOIN provider_configs pc ON pc.id = mc.provider_config_id
|
||||
WHERE LOWER(mc.model_id) LIKE $1
|
||||
AND mc.visibility = 'enabled'
|
||||
AND pc.is_active = true
|
||||
ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
|
||||
LIMIT 1
|
||||
`), normalized+"%").Scan(&modelID, &provConfigID)
|
||||
if modelID != "" {
|
||||
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID)
|
||||
return modelID, provConfigID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// extractFirstMention finds the first @token in content.
|
||||
// Returns the token without the @ prefix, or empty string.
|
||||
func extractFirstMention(content string) string {
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] != '@' {
|
||||
continue
|
||||
}
|
||||
// @ must be at start or preceded by whitespace
|
||||
if i > 0 && content[i-1] != ' ' && content[i-1] != '\n' && content[i-1] != '\t' {
|
||||
continue
|
||||
}
|
||||
// Extract token
|
||||
start := i + 1
|
||||
end := start
|
||||
for end < len(content) && content[end] != ' ' && content[end] != '\n' && content[end] != '\t' {
|
||||
end++
|
||||
}
|
||||
if end > start {
|
||||
// Strip trailing punctuation
|
||||
for end > start && (content[end-1] == ',' || content[end-1] == '.' || content[end-1] == '!' || content[end-1] == '?' || content[end-1] == ':' || content[end-1] == ';') {
|
||||
end--
|
||||
}
|
||||
if end > start {
|
||||
return content[start:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Config Resolution ───────────────────────
|
||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||
|
||||
@@ -1037,6 +1306,8 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var customHeadersJSON, providerSettingsJSON []byte
|
||||
var proxyMode string
|
||||
var proxyURL *string
|
||||
// $2/userID appears twice in the query; Postgres reuses positional params,
|
||||
// SQLite needs each ? bound separately.
|
||||
configArgs := []interface{}{configID, userID}
|
||||
@@ -1045,14 +1316,14 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
}
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
|
||||
model_default, headers, settings
|
||||
model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
||||
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
|
||||
@@ -1100,11 +1371,18 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
|
||||
}
|
||||
|
||||
proxyURLStr := ""
|
||||
if proxyURL != nil {
|
||||
proxyURLStr = *proxyURL
|
||||
}
|
||||
|
||||
return providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
Settings: providerSettings,
|
||||
ProxyMode: proxyMode,
|
||||
ProxyURL: proxyURLStr,
|
||||
}, providerID, model, configID, providerScope, nil
|
||||
}
|
||||
|
||||
@@ -1116,7 +1394,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, personaSystemPrompt, personaID string) ([]providers.Message, error) {
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID, currentModel string) ([]providers.Message, error) {
|
||||
messages := make([]providers.Message, 0)
|
||||
|
||||
// ── Admin system prompt (always injected first, no opt out) ──
|
||||
@@ -1183,6 +1461,16 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
||||
})
|
||||
}
|
||||
|
||||
// ── Participant roster (v0.23.0) ──
|
||||
// Inject the list of available @mentionable personas so the LLM
|
||||
// knows who it can direct responses to. This enables LLM→LLM chaining.
|
||||
if participantHint := h.buildParticipantHint(channelID, userID, personaID); participantHint != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: participantHint,
|
||||
})
|
||||
}
|
||||
|
||||
// Walk the active path (root → leaf) instead of loading all messages
|
||||
path, err := getActivePath(channelID, userID)
|
||||
if err != nil {
|
||||
@@ -1207,6 +1495,29 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
||||
startIdx = summaryIdx + 1
|
||||
}
|
||||
|
||||
// ── Build persona name cache for history rewrite (v0.23.0) ──
|
||||
// Collect unique persona IDs from the path so we can attribute
|
||||
// foreign persona messages by name instead of raw UUID.
|
||||
personaNameCache := make(map[string]string)
|
||||
hasForeignPersona := false
|
||||
{
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range path {
|
||||
if m.ParticipantType == "persona" && m.ParticipantID != "" && m.ParticipantID != personaID {
|
||||
if !seen[m.ParticipantID] {
|
||||
seen[m.ParticipantID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for pid := range seen {
|
||||
var name string
|
||||
database.DB.QueryRow(database.Q(`SELECT name FROM personas WHERE id = $1`), pid).Scan(&name)
|
||||
if name != "" {
|
||||
personaNameCache[pid] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range path[startIdx:] {
|
||||
if m.Role == "system" {
|
||||
continue // system prompts handled above
|
||||
@@ -1215,10 +1526,59 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
||||
if isSummaryMessage(&m) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, providers.Message{
|
||||
|
||||
// ── Foreign speaker attribution (v0.23.0) ──
|
||||
// ALL assistant messages get a Name field identifying who generated them.
|
||||
// Providers that support "name" (OpenAI, OpenRouter, Venice) use it
|
||||
// to distinguish speakers. Anthropic rewrites named foreign messages
|
||||
// to user role with attribution.
|
||||
msg := providers.Message{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
if m.Role == "assistant" {
|
||||
isForeign := false
|
||||
|
||||
if m.ParticipantType == "persona" && m.ParticipantID != "" {
|
||||
if m.ParticipantID != personaID {
|
||||
// Foreign persona — tag with name
|
||||
isForeign = true
|
||||
name := personaNameCache[m.ParticipantID]
|
||||
if name != "" {
|
||||
msg.Name = name
|
||||
}
|
||||
}
|
||||
} else if m.Model != nil && *m.Model != "" {
|
||||
// Raw model — foreign if model differs or we're a persona
|
||||
if *m.Model != currentModel || personaID != "" {
|
||||
isForeign = true
|
||||
msg.Name = *m.Model
|
||||
}
|
||||
}
|
||||
|
||||
if isForeign {
|
||||
hasForeignPersona = true
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
// If foreign persona messages exist, add a system hint explaining
|
||||
// the multi-participant context (helps all providers)
|
||||
if hasForeignPersona {
|
||||
hint := "This is a multi-participant AI conversation. Messages from other AI participants are marked with their name. You are one of several models — respond only as yourself, ignoring other participants' styles and characters."
|
||||
// Insert after the last system message, before conversation history
|
||||
insertIdx := 0
|
||||
for i, m := range messages {
|
||||
if m.Role == "system" {
|
||||
insertIdx = i + 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
messages = append(messages[:insertIdx+1], messages[insertIdx:]...)
|
||||
messages[insertIdx] = providers.Message{Role: "system", Content: hint}
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
@@ -1242,7 +1602,7 @@ func toolActivityJSON(activity []map[string]interface{}) json.RawMessage {
|
||||
return b
|
||||
}
|
||||
|
||||
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage) (string, error) {
|
||||
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage, providerConfigID string, personaID string) (string, error) {
|
||||
var tokensUsed *int
|
||||
if inputTokens > 0 || outputTokens > 0 {
|
||||
total := inputTokens + outputTokens
|
||||
@@ -1260,12 +1620,23 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
// Compute sibling_index
|
||||
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||
|
||||
// Determine participant
|
||||
// Determine participant: persona for assistant messages with personaID, otherwise user/model
|
||||
participantType := "user"
|
||||
participantID := userID
|
||||
if role == "assistant" {
|
||||
participantType = "model"
|
||||
participantID = model
|
||||
if personaID != "" {
|
||||
participantType = "persona"
|
||||
participantID = personaID
|
||||
} else {
|
||||
participantType = "model"
|
||||
participantID = model
|
||||
}
|
||||
}
|
||||
|
||||
// provider_config_id: nil if empty
|
||||
var provCfgVal interface{}
|
||||
if providerConfigID != "" {
|
||||
provCfgVal = providerConfigID
|
||||
}
|
||||
|
||||
// Prepare tool_calls JSONB (nil → NULL)
|
||||
@@ -1280,21 +1651,21 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
newID = store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?)
|
||||
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)
|
||||
`, newID, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx)
|
||||
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10)
|
||||
tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, channelID, role, content, model, tokensUsed,
|
||||
tcVal, parentID, participantType, participantID, siblingIdx,
|
||||
tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx,
|
||||
).Scan(&newID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
Reference in New Issue
Block a user