Changeset 0.23.0 (#153)

This commit is contained in:
2026-03-05 22:40:26 +00:00
parent 40d9834f64
commit 2fc620e1ac
62 changed files with 6214 additions and 362 deletions

View File

@@ -19,7 +19,7 @@ import (
type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), group, channel
Type string `json:"type,omitempty"` // direct (default), group, workflow
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
@@ -334,17 +334,17 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
ch.Tags = tags
ch.MessageCount = 0
// Auto-create channel_member for the creator
// Auto-create channel_participant for the creator
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO channel_members (id, channel_id, user_id, role)
VALUES (?, ?, ?, 'owner')
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
VALUES (?, ?, 'user', ?, 'owner')
ON CONFLICT DO NOTHING
`, store.NewID(), ch.ID, userID)
} else {
_, _ = database.DB.Exec(`
INSERT INTO channel_members (channel_id, user_id, role)
VALUES ($1, $2, 'owner')
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
VALUES ($1, 'user', $2, 'owner')
ON CONFLICT DO NOTHING
`, ch.ID, userID)
}

View File

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

View File

@@ -0,0 +1,223 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
)
// maxChainDepth limits AI-to-AI chaining to prevent runaway loops.
const maxChainDepth = 5
// chainIfMentioned checks if an assistant response @mentions another
// persona and triggers a follow-up completion. Uses resolveMention()
// directly — no roster or participant list needed. Works in any chat.
//
// Runs asynchronously (goroutine) after the SSE stream closes.
// The follow-up response is delivered to the client via WebSocket events.
func (h *CompletionHandler) chainIfMentioned(
channelID, userID, currentPersonaID, responseContent string,
depth int,
) {
if depth >= maxChainDepth {
return
}
defer func() {
if r := recover(); r != nil {
log.Printf("[chain] PANIC in chain (depth=%d): %v", depth, r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// Extract the @token for logging
token := extractFirstMention(responseContent)
if token == "" {
log.Printf("[chain] No @mention found in response (len=%d, persona=%s)", len(responseContent), currentPersonaID[:min(8, len(currentPersonaID))])
return
}
log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth)
// Use the same resolveMention() that handles user @mentions
mentionModel, mentionConfig, mentionPersona := h.resolveMention(ctx, userID, responseContent)
if mentionModel == "" {
log.Printf("[chain] @%s did not resolve to any model", token)
return
}
if mentionPersona == nil {
log.Printf("[chain] @%s resolved to raw model %s (no chain for raw models)", token, mentionModel)
return // no persona @mention found, or it's a raw model (no chain for those)
}
// Don't chain to self
if mentionPersona.ID == currentPersonaID {
log.Printf("[chain] @%s is self-mention, skipping", token)
return
}
log.Printf("[chain] %s @mentioned %s (depth=%d)", currentPersonaID[:min(8, len(currentPersonaID))], mentionPersona.Name, depth+1)
// Emit typing indicator
if h.hub != nil {
h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, true)
defer h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, false)
}
// Brief pause for UX
time.Sleep(500 * time.Millisecond)
// Resolve provider config
configID := mentionConfig
chainReq := completionRequest{
ChannelID: channelID,
Model: mentionModel,
ProviderConfigID: configID,
}
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
if err != nil {
log.Printf("[chain] Failed to resolve config for %s: %v", mentionPersona.Name, err)
return
}
configID = resolvedConfigID
provider, err := providers.Get(providerID)
if err != nil {
log.Printf("[chain] Provider %s unavailable: %v", providerID, err)
return
}
// Load conversation with persona's system prompt
messages, err := h.loadConversation(channelID, userID, mentionPersona.SystemPrompt, mentionPersona.ID, model)
if err != nil {
log.Printf("[chain] Failed to load conversation: %v", err)
return
}
// Context boundary
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style and mannerisms. Respond only as yourself per your system prompt.]",
mentionPersona.Name,
)
messages = append(messages, providers.Message{Role: "system", Content: boundary})
caps := capspkg.ResolveIntrinsic(model, nil, nil)
provReq := providers.CompletionRequest{
Model: model,
Messages: messages,
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
}
if mentionPersona.Temperature != nil {
provReq.Temperature = mentionPersona.Temperature
}
if mentionPersona.ThinkingBudget != nil && *mentionPersona.ThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *mentionPersona.ThinkingBudget
}
// Apply provider-specific hooks
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
// Synchronous completion — result delivered via WebSocket
callStart := time.Now()
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
latencyMs := int(time.Since(callStart).Milliseconds())
if err != nil {
log.Printf("[chain] Completion failed for %s: %v", mentionPersona.Name, err)
if h.health != nil {
h.health.RecordError(configID, latencyMs, err.Error())
}
return
}
if h.health != nil {
h.health.RecordSuccess(configID, latencyMs)
}
if resp.Content == "" {
return
}
// Persist
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
resp.InputTokens, resp.OutputTokens, nil, nil, configID, mentionPersona.ID)
if err != nil {
log.Printf("[chain] Failed to persist chained message: %v", err)
return
}
// Deliver via WebSocket
if h.hub != nil {
payload, _ := json.Marshal(map[string]interface{}{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": resp.Content,
"model": model,
"participant_type": "persona",
"participant_id": mentionPersona.ID,
"display_name": mentionPersona.Name,
"avatar": mentionPersona.Avatar,
"tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth + 1,
})
h.hub.SendToUser(userID, events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
// Log usage
if h.stores.Usage != nil {
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &configID,
ProviderScope: "global",
ModelID: model,
PromptTokens: resp.InputTokens,
CompletionTokens: resp.OutputTokens,
}
_ = h.stores.Usage.Log(ctx, entry)
}
log.Printf("[chain] ✅ %s responded (depth=%d, %d tokens) in channel %s",
mentionPersona.Name, depth+1, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
// Recursive: check if this response @mentions yet another persona
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
}
// emitTyping sends a typing indicator via WebSocket.
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
eventType := "typing.start"
if !start {
eventType = "typing.stop"
}
payload, _ := json.Marshal(map[string]string{
"channel_id": channelID,
"participant_id": participantID,
"participant_type": "persona",
"display_name": displayName,
})
h.hub.SendToUser(userID, events.Event{
Label: eventType,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}

View File

@@ -37,6 +37,7 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
var req struct {
ModelID string `json:"model_id" binding:"required"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
@@ -48,13 +49,14 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
}
patch := models.UserModelSettingPatch{
ProviderConfigID: req.ProviderConfigID,
Hidden: req.Hidden,
PreferredTemperature: req.PreferredTemperature,
PreferredMaxTokens: req.PreferredMaxTokens,
SortOrder: req.SortOrder,
}
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, patch); err != nil {
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, req.ProviderConfigID, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
return
}
@@ -62,23 +64,23 @@ func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
}
// BulkSetPreferences sets hidden state for multiple models at once.
// BulkSetPreferences sets hidden state for multiple model+provider pairs at once.
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
userID := getUserID(c)
var req struct {
ModelIDs []string `json:"model_ids" binding:"required"`
Hidden bool `json:"hidden"`
Entries []models.HiddenEntry `json:"entries" binding:"required"`
Hidden bool `json:"hidden"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.ModelIDs, req.Hidden); err != nil {
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.Entries, req.Hidden); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.ModelIDs)})
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.Entries)})
}

View File

@@ -27,9 +27,15 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
}
proxyURL := ""
if cfg.ProxyURL != nil {
proxyURL = *cfg.ProxyURL
}
provCfg := providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: apiKeyPlain,
Endpoint: cfg.Endpoint,
APIKey: apiKeyPlain,
ProxyMode: cfg.ProxyMode,
ProxyURL: proxyURL,
}
if cfg.Headers != nil {

View File

@@ -0,0 +1,340 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Channel Participants Handler ──────────────
// ICD §3.7: Manages polymorphic participants per channel.
// Routes:
// GET /channels/:id/participants — list
// POST /channels/:id/participants — add
// PATCH /channels/:id/participants/:participantId — update role
// DELETE /channels/:id/participants/:participantId — remove
// GET /channels/:id/presence — who's online
type ParticipantHandler struct {
stores store.Stores
}
func NewParticipantHandler(stores store.Stores) *ParticipantHandler {
return &ParticipantHandler{stores: stores}
}
// ── List ─────────────────────────────────────
func (h *ParticipantHandler) List(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// Caller must be a participant (any role)
if !h.requireParticipant(c, channelID, userID) {
return
}
participants, err := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list participants"})
return
}
if participants == nil {
participants = []models.ChannelParticipant{}
}
c.JSON(http.StatusOK, gin.H{"participants": participants})
}
// ── Add ──────────────────────────────────────
type addParticipantRequest struct {
ParticipantType string `json:"participant_type" binding:"required"`
ParticipantID string `json:"participant_id" binding:"required"`
Role string `json:"role"`
}
func (h *ParticipantHandler) Add(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// Only owners can add participants
if !h.requireOwner(c, channelID, userID) {
return
}
var req addParticipantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate participant_type
switch req.ParticipantType {
case "user", "persona", "session":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "participant_type must be user, persona, or session"})
return
}
// Default role
role := req.Role
if role == "" {
role = "member"
}
switch role {
case "owner", "member", "observer":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
return
}
// Build participant
p := &models.ChannelParticipant{
ChannelID: channelID,
ParticipantType: req.ParticipantType,
ParticipantID: req.ParticipantID,
Role: role,
}
// If adding a persona, resolve display_name and avatar from persona record,
// and auto-add persona's model to channel_models roster.
if req.ParticipantType == "persona" {
persona, err := h.stores.Personas.GetByID(c.Request.Context(), req.ParticipantID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up persona"})
return
}
dn := persona.Name
p.DisplayName = &dn
if persona.Avatar != "" {
p.AvatarURL = &persona.Avatar
}
// Auto-add persona's model to channel_models roster
cm := &models.ChannelModel{
ChannelID: channelID,
ModelID: persona.BaseModelID,
ProviderConfigID: derefStr(persona.ProviderConfigID),
PersonaID: &req.ParticipantID,
Handle: persona.Handle,
DisplayName: persona.Name,
SystemPrompt: persona.SystemPrompt,
IsDefault: false,
}
_ = h.stores.Channels.SetModel(c.Request.Context(), cm)
} else if req.ParticipantType == "user" {
// Resolve user display name
user, err := h.stores.Users.GetByID(c.Request.Context(), req.ParticipantID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up user"})
return
}
dn := user.DisplayName
if dn == "" {
dn = user.Username
}
p.DisplayName = &dn
if user.AvatarURL != "" {
p.AvatarURL = &user.AvatarURL
}
}
if err := h.stores.Channels.AddParticipant(c.Request.Context(), p); err != nil {
// Check for duplicate
if isDuplicateErr(err) {
c.JSON(http.StatusConflict, gin.H{"error": "participant already in channel"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"})
return
}
// Return updated participant list
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusCreated, gin.H{"participants": participants})
}
// ── Update Role ─────────────────────────────
type updateParticipantRequest struct {
Role string `json:"role" binding:"required"`
}
func (h *ParticipantHandler) Update(c *gin.Context) {
channelID := c.Param("id")
participantRecordID := c.Param("participantId")
userID := getUserID(c)
if !h.requireOwner(c, channelID, userID) {
return
}
var req updateParticipantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
switch req.Role {
case "owner", "member", "observer":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
return
}
// Verify participant belongs to this channel
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
return
}
if p.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
return
}
if err := h.stores.Channels.UpdateParticipantRole(c.Request.Context(), participantRecordID, req.Role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update participant"})
return
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"participants": participants})
}
// ── Remove ──────────────────────────────────
func (h *ParticipantHandler) Remove(c *gin.Context) {
channelID := c.Param("id")
participantRecordID := c.Param("participantId")
userID := getUserID(c)
if !h.requireOwner(c, channelID, userID) {
return
}
// Verify participant belongs to this channel
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
return
}
if p.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
return
}
// Cannot remove the last owner
if p.Role == "owner" {
owners, _ := h.stores.Channels.CountParticipantsByRole(c.Request.Context(), channelID, "owner")
if owners <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last owner"})
return
}
}
// If removing a persona, also remove its auto-created model roster entry
if p.ParticipantType == "persona" {
_ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID)
}
if err := h.stores.Channels.RemoveParticipant(c.Request.Context(), participantRecordID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"})
return
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"participants": participants})
}
// ── Helpers ──────────────────────────────────
// requireParticipant checks the user is any participant in the channel.
func (h *ParticipantHandler) requireParticipant(c *gin.Context, channelID, userID string) bool {
ok, err := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check participation"})
return false
}
if !ok {
// Fall back to legacy channels.user_id ownership for direct channels
if userOwnsChannel(c, channelID, userID) {
return true
}
return false
}
return true
}
// requireOwner checks the user is an owner participant in the channel.
func (h *ParticipantHandler) requireOwner(c *gin.Context, channelID, userID string) bool {
role, err := h.stores.Channels.GetParticipantRole(c.Request.Context(), channelID, "user", userID)
if err != nil {
// Fall back to legacy channels.user_id ownership for direct channels
if userOwnsChannel(c, channelID, userID) {
return true
}
return false
}
if role != "owner" {
c.JSON(http.StatusForbidden, gin.H{"error": "owner role required"})
return false
}
return true
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// isDuplicateErr detects unique constraint violations across both Postgres and SQLite.
func isDuplicateErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// Postgres: "duplicate key value violates unique constraint"
// SQLite: "UNIQUE constraint failed"
return contains(msg, "duplicate key") || contains(msg, "UNIQUE constraint")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchStr(s, substr)
}
func searchStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -223,6 +223,7 @@ func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
type personaRequest struct {
Name string `json:"name" binding:"required"`
Handle string `json:"handle,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
BaseModelID string `json:"base_model_id,omitempty"`
@@ -238,6 +239,7 @@ type personaRequest struct {
func (r *personaRequest) toPersona() *models.Persona {
p := &models.Persona{
Name: r.Name,
Handle: r.Handle,
Description: r.Description,
Icon: r.Icon,
BaseModelID: r.BaseModelID,