Changeset 0.23.1 (#154)

This commit is contained in:
2026-03-06 13:26:25 +00:00
parent 2fc620e1ac
commit 4c6555cb06
38 changed files with 1536 additions and 4031 deletions

View File

@@ -140,6 +140,11 @@ 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"), ",")
}
search := strings.TrimSpace(c.Query("search"))
projectFilter := c.Query("project_id") // "uuid" or "none"
@@ -148,7 +153,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
countArgs := []interface{}{userID, archived == "true"}
argN := 3
if channelType != "" {
if len(channelTypes) > 0 {
placeholders := make([]string, len(channelTypes))
for i, t := range channelTypes {
placeholders[i] = "$" + strconv.Itoa(argN)
countArgs = append(countArgs, strings.TrimSpace(t))
argN++
}
countQuery += " AND type IN (" + strings.Join(placeholders, ",") + ")"
} else if channelType != "" {
countQuery += ` AND type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType)
argN++
@@ -193,7 +206,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
args := []interface{}{userID, archived == "true"}
argN = 3
if channelType != "" {
if len(channelTypes) > 0 {
placeholders := make([]string, len(channelTypes))
for i, t := range channelTypes {
placeholders[i] = "$" + strconv.Itoa(argN)
args = append(args, strings.TrimSpace(t))
argN++
}
query += " AND c.type IN (" + strings.Join(placeholders, ",") + ")"
} else if channelType != "" {
query += ` AND c.type = $` + strconv.Itoa(argN)
args = append(args, channelType)
argN++

View File

@@ -209,6 +209,27 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── ai_mode guard (v0.23.1) ────────────────────────────────────────────────
// Check channel ai_mode before doing any work. For DM channels with
// mention_only mode and no @mention in the content, persist the message
// but skip the completion entirely.
{
var aiMode string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1
`), channelID).Scan(&aiMode)
if aiMode == "off" {
c.JSON(http.StatusForbidden, gin.H{"error": "AI responses are disabled for this channel"})
return
}
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
// Deliver message without triggering completion
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true})
return
}
}
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
var personaSystemPrompt string
var personaID string // tracks active persona for KB scoping
@@ -386,10 +407,26 @@ 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)
// ── @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 != "" {
// ── @mention routing (v0.23.0 / v0.23.1) ─────────────────────────────────
// Resolve @persona-handle, @model-id, or @username.
// - User mention → skip completion, notify recipient (v0.23.1)
// - 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)
}
}
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
return
}
if mentionModel != "" {
req.Model = mentionModel
if mentionConfig != "" {
req.ProviderConfigID = mentionConfig
@@ -1127,15 +1164,20 @@ func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPerso
//
// Resolution order:
// 1. Persona handle (exact match, then prefix)
// 2. Model ID in enabled catalog (exact match, then prefix)
// 2. User handle/username (exact, then prefix) — v0.23.1
// When a user is @mentioned in a DM/channel, skip completion and deliver
// a notification instead. Returns non-empty mentionedUserID in that case.
// 3. Model ID in enabled catalog (exact match, then prefix)
//
// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found.
// Returns (modelID, providerConfigID, *Persona, mentionedUserID).
// Exactly one of (modelID, mentionedUserID) will be non-empty on a successful
// match, or all empty if no @mention found/resolved.
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) {
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona, string) {
// Extract first @token
token := extractFirstMention(content)
if token == "" {
return "", "", nil
return "", "", nil, ""
}
// Normalize: lowercase, hyphens
@@ -1155,7 +1197,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
cfgID = *p.ProviderConfigID
}
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID)
return p.BaseModelID, cfgID, p
return p.BaseModelID, cfgID, p, ""
}
}
@@ -1177,13 +1219,42 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
cfgID = *p.ProviderConfigID
}
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID)
return p.BaseModelID, cfgID, p
return p.BaseModelID, cfgID, p, ""
}
}
}
}
// 3. Try model_id in enabled catalog (exact)
// 3. Try username (exact match) — v0.23.1
// Only resolves to a user if the caller is not the same user (no self-mention)
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
LIMIT 1
`), normalized, userID).Scan(&mentionedUserID)
if err == nil && mentionedUserID != "" {
log.Printf("[mention] @%s → user %s", token, mentionedUserID)
return "", "", nil, mentionedUserID
}
// 4. Try username (prefix — unambiguous only) — v0.23.1
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
`), 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
LIMIT 1
`), normalized+"%", userID).Scan(&mentionedUserID)
if mentionedUserID != "" {
log.Printf("[mention] @%s → user prefix %s", token, mentionedUserID)
return "", "", nil, mentionedUserID
}
}
var modelID, provConfigID string
err = database.DB.QueryRowContext(ctx, database.Q(`
SELECT mc.model_id, mc.provider_config_id
@@ -1198,10 +1269,10 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
`), normalized).Scan(&modelID, &provConfigID)
if err == nil && modelID != "" {
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID)
return modelID, provConfigID, nil
return modelID, provConfigID, nil, ""
}
// 4. Try model_id prefix (unambiguous)
// 5. Try model_id prefix (unambiguous)
var prefixCount int
database.DB.QueryRowContext(ctx, database.Q(`
SELECT COUNT(DISTINCT mc.model_id)
@@ -1224,11 +1295,11 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
`), normalized+"%").Scan(&modelID, &provConfigID)
if modelID != "" {
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID)
return modelID, provConfigID, nil
return modelID, provConfigID, nil, ""
}
}
return "", "", nil
return "", "", nil, ""
}
// extractFirstMention finds the first @token in content.

View File

@@ -49,7 +49,7 @@ func (h *CompletionHandler) chainIfMentioned(
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)
mentionModel, mentionConfig, mentionPersona, _ := h.resolveMention(ctx, userID, responseContent)
if mentionModel == "" {
log.Printf("[chain] @%s did not resolve to any model", token)
return

146
server/handlers/folders.go Normal file
View File

@@ -0,0 +1,146 @@
package handlers
// folders.go — Chat folder CRUD (v0.23.1)
//
// Folders are user-scoped groupings for personal (direct) chats.
// Routes:
// GET /api/v1/folders
// POST /api/v1/folders
// PUT /api/v1/folders/:id
// DELETE /api/v1/folders/:id
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
type FolderHandler struct{}
func NewFolderHandler() *FolderHandler { return &FolderHandler{} }
type folderRow struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID *string `json:"parent_id,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (h *FolderHandler) List(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT id, name, parent_id, sort_order, created_at, updated_at
FROM folders
WHERE user_id = $1
ORDER BY sort_order, name
`), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
defer rows.Close()
var folders []folderRow
for rows.Next() {
var f folderRow
if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt); err != nil {
continue
}
folders = append(folders, f)
}
if folders == nil {
folders = []folderRow{}
}
c.JSON(http.StatusOK, gin.H{"folders": folders})
}
func (h *FolderHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
var f folderRow
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
INSERT INTO folders (user_id, name, sort_order)
VALUES ($1, $2, $3)
RETURNING id, name, parent_id, sort_order, created_at, updated_at
`), userID, req.Name, req.SortOrder).Scan(
&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
return
}
c.JSON(http.StatusCreated, gin.H{"folder": f})
}
func (h *FolderHandler) Update(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
var req struct {
Name string `json:"name"`
SortOrder *int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
req.Name = strings.TrimSpace(req.Name)
}
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE folders
SET name = COALESCE(NULLIF($3, ''), name),
sort_order = COALESCE($4, sort_order)
WHERE id = $1 AND user_id = $2
`), folderID, userID, req.Name, req.SortOrder)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *FolderHandler) Delete(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
// Unassign chats before deleting folder
_, _ = database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2
`), folderID, userID)
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM folders WHERE id = $1 AND user_id = $2
`), folderID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -0,0 +1,69 @@
package handlers
// presence.go — Heartbeat upsert and status query (v0.23.1)
//
// Clients POST /api/v1/presence/heartbeat every 30s while active.
// GET /api/v1/presence?users=id1,id2 returns current status.
// Online = last_seen within 90s.
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
const presenceOnlineThreshold = 90 * time.Second
// PresenceHeartbeat upserts the calling user's last_seen timestamp.
func PresenceHeartbeat(c *gin.Context) {
userID := getUserID(c)
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
INSERT INTO user_presence (user_id, last_seen, status)
VALUES ($1, NOW(), 'online')
ON CONFLICT (user_id) DO UPDATE
SET last_seen = NOW(), status = 'online'
`), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "presence update failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// PresenceQuery returns online/offline status for a list of user IDs.
// Query param: ?users=uuid1,uuid2,...
func PresenceQuery(c *gin.Context) {
raw := c.Query("users")
if raw == "" {
c.JSON(http.StatusOK, gin.H{"presence": map[string]string{}})
return
}
ids := strings.Split(raw, ",")
if len(ids) > 100 {
ids = ids[:100]
}
threshold := time.Now().Add(-presenceOnlineThreshold)
result := make(map[string]string, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
var lastSeen time.Time
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT last_seen FROM user_presence WHERE user_id = $1
`), id).Scan(&lastSeen)
if err != nil || lastSeen.Before(threshold) {
result[id] = "offline"
} else {
result[id] = "online"
}
}
c.JSON(http.StatusOK, gin.H{"presence": result})
}