Changeset 0.23.2 (#155)

This commit is contained in:
2026-03-06 23:17:03 +00:00
parent 4c6555cb06
commit 2dc4514a57
36 changed files with 2784 additions and 192 deletions

View File

@@ -333,6 +333,14 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
hasAdminPrompt = true
}
// Channel retention mode (v0.23.2 — flexible or retain)
retentionMode := "flexible"
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
if mode, ok := retCfg["mode"].(string); ok && mode != "" {
retentionMode = mode
}
}
c.JSON(http.StatusOK, gin.H{
"banner": banner,
"branding": branding,
@@ -340,9 +348,10 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
"storage_configured": storageConfigured,
"paste_to_file_chars": pasteChars,
"policies": gin.H{
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],
"allow_user_personas": policies["allow_user_personas"],
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],
"allow_user_personas": policies["allow_user_personas"],
"channel_retention_mode": retentionMode,
},
})
}
@@ -724,3 +733,88 @@ func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID
log.Printf("audit log error: %v", err)
}
}
// ── Archived Channels (v0.23.2) ──────────────
func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT c.id, c.title, c.type, c.user_id,
COALESCE(u.username, '') AS owner_name,
c.updated_at,
(SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count
FROM channels c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.is_archived = true
ORDER BY c.updated_at DESC
LIMIT 100
`))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
type archivedChannel struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
OwnerName string `json:"owner_name"`
UpdatedAt string `json:"updated_at"`
MessageCount int `json:"message_count"`
}
channels := []archivedChannel{}
for rows.Next() {
var ch archivedChannel
var userID string
if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &userID, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil {
channels = append(channels, ch)
}
}
// Retention config
retentionMode := "flexible"
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
if mode, ok := retCfg["mode"].(string); ok {
retentionMode = mode
}
}
c.JSON(http.StatusOK, gin.H{
"channels": channels,
"retention_mode": retentionMode,
})
}
func (h *AdminHandler) PurgeChannel(c *gin.Context) {
channelID := c.Param("id")
// Verify the channel is actually archived
var isArchived bool
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT is_archived FROM channels WHERE id = $1
`), channelID).Scan(&isArchived)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if !isArchived {
c.JSON(http.StatusConflict, gin.H{"error": "channel must be archived before purging"})
return
}
// Hard delete (CASCADE removes messages, participants, models)
_, err = database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM channels WHERE id = $1
`), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "purge failed"})
return
}
// Audit log
uid := getUserID(c)
h.auditLog(c, uid, "channel.purged", "channel", channelID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -19,13 +19,16 @@ import (
type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), group, workflow
Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
// v0.23.2: DM creation
Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user)
AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off
}
type updateChannelRequest struct {
@@ -40,6 +43,8 @@ type updateChannelRequest struct {
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off
Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic
}
type channelResponse struct {
@@ -47,6 +52,8 @@ type channelResponse struct {
UserID string `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
AiMode string `json:"ai_mode,omitempty"`
Topic *string `json:"topic,omitempty"`
Description *string `json:"description"`
Model *string `json:"model"`
ProviderConfigID *string `json:"provider_config_id"`
@@ -59,6 +66,7 @@ type channelResponse struct {
Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"`
UnreadCount int `json:"unread_count,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -140,16 +148,24 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder")
channelType := c.DefaultQuery("type", "") // empty = all types
channelTypes := c.QueryArray("types") // v0.23.1: multi-value, e.g. ?types=dm&types=channel
// Comma-joined convenience: ?types=dm,channel
if len(channelTypes) == 0 && c.Query("types") != "" {
channelTypes = strings.Split(c.Query("types"), ",")
// v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel
// and comma-joined ?types=dm,channel
var channelTypes []string
if raw := c.Query("types"); raw != "" {
for _, t := range strings.Split(raw, ",") {
t = strings.TrimSpace(t)
if t != "" {
channelTypes = append(channelTypes, t)
}
}
}
search := strings.TrimSpace(c.Query("search"))
projectFilter := c.Query("project_id") // "uuid" or "none"
// Count total
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
// Count total — include channels owned by user OR where user is a participant
countQuery := `SELECT COUNT(*) FROM channels c WHERE (c.user_id = $1 OR c.id IN (
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1
)) AND c.is_archived = $2`
countArgs := []interface{}{userID, archived == "true"}
argN := 3
@@ -160,26 +176,26 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
countArgs = append(countArgs, strings.TrimSpace(t))
argN++
}
countQuery += " AND type IN (" + strings.Join(placeholders, ",") + ")"
countQuery += " AND c.type IN (" + strings.Join(placeholders, ",") + ")"
} else if channelType != "" {
countQuery += ` AND type = $` + strconv.Itoa(argN)
countQuery += ` AND c.type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType)
argN++
}
if folder != "" {
countQuery += ` AND folder = $` + strconv.Itoa(argN)
countQuery += ` AND c.folder = $` + strconv.Itoa(argN)
countArgs = append(countArgs, folder)
argN++
}
if search != "" {
countQuery += ` AND title ILIKE $` + strconv.Itoa(argN)
countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN)
countArgs = append(countArgs, "%"+search+"%")
argN++
}
if projectFilter == "none" {
countQuery += ` AND project_id IS NULL`
countQuery += ` AND c.project_id IS NULL`
} else if projectFilter != "" {
countQuery += ` AND project_id = $` + strconv.Itoa(argN)
countQuery += ` AND c.project_id = $` + strconv.Itoa(argN)
countArgs = append(countArgs, projectFilter)
argN++
}
@@ -191,8 +207,10 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
}
// Fetch channels with message count
// Include channels owned by user OR where user is a participant (multi-user)
query := `
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
@@ -201,7 +219,9 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.user_id = $1 AND c.is_archived = $2`
WHERE (c.user_id = $1 OR c.id IN (
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1
)) AND c.is_archived = $2`
args := []interface{}{userID, archived == "true"}
argN = 3
@@ -252,7 +272,8 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
var ch channelResponse
var tags []string
err := rows.Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
@@ -267,6 +288,18 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
ch.Tags = tags
channels = append(channels, ch)
}
rows.Close()
// v0.23.2: Compute unread counts per channel (safe post-processing)
for i := range channels {
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COUNT(*) FROM messages m
JOIN channel_participants cp ON cp.channel_id = m.channel_id
WHERE cp.channel_id = $1
AND cp.participant_type = 'user' AND cp.participant_id = $2
AND m.created_at > cp.last_read_at
`), channels[i].ID, userID).Scan(&channels[i].UnreadCount)
}
SafeJSON(c, http.StatusOK, paginatedResponse{
Data: channels,
@@ -298,6 +331,62 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
channelType = "direct"
}
// Default ai_mode: mention_only for DMs, auto for everything else
aiMode := req.AiMode
if aiMode == "" {
if channelType == "dm" {
aiMode = "mention_only"
} else {
aiMode = "auto"
}
}
// ── DM dedup: check for existing DM between these two users ──
if channelType == "dm" && len(req.Participants) == 1 {
otherUserID := req.Participants[0]
if otherUserID == userID {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"})
return
}
// Look for an existing DM channel where both users are participants
var existingID string
_ = database.DB.QueryRow(database.Q(`
SELECT cp1.channel_id FROM channel_participants cp1
JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id
JOIN channels c ON c.id = cp1.channel_id
WHERE c.type = 'dm'
AND cp1.participant_type = 'user' AND cp1.participant_id = $1
AND cp2.participant_type = 'user' AND cp2.participant_id = $2
LIMIT 1
`), userID, otherUserID).Scan(&existingID)
if existingID != "" {
// Return existing channel instead of creating duplicate
var ch channelResponse
var tags []string
err := database.DB.QueryRow(database.Q(`
SELECT id, user_id, title, type, description, model, provider_config_id,
system_prompt, is_archived, is_pinned, folder, project_id, workspace_id,
tags, settings,
created_at, updated_at
FROM channels WHERE id = $1
`), existingID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
if err == nil {
if tags == nil {
tags = []string{}
}
ch.Tags = tags
SafeJSON(c, http.StatusOK, ch) // 200, not 201 — existing resource
return
}
// If read fails, fall through and create new (shouldn't happen)
}
}
// INSERT and retrieve the new row
var ch channelResponse
var tags []string
@@ -306,10 +395,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
id := store.NewID()
_, err := database.DB.Exec(`
INSERT INTO channels (id, user_id, title, type, description, model,
system_prompt, provider_config_id, folder, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
system_prompt, provider_config_id, folder, tags, ai_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, userID, req.Title, channelType, req.Description, req.Model,
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags),
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags), aiMode,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
@@ -332,12 +421,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
}
} else {
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags, ai_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
req.Folder, pq.Array(req.Tags),
req.Folder, pq.Array(req.Tags), aiMode,
).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
@@ -370,6 +459,28 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
`, ch.ID, userID)
}
// v0.23.2: Add DM partner as member participant
if channelType == "dm" && len(req.Participants) > 0 {
for _, pid := range req.Participants {
if pid == userID {
continue // skip self (already added as owner)
}
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
VALUES (?, ?, 'user', ?, 'member')
ON CONFLICT DO NOTHING
`, store.NewID(), ch.ID, pid)
} else {
_, _ = database.DB.Exec(`
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
VALUES ($1, 'user', $2, 'member')
ON CONFLICT DO NOTHING
`, ch.ID, pid)
}
}
}
// Auto-create channel_model if model specified
if req.Model != "" {
if database.IsSQLite() {
@@ -519,6 +630,17 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
addClause("workspace_id", *req.WorkspaceID)
}
}
if req.AiMode != nil {
mode := *req.AiMode
if mode != "auto" && mode != "mention_only" && mode != "off" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"})
return
}
addClause("ai_mode", mode)
}
if req.Topic != nil {
addClause("topic", *req.Topic)
}
if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
@@ -571,3 +693,38 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
}
// ── Mark Read (v0.23.2) ───────────────────────
func (h *ChannelHandler) MarkRead(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
// Update the read cursor timestamp (last_read_at exists since migration 005)
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE channel_participants
SET last_read_at = NOW()
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
`), channelID, userID)
if err != nil {
// Participant row may not exist for legacy direct chats — that's OK
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
// Best-effort: also set last_read_message_id if the column exists (migration 017)
var latestMsgID *string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1
`), channelID).Scan(&latestMsgID)
if latestMsgID != nil {
// This may fail if 017 hasn't been applied — that's fine, last_read_at is primary
_, _ = database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE channel_participants
SET last_read_message_id = $1
WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3
`), *latestMsgID, channelID, userID)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -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]) + "…"
}

