Changeset 0.23.2 (#155)
This commit is contained in:
@@ -224,8 +224,43 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
|
||||
// Deliver message without triggering completion
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true})
|
||||
// Persist the user message before returning
|
||||
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
|
||||
if err != nil {
|
||||
log.Printf("[mention_only] Failed to persist user message: %v", err)
|
||||
}
|
||||
// Broadcast via WS to ALL user participants (not just sender)
|
||||
if h.hub != nil && msgID != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"id": msgID,
|
||||
"channel_id": channelID,
|
||||
"role": "user",
|
||||
"content": req.Content,
|
||||
"user_id": userID,
|
||||
})
|
||||
evt := events.Event{
|
||||
Label: "message.created",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
// Send to sender
|
||||
h.hub.SendToUser(userID, evt)
|
||||
// Send to other user participants in the channel
|
||||
pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT participant_id FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
|
||||
`), channelID, userID)
|
||||
if pErr == nil {
|
||||
defer pRows.Close()
|
||||
for pRows.Next() {
|
||||
var pid string
|
||||
if pRows.Scan(&pid) == nil {
|
||||
h.hub.SendToUser(pid, evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -234,6 +269,40 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
var personaSystemPrompt string
|
||||
var personaID string // tracks active persona for KB scoping
|
||||
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
||||
|
||||
// v0.23.2: Group leader default — when type=group and no @mention, use the leader persona
|
||||
if req.PersonaID == "" && extractFirstMention(req.Content) == "" {
|
||||
var channelType string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&channelType)
|
||||
if channelType == "group" {
|
||||
// Find the leader persona via channel_participants → persona_group_members
|
||||
var leaderPersonaID string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT cp.participant_id
|
||||
FROM channel_participants cp
|
||||
JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id
|
||||
WHERE cp.channel_id = $1
|
||||
AND cp.participant_type = 'persona'
|
||||
AND pgm.is_leader = true
|
||||
LIMIT 1
|
||||
`), channelID).Scan(&leaderPersonaID)
|
||||
// Fallback: first persona participant if no leader flag set
|
||||
if leaderPersonaID == "" {
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT participant_id FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'persona'
|
||||
ORDER BY created_at LIMIT 1
|
||||
`), channelID).Scan(&leaderPersonaID)
|
||||
}
|
||||
if leaderPersonaID != "" {
|
||||
req.PersonaID = leaderPersonaID
|
||||
log.Printf("[group] channel %s: leader persona %s", channelID[:min(8, len(channelID))], leaderPersonaID[:min(8, len(leaderPersonaID))])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.PersonaID != "" {
|
||||
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
||||
if persona == nil {
|
||||
@@ -413,14 +482,60 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// - AI mention → override model/persona for this turn (v0.23.0)
|
||||
mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content)
|
||||
|
||||
if mentionedUserID != "" {
|
||||
// Human @mention — message already persisted; notify, skip AI.
|
||||
if hub, ok := c.Get("events_hub"); ok {
|
||||
if h2, ok2 := hub.(interface {
|
||||
NotifyUserMention(toUser, channel, fromUser string)
|
||||
}); ok2 {
|
||||
h2.NotifyUserMention(mentionedUserID, channelID, userID)
|
||||
// v0.23.2: @all fan-out — route to every persona participant (depth-1 only)
|
||||
if strings.Contains(strings.ToLower(req.Content), "@all") {
|
||||
// Get all persona participants for this channel
|
||||
allRows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT participant_id FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'persona'
|
||||
ORDER BY created_at
|
||||
`), channelID)
|
||||
if err == nil {
|
||||
defer allRows.Close()
|
||||
var allPersonaIDs []string
|
||||
for allRows.Next() {
|
||||
var pid string
|
||||
if allRows.Scan(&pid) == nil {
|
||||
allPersonaIDs = append(allPersonaIDs, pid)
|
||||
}
|
||||
}
|
||||
if len(allPersonaIDs) > 0 {
|
||||
// Use the first persona for the streamed response
|
||||
first := ResolvePersona(h.stores, allPersonaIDs[0], userID)
|
||||
if first != nil {
|
||||
mentionModel = first.BaseModelID
|
||||
if first.ProviderConfigID != nil {
|
||||
mentionConfig = *first.ProviderConfigID
|
||||
}
|
||||
mentionPersona = first
|
||||
}
|
||||
// Chain remaining personas via goroutine after the stream completes
|
||||
if len(allPersonaIDs) > 1 && h.hub != nil {
|
||||
remaining := allPersonaIDs[1:]
|
||||
go func() {
|
||||
for _, pid := range remaining {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
h.chainToPersona(channelID, userID, pid, req.Content, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mentionedUserID != "" {
|
||||
// Human @mention — message already persisted; notify recipient, skip AI.
|
||||
if h.hub != nil {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"channel_id": channelID,
|
||||
"from_user": userID,
|
||||
"content": truncateContent(req.Content, 120),
|
||||
})
|
||||
h.hub.SendToUser(mentionedUserID, events.Event{
|
||||
Label: "user.mentioned",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
|
||||
return
|
||||
@@ -1230,7 +1345,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
var mentionedUserID string
|
||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(username) = $1 AND id != $2 AND is_approved = true
|
||||
WHERE LOWER(username) = $1 AND id != $2 AND is_active = true
|
||||
LIMIT 1
|
||||
`), normalized, userID).Scan(&mentionedUserID)
|
||||
if err == nil && mentionedUserID != "" {
|
||||
@@ -1242,12 +1357,12 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
var userCount int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(*) FROM users
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true
|
||||
`), normalized+"%", userID).Scan(&userCount)
|
||||
if userCount == 1 {
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true
|
||||
LIMIT 1
|
||||
`), normalized+"%", userID).Scan(&mentionedUserID)
|
||||
if mentionedUserID != "" {
|
||||
@@ -1820,3 +1935,12 @@ func (h *CompletionHandler) recordHealth(configID string, start time.Time, err e
|
||||
h.health.RecordSuccess(configID, latencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateContent returns content truncated to maxLen runes with ellipsis.
|
||||
func truncateContent(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxLen]) + "…"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user