Changeset 0.23.0 (#153)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
223
server/handlers/completion_chain.go
Normal file
223
server/handlers/completion_chain.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
340
server/handlers/participants.go
Normal file
340
server/handlers/participants.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user