View File

@@ -203,6 +203,141 @@ func (h *CompletionHandler) chainIfMentioned(
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
}
// chainToPersona triggers a completion for a specific persona by ID.
// Used by @all fan-out. Does NOT recurse (depth-1 only).
func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, triggerContent string, depth int) {
defer func() {
if r := recover(); r != nil {
log.Printf("[chain-all] PANIC: %v", r)
}
}()
persona := ResolvePersona(h.stores, personaID, userID)
if persona == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if h.hub != nil {
h.emitTyping(userID, channelID, persona.ID, persona.Name, true)
defer h.emitTyping(userID, channelID, persona.ID, persona.Name, false)
}
configID := ""
if persona.ProviderConfigID != nil {
configID = *persona.ProviderConfigID
}
chainReq := completionRequest{
ChannelID: channelID,
Model: persona.BaseModelID,
ProviderConfigID: configID,
}
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
if err != nil {
log.Printf("[chain-all] Config failed for %s: %v", persona.Name, err)
return
}
configID = resolvedConfigID
provider, err := providers.Get(providerID)
if err != nil {
return
}
messages, err := h.loadConversation(channelID, userID, persona.SystemPrompt, persona.ID, model)
if err != nil {
return
}
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style. Respond only as yourself per your system prompt.]",
persona.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 persona.Temperature != nil {
provReq.Temperature = persona.Temperature
}
if persona.ThinkingBudget != nil && *persona.ThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *persona.ThinkingBudget
}
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
callStart := time.Now()
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
latencyMs := int(time.Since(callStart).Milliseconds())
if err != nil {
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
}
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
resp.InputTokens, resp.OutputTokens, nil, nil, configID, persona.ID)
if err != nil {
return
}
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": persona.ID,
"display_name": persona.Name,
"avatar": persona.Avatar,
"tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth,
})
h.hub.SendToUser(userID, events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
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-all] ✅ %s responded (%d tokens) in channel %s",
persona.Name, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
}
// emitTyping sends a typing indicator via WebSocket.
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
eventType := "typing.start"

View File

@@ -29,16 +29,20 @@ type createMessageRequest struct {
}
type messageResponse struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Role string `json:"role"`
Content string `json:"content"`
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used"`
ParentID *string `json:"parent_id,omitempty"`
SiblingCount int `json:"sibling_count"`
SiblingIndex int `json:"sibling_index"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Role string `json:"role"`
Content string `json:"content"`
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used"`
ParentID *string `json:"parent_id,omitempty"`
SiblingCount int `json:"sibling_count"`
SiblingIndex int `json:"sibling_index"`
ParticipantType *string `json:"participant_type,omitempty"`
ParticipantID *string `json:"participant_id,omitempty"`
SenderName *string `json:"sender_name,omitempty"`
SenderAvatar *string `json:"sender_avatar,omitempty"`
CreatedAt string `json:"created_at"`
}
type editRequest struct {
@@ -98,11 +102,20 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
}
rows, err := database.DB.Query(database.Q(`
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
sibling_index, created_at
FROM messages
WHERE channel_id = $1 AND deleted_at IS NULL
ORDER BY created_at ASC
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
m.sibling_index, m.participant_type, m.participant_id,
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username)
WHEN m.participant_type = 'persona' THEN p.name
ELSE NULL END AS sender_name,
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
WHEN m.participant_type = 'persona' THEN p.avatar
ELSE NULL END AS sender_avatar,
m.created_at
FROM messages m
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
ORDER BY m.created_at ASC
LIMIT $2 OFFSET $3
`), channelID, perPage, offset)
if err != nil {
@@ -117,7 +130,9 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
err := rows.Scan(
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
&msg.Model, &msg.TokensUsed, &msg.ParentID,
&msg.SiblingIndex, &msg.CreatedAt,
&msg.SiblingIndex, &msg.ParticipantType, &msg.ParticipantID,
&msg.SenderName, &msg.SenderAvatar,
&msg.CreatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
@@ -693,9 +708,20 @@ func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
return false
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
if ownerID == userID {
return true
}
return true
// v0.23.2: Also allow if user is a participant (multi-user DMs, channels)
var exists bool
_ = database.DB.QueryRow(
database.Q(`SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2)`),
channelID, userID,
).Scan(&exists)
if exists {
return true
}
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -258,6 +259,37 @@ func (h *ParticipantHandler) Remove(c *gin.Context) {
}
}
// v0.23.2: DM guard — cannot drop below 2 human participants
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 == "dm" && p.ParticipantType == "user" {
var userCount int
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COUNT(*) FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID).Scan(&userCount)
if userCount <= 2 {
c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"})
return
}
}
// v0.23.2: Group guard — cannot remove the last persona
if channelType == "group" && p.ParticipantType == "persona" {
var personaCount int
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COUNT(*) FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'persona'
`), channelID).Scan(&personaCount)
if personaCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"})
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)

View File

@@ -0,0 +1,311 @@
package handlers
// persona_groups.go — Persona group (roster template) CRUD (v0.23.2)
//
// Persona groups are saved collections of personas used as templates
// for creating group chats. Each member has an is_leader flag.
//
// Routes:
// GET /api/v1/persona-groups
// POST /api/v1/persona-groups
// GET /api/v1/persona-groups/:id
// PUT /api/v1/persona-groups/:id
// DELETE /api/v1/persona-groups/:id
// POST /api/v1/persona-groups/:id/members
// DELETE /api/v1/persona-groups/:id/members/:memberId
import (
"database/sql"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type PersonaGroupHandler struct{}
func NewPersonaGroupHandler() *PersonaGroupHandler { return &PersonaGroupHandler{} }
// ── List ────────────────────────────────────────
func (h *PersonaGroupHandler) List(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
FROM persona_groups
WHERE owner_id = $1
ORDER BY name
`), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"})
return
}
defer rows.Close()
groups := []models.PersonaGroup{}
for rows.Next() {
var g models.PersonaGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil {
continue
}
groups = append(groups, g)
}
// Load members for each group
for i := range groups {
groups[i].Members = h.loadMembers(c, groups[i].ID)
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
// ── Get ─────────────────────────────────────────
func (h *PersonaGroupHandler) Get(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
var g models.PersonaGroup
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
FROM persona_groups WHERE id = $1 AND owner_id = $2
`), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"})
return
}
g.Members = h.loadMembers(c, g.ID)
c.JSON(http.StatusOK, g)
}
// ── Create ──────────────────────────────────────
func (h *PersonaGroupHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var g models.PersonaGroup
if database.IsSQLite() {
id := store.NewID()
_, err := database.DB.ExecContext(c.Request.Context(), `
INSERT INTO persona_groups (id, name, description, owner_id, scope)
VALUES (?, ?, ?, ?, 'personal')
`, id, strings.TrimSpace(req.Name), req.Description, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
database.DB.QueryRowContext(c.Request.Context(), `
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
FROM persona_groups WHERE id = ?
`, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
} else {
err := database.DB.QueryRowContext(c.Request.Context(), `
INSERT INTO persona_groups (name, description, owner_id, scope)
VALUES ($1, $2, $3, 'personal')
RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at
`, strings.TrimSpace(req.Name), req.Description, userID).Scan(
&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
}
g.Members = []models.PersonaGroupMember{}
c.JSON(http.StatusCreated, g)
}
// ── Update ──────────────────────────────────────
func (h *PersonaGroupHandler) Update(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT owner_id FROM persona_groups WHERE id = $1
`), id).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
if req.Name != nil {
database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE persona_groups SET name = $1, updated_at = NOW() WHERE id = $2
`), strings.TrimSpace(*req.Name), id)
}
if req.Description != nil {
database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE persona_groups SET description = $1, updated_at = NOW() WHERE id = $2
`), *req.Description, id)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Delete ──────────────────────────────────────
func (h *PersonaGroupHandler) Delete(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
result, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2
`), id, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
n, _ := result.RowsAffected()
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Add Member ──────────────────────────────────
func (h *PersonaGroupHandler) AddMember(c *gin.Context) {
userID := getUserID(c)
groupID := c.Param("id")
var req struct {
PersonaID string `json:"persona_id" binding:"required"`
IsLeader bool `json:"is_leader"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT owner_id FROM persona_groups WHERE id = $1
`), groupID).Scan(&ownerID)
if err != nil || ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
// If setting as leader, clear existing leader
if req.IsLeader {
database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE persona_group_members SET is_leader = false WHERE group_id = $1
`), groupID)
}
if database.IsSQLite() {
id := store.NewID()
_, err = database.DB.ExecContext(c.Request.Context(), `
INSERT INTO persona_group_members (id, group_id, persona_id, is_leader)
VALUES (?, ?, ?, ?)
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader
`, id, groupID, req.PersonaID, req.IsLeader)
} else {
_, err = database.DB.ExecContext(c.Request.Context(), `
INSERT INTO persona_group_members (group_id, persona_id, is_leader)
VALUES ($1, $2, $3)
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader
`, groupID, req.PersonaID, req.IsLeader)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true})
}
// ── Remove Member ───────────────────────────────
func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) {
userID := getUserID(c)
groupID := c.Param("id")
memberID := c.Param("memberId")
// Verify ownership
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT owner_id FROM persona_groups WHERE id = $1
`), groupID).Scan(&ownerID)
if err != nil || ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2
`), memberID, groupID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Helpers ─────────────────────────────────────
func (h *PersonaGroupHandler) loadMembers(c *gin.Context, groupID string) []models.PersonaGroupMember {
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order,
COALESCE(p.name, '') AS persona_name,
COALESCE(p.handle, '') AS persona_handle,
COALESCE(p.avatar, '') AS persona_avatar
FROM persona_group_members pgm
LEFT JOIN personas p ON p.id = pgm.persona_id
WHERE pgm.group_id = $1
ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id
`), groupID)
if err != nil {
return []models.PersonaGroupMember{}
}
defer rows.Close()
members := []models.PersonaGroupMember{}
for rows.Next() {
var m models.PersonaGroupMember
if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder,
&m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil {
continue
}
members = append(members, m)
}
return members
}

View File

@@ -67,3 +67,50 @@ func PresenceQuery(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"presence": result})
}
// SearchUsers returns a lightweight list of approved users matching a query.
// GET /api/v1/users/search?q=alice — matches against username and display_name.
// Returns at most 20 results. Excludes the calling user.
func SearchUsers(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
query := database.Q(`
SELECT id, username, COALESCE(display_name, '') AS display_name
FROM users
WHERE is_active = true AND id != $1
`)
args := []interface{}{userID}
if q != "" {
query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3)`)
pattern := "%" + strings.ToLower(q) + "%"
args = append(args, pattern, pattern)
}
query += ` ORDER BY username LIMIT 20`
rows, err := database.DB.QueryContext(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
defer rows.Close()
type userResult struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
}
results := []userResult{}
for rows.Next() {
var u userResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName); err != nil {
continue
}
results = append(results, u)
}
c.JSON(http.StatusOK, gin.H{"users": results})
}